pytest_addoption详解:用conftest.py自定义命令行参数实现测试环境切换与用例筛选

发布时间:2026/10/12 11:04:59
pytest_addoption详解:用conftest.py自定义命令行参数实现测试环境切换与用例筛选
1. pytest_addoption 到底在解决什么问题先说个场景。你维护着一个测试工程里面有好几百个用例跑一次全量可能要十几分钟。某天你只想跑某个环境的冒烟用例或者只想验证数据库相关的几条用例怎么办无非是加个-k表达式过滤或者临时改代码里的环境变量。但改代码这件事在测试工程里是最忌讳的——改完忘了改回来CI 上直接跑错环境这种事我在团队里见过不止一次。pytest 的pytest_addoptionHook 就是用来解决这个问题的它允许你在 pytest 启动时注册自定义的命令行参数然后通过request.config.getoption()在 fixture 和用例里读取。说白了就是给 pytest 这个执行框架加一个你自己定义的开关让它按你的意图来跑测试。这个 Hook 是 pytest 插件体系里最基础、也最常用的几个入口之一。它做的事情很纯粹注册参数。但真正用起来你会发现自己其实是在设计一个测试执行协议——哪些用例按什么条件跑、跑的时候带什么配置、在什么环境下跑这些都应该由命令行参数来控制而不是写死在代码里。这篇文章我会从 Hook 方法的注册机制讲起接着写完整的实现步骤再给几个我在实际项目中用过的组合案例最后把我在这个 Hook 上踩过的坑一并列出来。适合刚接触 pytest 插件开发的人也适合已经写过一些 fixture、但对 Hook 体系还比较模糊的同学。2. 从 conftest.py 说起pytest 插件的入口文件2.1 为什么是 conftest.pypytest 有一个很重要的设计conftest.py 是测试目录的本地插件文件。你写在 conftest.py 里的 fixture、Hook 方法会被 pytest 自动发现并注册不需要手动 import也不需要像第三方插件那样通过pytest_plugins声明。它遵循的是就近生效的原则——放在根目录的 conftest.py 对整个测试 session 生效放在某个子目录里的 conftest.py 只对该子目录下的用例生效。pytest_addoption通常就写在根目录的 conftest.py 里。为什么因为命令行参数是全局的它必须在测试执行之前就被解析而且它影响的是整个 session 的执行策略——比如环境选择、是否需要失败重跑、是否生成某个类型的报告这些显然都是全局的。如果你把pytest_addoption写到子目录的 conftest.py 里就很容易踩到一个隐蔽的坑pytest 只有当它收集到该子目录下的用例时才会加载这个 conftest.py而在收集阶段可能根本不会触发。结果是参数没注册用法直接报 unrecognized arguments。所以我的建议是凡是命令行参数注册一律放根目录 conftest.py不要为了一时方便放在子目录。2.2 Hook 方法的注册时机pytest 的 Hook 体系是分层调用的。pytest_addoption属于PytestPluginManager级别的 Hook它在 pytest 启动早期、解析命令行参数的时候就会调用。这里有个关键点pytest_addoption的执行早于 fixture 的创建也早于用例的收集。这意味着你在pytest_addoption里做的任何操作都必须是在参数还没被真正使用之前完成。举一个实际例子就明白了# conftest.py import pytest def pytest_addoption(parser): parser.addoption(--env, actionstore, defaulttest, help指定运行环境: test/staging/prod)这个--env参数注册之后你在任何 fixture 或者测试用例里都可以通过request.config.getoption(--env)拿到它的值。注意参数名要带两个横线的原样字符串不是把横线去掉后的名字。这个用法看起来很简单但它背后隐藏着 pytest 插件机制的运作逻辑pytest_addoption 是自定义 pytest 命令行的起点几乎所有的 pytest 插件都是从这里开始接入的。比如 pytest-selenium、pytest-rerunfailures、pytest-html它们的核心参数注册都是在这个 Hook 里完成的。你如果想给自己的测试框架增加可配置性理解这个 Hook 就是第一步。3. 一个带【参数校验】的完整例子3.1 注册参数与读取参数还是用--env这个参数来演示。但这次我不只注册它还要对它做校验——因为实际项目中参数值的合法性太重要了。你不可能让用户随便传一个--env whatever进去然后用例跑起来才发现环境不对。pytest_addoption 的parser对象提供了几个方法最常用的是addoption和addini。前者注册命令行参数后者注册 ini 配置文件里的选项一般用不到我们先把addoption讲透。# conftest.py import pytest def pytest_addoption(parser): parser.addoption( --env, actionstore, defaulttest, help指定运行环境: test/staging/prod, choices[test, staging, prod], )注意这里的choices参数。它是 argparse 自带的能力pytest 的 parser 直接复用了 argparse 的机制。传了choices之后如果你在命令行里输入pytest --env weirdpytest 会在启动阶段就报错退出错误信息会提示你合法的选项是什么。这种校验放在入口处是最好的不要等到用例执行到中途才报错。读取参数的方式有两种取决于你所在的位置# 在 fixture 里读取 pytest.fixture(scopesession) def env(request): return request.config.getoption(--env) # 在测试用例里直接读 def test_something(request): env request.config.getoption(--env) assert env in (test, staging, prod)request是 pytest 内置的 fixture几乎在所有需要访问配置信息的场景里都能用。它的config属性就是当前 session 的配置对象getoption()方法专门用来读取通过addoption注册的参数。3.2 参数的 action 类型不只是 storeaction参数是另外一个容易忽略但很重要的点。默认是store就是存一个字符串。但在实际项目中我经常会用到另外两种actionstore_true开关型参数不需要传值。比如--headed表示浏览器有头模式--skip-slow表示跳过慢用例。读取结果是布尔值。actionappend可重复传值。比如--include-area api --include-area pay读取结果是一个列表适合多选场景。def pytest_addoption(parser): parser.addoption(--headed, actionstore_true, help浏览器有头模式运行) parser.addoption(--exclude-area, actionappend, default[], help排除指定业务域可多次传入)这里有一个细节actionappend时建议显式指定default[]否则当参数一次都没传的时候你读取到的是None后面做遍历的时候还得先判空。显式指定默认值可以少写不少防御代码。再看读取端。比如你定义了一个exclude_areasfixturepytest.fixture(scopesession) def exclude_areas(request): return request.config.getoption(--exclude-area)那么你可以在用例里高效地跳过某些不需要执行的用例def test_pay_flow(exclude_areas): if pay in exclude_areas: pytest.skip(当前排除支付域) # ... 实际的用例逻辑3.3 参数注册与 fixture 结合一个可配置的 fixture把参数和 fixture 组合起来是整个 pytest 工程可配置性的核心。这里我给一个完整的基于环境配置选择测试数据的例子# conftest.py import pytest ENV_CONFIG { test: {base_url: http://test.example.com, db_name: test_db}, staging: {base_url: http://staging.example.com, db_name: staging_db}, prod: {base_url: http://example.com, db_name: prod_db}, } def pytest_addoption(parser): parser.addoption( --env, actionstore, defaulttest, choiceslist(ENV_CONFIG.keys()), help指定运行环境, ) pytest.fixture(scopesession) def env_config(request): env request.config.getoption(--env) return ENV_CONFIG[env] pytest.fixture(scopesession) def base_url(env_config): return env_config[base_url]这样你的用例里不需要出现任何和环境相关的硬编码。测试用例只依赖base_url这个 fixture至于跑在哪个环境完全由命令行决定pytest --env staging tests/test_api.py这样做的好处是环境切换不碰代码只碰命令行。对于测试团队来说这意味着代码评审的时候不会出现因为改环境配置而误伤测试逻辑的情况。4. 进阶用法参数联动与动态跳过4.1 根据参数筛选用例单纯的参数注册不难难的是参数影响用例执行。pytest 的pytest_collection_modifyitemsHook 可以和pytest_addoption配合实现按参数动态筛选用例。下面这段代码解决了一个很实际的需求运行时不希望看到某些慢用例尤其是本地调试时。# conftest.py import pytest def pytest_addoption(parser): parser.addoption(--run-slow, actionstore_true, help运行标记为 slow 的用例) def pytest_collection_modifyitems(config, items): if config.getoption(--run-slow): return # 如果显式指定运行慢用例就不做筛选 skip_slow pytest.mark.skip(reason需要 --run-slow 才会执行) for item in items: if slow in item.keywords: item.add_marker(skip_slow)这个模式在 pytest 社区里很常见。关键是理解它的设计思路默认跳过慢用例只有你显式请求时才运行。这是因为在本地开发时程序员只关心自己改动的模块不希望每次跑用例都要等 5 分钟去跑全量慢用例而在 CI 里你可以单独跑一条 job 带--run-slow去覆盖慢用例。同理你也可以用--exclude-area这种参数配合pytest_collection_modifyitems做动态剔除def pytest_collection_modifyitems(config, items): exclude_areas config.getoption(--exclude-area) if not exclude_areas: return remaining [] for item in items: markers {marker.name for marker in item.iter_markers()} if markers set(exclude_areas): item.add_marker(pytest.mark.skip(reasonf排除区域: {exclude_areas})) else: remaining.append(item) # 注意如果修改 items 列表内容需要清空后重新扩展 items[:] remaining这里我额外强调一个容易踩的坑在pytest_collection_modifyitems里如果你直接对items列表做items remaining是无效的——你在局部变量里重新绑定了一个新列表pytest 拿到的还是原来的那个列表对象。必须用items[:] remaining这种切片方式去修改原列表。4.2 多参数组合造一个测试执行配置在实际项目中单独一两个参数往往不够。我做过一个移动端测试框架命令行参数有十几个环境、设备平台Android/iOS、是否安装包、是否重置数据、是否开启录制、指定测试数据版本……如果全部散落着用request.config.getoption()会调用得非常混乱。此时我建议做一个聚合的 fixture把参数集中起来管理。比如pytest.fixture(scopesession) def runtime_config(request): 聚合所有命令行参数为一个配置对象 return { env: request.config.getoption(--env), platform: request.config.getoption(--platform), install_apk: request.config.getoption(--install-apk), reset_data: request.config.getoption(--reset-data), record_video: request.config.getoption(--record-video), }于是测试用例里的依赖变得非常干净def test_login(runtime_config): if runtime_config[platform] android: # 执行 Android 的登录流程 else: # 执行 iOS 的登录流程这种集中式配置对象的写法比用例里到处调用request.config.getoption()要清晰得多。它本质上是把参数读取这件事收敛到了一个 fixture 里后续如果参数变化只需要改 fixture不需要去改几十个用例。5. 踩坑笔记这些错误我犯过不止一次5.1 参数名写错--env还是envrequest.config.getoption()里既可以写--env也可以写env不带横线两者都能工作。但问题是如果你在addoption里定义了一个长参数--server-port你在读取时写getoption(server_port)就会出问题。pytest 内部会做参数名规范化但我实测下来发现最稳妥的方式是永远用注册时的完整参数名去读取也就是--server-port原样传入。别去依赖它内部的规范化逻辑省得自己在不同写法和规范之间绕来绕去。另外不要把参数名弄得和 pytest 内置参数重名。比如--log-level这种pytest 自带日志功能的参数已经用了这个名字你再注册一个同名参数轻则报错重则在某些版本里静默覆盖了内置行为排查起来非常费劲。如果真需要此类参数换个名字比如--case-log-level。5.2 fixture 的使用顺序问题pytest_addoption是在命令行解析阶段被调用的任何 fixture 的实例化都发生在这之后。但你仍可能碰到一种奇怪的情况在 fixture 里读取一个未注册的参数时不报错而是返回None。为什么因为某些第三方插件可能也在同一个 Hook 里注册参数如果你的 conftest.py 加载顺序不对参数可能还没来得及注册。此时你读取参数pytest 不会抛异常——getoption对不存在的参数返回一个默认值。遇到这种情况我的排查步骤是确认参数注册代码在根目录 conftest.py而不是子目录。用pytest --help查看输出的参数列表确认它是否出现在 custom options 部分。查看是否有其他插件注册了同名参数。pytest --help这一步是排查参数注册问题的最直接手段。如果你在输出里看不到自己的参数那问题一定在注册阶段而不是读取阶段。5.3addoption的 default 类型如果参数是store_true那它的默认值天然是False你不用、也不应该给它传default。但如果你给store_true传了defaultTrue会得到永远为真的效果整个开关失去意义。如果你用actionappend记住设置default[]否则第一次使用--xxx value时返回值是None而不是[value]。最后一个细节default 的值类型最好和 action 匹配。actionstore下default 可以是一个字符串如果你传了一个列表进去读取出来就是列表后续逻辑很容易踩到类型不匹配的坑。6. 从pytest_addoption到自定义插件扩展路径6.1 不只是 conftest.py做成可安装的插件conftest.py 适合放在项目内部使用。如果你的参数和 Hook 逻辑做得比较通用想分享给多个测试工程可以考虑把它封装成一个可安装的 pytest 插件。插件的形式很简单my_pytest_plugin/ ├── my_pytest_plugin/ │ └── __init__.py ├── pyproject.toml └── setup.py在__init__.py里直接定义 Hook 方法def pytest_addoption(parser): parser.addoption( --env, actionstore, defaulttest, choices[test, staging, prod], ) def pytest_collection_modifyitems(config, items): env config.getoption(--env) # 根据环境动态调整用例然后在pyproject.toml里声明入口点确保 pytest 能发现这个插件[tool.pytest.ini_options] # 无特殊配置 [project.entry-points.pytest11] my_plugin my_pytest_pluginpytest11是 pytest 插件的标准入口点名称。安装之后插件里的pytest_addoption会自动被调用你可以在任何测试工程里使用--env参数不需要再写 conftest.py。6.2 插件和 conftest.py 的加载顺序这是很多人在写插件时都会困惑的地方conftest.py 里的 Hook 会覆盖插件里的同名 Hook 吗答案是不一定取决于 Hook 的调用机制。pytest 的 Hook 采用的是 LIFO后进先出的调用顺序——也就是说先加载的模块其 Hook 会相对晚被调用后加载的模块反而先被调用。如果你在插件和 conftest.py 里同时实现了pytest_addoption两者的代码都会执行但注册顺序可能影响同名参数的注册结果。我的建议是项目内不要和插件注册同名参数。否则你会发现某天升级插件版本后参数的行为变了而你根本不知道是哪里出了问题。回到pytest_addoption本身它是个元编程级别的 Hook。你写的不是测试用例而是测试框架的配置入口。从这个角度去理解它你就能明白为什么 pytest 官方把插件机制设计得如此灵活——因为你完全可以通过一个 Hook 去定义你自己的测试执行语义而不用改动 pytest 的任何内核代码。我在自己的测试框架里用pytest_addoption实现了环境切换、设备选择、测试数据版本控制、失败重跑开关、报告输出路径定义等功能。这些功能没有一个是炫技全部是测试工程里最真实、最频繁的需求。它们共同的编程模式就是注册一个参数 - 在 fixture 里读取 - 根据参数动态调整用例行为。当你能熟练运用这个模式你会发现你的测试工程开始有了配置化的味道——分析问题、临时调整测试策略、多环境并行执行都变得简单可维护。希望这篇文章能帮你把这个 Hook 用起来彻底解决测试命令不可定制的痛点。