1. 执行参数的整体思路:pytest 的运行选项到底解决了什么问题
1.1 从"全量跑一遍"到"精确控制"的演进
做自动化测试的人,基本都经历过这个阶段:一开始写用例,就是简单粗暴地在项目根目录执行 pytest,让它把所有用例全部跑一遍。项目规模小的时候没什么问题,但用例一旦突破几百上千条,痛点立刻来了。
某些用例只在特定环境里能跑通,某些用例属于冒烟测试必须快速反馈,某些用例跑一次要十几分钟不能每次全量执行。这时候如果还只会单一地执行 pytest,你就会被逼着改代码、写注释、去临时跳过用例——整个流程又慢又容易出错。
pytest 的执行参数体系就是为了解决这些实际问题存在的。它允许你在不修改任何测试代码的前提下,通过命令行选项精确指定:要跑哪些用例、跳过哪些用例、失败后怎么处理、输出信息详细到什么程度、测试过程如何调度。
这意味着什么?意味着执行策略和测试代码彻底解耦。你可以把同一套测试代码,根据不同的场景用不同参数组合出完全不同的执行方案,而不需要维护几份冗余的脚本副本。
1.2 参数设计的底层逻辑:收集、筛选、执行、报告
我用了很长时间 pytest 之后才看清楚,它的参数其实是围绕一条完整链路设计的。理解这条链路,比背参数列表有用得多。
第一步是收集(collection),pytest 会递归扫描指定目录下的测试文件,识别符合规则(默认是 test_*.py 或 *_test.py)的模块、类、函数,把它们整理成一个用例列表。第二步是筛选(filter),根据你给的参数(比如 -k、-m)从列表中过滤出符合条件的子集。第三步是执行(execute),按照顺序或者你指定的调度方式去跑这些用例,中间如果遇到失败,会依据 -x、--maxfail 等参数决定是否继续。第四步是报告(report),把执行结果格式化输出,简单模式还是详细模式还是带耗时统计,都由参数控制。
理解了这个流程之后,你会发现一个很重要的点:很多参数是"分阶段"起作用的。比如 -k 和 -m 是在筛选阶段生效,-x 和 --maxfail 是在执行阶段生效,而 -v 和 -q 只影响报告呈现。如果某个参数不生效,要先判断它应该在哪个阶段起作用,再回顾面的排查逻辑,思路会清晰很多。
2. 最常用的执行参数实测:筛选、输出与失败策略
2.1 用 -k 和 -m 控制"跑哪些用例"
这两个参数是我日常工作中用得最频繁的,但它们各自的定位不同。
-k 参数是表达式匹配,基于用例的名称去筛选。它支持关键字、子字符串、以及用 and、or、not 组合的逻辑表达式。举个例子,假设你有一个支付模块的测试文件,里面的用例命名带有 login、pay、refund 这些关键词,只跑支付相关用例就可以执行:
pytest -k "pay"如果你想跑登录和支付,但排除退款,可以这样写:
pytest -k "login or pay" pytest -k "pay and not refund"这里有个容易踩的坑:-k 匹配的是用例的唯一标识(node id),也就是文件路径、类名、函数名的完整组合,不仅仅是函数名。所以你如果只想匹配某个特定文件里的 pay 用例,直接写 pay 可能会把其他文件里带 pay 的用例也带进来。要精确匹配,就得把路径信息一起写进去:
pytest -k "test_pay_module and pay"我实际项目里更常用的是 -m 参数。它是基于标记(marker)筛选的,比 -k 更规范。使用前需要在测试代码里打标记:
import pytest @pytest.mark.smoke def test_login_success(): pass @pytest.mark.slow def test_export_large_report(): pass然后执行:
pytest -m "smoke" pytest -m "not slow" pytest -m "smoke and not slow"建议在项目里建立统一的标记规范。我参与过的项目一般会定义 smoke、regression、slow、flaky 这几类,对应的边际也清晰:冒烟级别在提交代码后必跑,回归级别在合入分支前跑,慢用例只在夜间跑。使用表达式"smoke and not flaky",就能把不稳定但不影响主流程的用例排除掉。
这里要特别提醒一点:自定义标记如果不注册,pytest 执行时会出现 PytestUnknownMarkWarning 警告。怎么注册,我后面在配置文件那一节会详细讲。
2.2 输出控制:-v、-q、-s 和 --tb 的组合逻辑
很多人觉得输出信息不重要,能看结果就行。但用例数量多、执行时间长的时候,糟糕的输出会严重拖累排查效率。这块调节好了,定位问题的速度能提升一个量级。
默认执行 pytest 的时候,每个用例是一个点(.),失败是一个 F,错误是 E。这种模式在用例少的时候没问题,但你想知道具体哪些用例通过了、哪些失败了,就得用 -v(verbose)模式:
pytest -v实际输出会变成这样:
test_login.py::test_login_success PASSED test_login.py::test_login_failed PASSED每一条都是完整路径加执行结果,一眼就能看到哪个用例出了问题。
-q(quiet)则是反方向,它只在最简模式下给出结果汇总。适合在 CI 脚本里配合大日志量使用,减少流水线日志的噪音。
-s 参数经常被人忽略,但它非常关键。pytest 默认会捕获测试中的 print 输出,用例结束之后才集中显示。问题是,如果你要调试某个正在执行中、长时间跑不完的用例,print 的输出被吞掉了,你根本不知道它跑到哪里了。加上 -s 就是告诉 pytest 不要捕获标准输出,直接实时打印到终端。
--tb 参数控制的是失败堆栈信息的显示格式。我刚用 pytest 的时候,用例一旦失败,一屏的 traceback 刷下来看得脑壳疼。后来发现可以这样:
pytest --tb=short pytest --tb=line pytest --tb=noshort 是精简版,只保留关键调用栈;line 只输出一行错误信息,最适合大量用例连续失败时快速扫一遍;no 是完全不显示堆栈,纯粹看结果统计。这两个参数搭配 -v 一起用,排查效率会高出很多。
我推荐的日常组合是:
pytest -v -s --tb=short信息量适中、不吞日志、堆栈清晰,调试和确认功能都能兼顾。
2.3 失败即停策略:-x、--maxfail 与 --ff 的实战选择
大规模用例集跑起来,最担心的是"失败连锁反应"——一个用例失败导致后续一堆用例也跟着失败,最终报告里红彤彤一片,你根本分不清是根因还是次生灾害。
-x 参数(exitfirst)可以在第一个失败用例出现时立刻终止整个测试会话。它的价值在于快速定位"从哪个用例开始环境就坏了"。如果某次提交引入了严重的环境问题,后续用例跑了也是白跑,不如尽早止损、先看第一个失败的红线。
--maxfail 则更温和一些,它可以指定最多允许失败几个用例之后才终止。比如:
pytest --maxfail=3意思是前 3 个用例失败时继续跑,第 4 个失败出现时才停止。这在处理偶发失败时很有用,你不想因为一个随机超时就叫停整个回归,但也需要一个止损阈值防止灾难扩大。
--ff(failed-first)是另一种思路:它会先执行上一次运行中失败的用例,然后再跑其余用例。这对本地开发调试特别有用——你改了代码,关心的是刚才挂掉的用例现在过没过,而不是从头到尾再看一遍。
这三个参数经常配合使用。比如在本地快速修复场景,我习惯这样:
pytest -x --ff -v先重跑上次失败的用例,一旦遇到失败立刻停,把调试周期压缩到极致。
3. 进阶参数与二次开发:从命令行到底层机制
3.1 分布式执行与 -n 参数:并行产生的利与弊
用例多了之后,单线程跑太慢,该怎么办?pytest 生态里最成熟的方案就是 pytest-xdist 插件,它提供 -n 参数来指定并行进程数:
pip install pytest-xdist pytest -n 4这表示用 4 个进程并发执行用例。如果写-n auto,pytest 会根据 CPU 核数自动决定进程数。
分布式执行带来的提升很明显,但它不是银弹。我在给团队搭这套环境的时候就踩过不少坑,这里重点提醒三个问题:
第一个是测试数据隔离。假设你有 4 个进程同时跑,用例都往同一个测试数据库里写脏数据,一定会互相干扰。解决思路是给每个进程分配独立的数据环境,或者用 xdist 的--dist=loadgroup之类的分组策略限制并行范围。
第二个是输出乱序。并行执行时 -v 的输出不再按照用例文件顺序排列,而是哪个进程先跑完就先打印,最终报告有时看起来是乱的。这个不影响结果正确性,但确实会让初次使用者困惑。
第三个是依赖关系。xdist 默认假定用例之间互相独立。如果你的用例之间有共享状态或者顺序依赖,并行执行就会跑挂。这种情况需要重构用例设计,让它们具备可独立运行的能力。
如果只是想在本地快速跑通一组用例,又不想引入分布式复杂度,也可以先靠并发进程数小一点,比如:
pytest -n 2 -v并行数量并不是越大越好,IO 密集型的测试、CPU 密集型的测试对进程数的最优值不同,需要根据实际场景做压测来定。
3.2 复用执行状态:--lf、--cache 与测试缓存的秘密
pytest 有一个不太起眼但作用非常大的特性:它会缓存上一次执行的结果。这个缓存的载体就是参数--cache-more或者默认的.pytest_cache目录。很多项目的 .gitignore 里都把这个目录忽略了,但它的价值远不止存一个运行记录那么简单。
--lf和前面讲的--ff都是基于这个缓存机制工作的。--lf(last-failed)只运行上一次失败的用例,而--ff会先运行失败用例再运行全部用例。基于这套机制,你还可以用--co(collect-only)看当前缓存里用到的用例集合:
pytest --co -q这里有个很实用的玩法:缓存目录里有一个v/cache/lastfailed文件,存的是上次失败用例的 node id 列表。如果你用的 CI 系统不支持传入复杂参数,可以直接读取这个文件来构造下一次的定向执行命令。
不过要提醒一点:缓存是基于"上一次执行"的,如果测试代码发生变化,node id 可能会变,缓存的失效判断以代码文件路径和函数名组合为依据。如果改了文件名或者函数名,旧的缓存记录就找不到对应的用例了,--lf就会退化为全量执行。这不算 bug,是设计如此,理解它就不会觉得奇怪。
3.3 自定义命令行参数:通过钩子函数注入自己的选项
pytest 的命令行参数体系并没有封死扩展能力。相反,它提供了一套完整的 hooks 机制,让你可以很优雅地注册自己的参数。
最常见的场景是:测试环境地址、数据库连接串、是否开启某个实验性开关,这些信息不想写死在代码里,也不方便放到环境变量里,那就把她们做成 pytest 参数。
下面是一个标准写法。我们需要在自己的 conftest.py 里定义参数,然后在 fixture 里读取它:
# conftest.py import pytest def pytest_addoption(parser): parser.addoption( "--env", action="store", default="test", help="specify the test environment: test/staging/prod", ) parser.addoption( "--run-demo", action="store_true", default=False, help="run demo tests that are usually skipped", ) @pytest.fixture def env(request): return request.config.getoption("--env")然后在测试用例里直接依赖这个 fixture:
# test_demo.py def test_check_env(env): assert env in ["test", "staging", "prod"]执行的时候就可以这样传参数:
pytest --env=staging -v这套机制的精髓在于 pytest_addoption 这个钩子函数:它会在命令行解析阶段被调用,把自定义参数加入解析器。而在 fixture 里通过 request.config.getoption 取出参数值,就能在测试代码里平滑使用。
比环境变量更优雅的是,你的自定义参数还可以和 pytest 内置的 addopts 配置一起写进配置文件,让团队的默认执行计划保持一致。这一点在 CI 集成时非常有用。
4. 配置文件与实战组合:把参数策略固化到项目里
4.1 addopts:把常用参数写进配置文件
每次执行 pytest 都手动敲一长串参数,终究不是长久之计。pytest 支持通过配置文件把这些参数固化下来,新手可能不知道,但项目落地之后这是必做的一步。
pytest 的配置可以写在 pytest.ini、pyproject.toml 的 [tool.pytest.ini_options] 段、或 setup.cfg 中。推荐使用 pytest.ini,因为它最直观、不会跟其他工具的配置混淆。
一个常见的项目配置长这样:
[pytest] addopts = -v -s --tb=short --maxfail=5 testpaths = tests python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* markers = smoke: smoke tests regression: regression tests slow: tests that take a long time flaky: tests that are flakyaddopts 这一行的意思就是:每次执行 pytest 时自动附加这些参数,不需要再手动敲。testpaths 指定了搜索路径,避免 pytest 扫描整个项目目录浪费时间。python_files 限定了哪些文件算测试文件。markers 则把我们前面自定义的标记都注册了,避免出现 PytestUnknownMarkWarning。
有个细节值得注意:addopts 里加 -v 之后,如果你某次执行想用 -q,后面再手动加 -q 并不会覆盖前面的 -v,两个参数同时存在时以哪个为准?实测结果是后出现的参数生效,所以pytest -v -q最终输出是 quiet 模式的。总之配置 addopts 要谨慎,尽量只配置那些你不希望每次都手动敲的参数。
另外一个容易踩的坑:如果 pytest.ini 里有 addopts = -x,而你某次执行想临时忽略这个参数,没有那么直接的办法(除非用-o addopts=""或者-p no:cacheprovider之类绕路)。操作层面更稳妥的做法是手动覆盖,或者临时用其他 pytest 配置文件。比如可以直接执行:
pytest -o "addopts=" -v这样会清空配置里的 addopts,再手动指定参数,就绕开了配置和命令行的叠加问题。
4.2 标记注册与自定义标签体系的搭建
标记(markers)在 pytest 里不仅是筛选工具的底层支撑,它还是测试分层的重要组织方式。我见过很多项目,用例看起体量很大,但全铺在一个"测试集"里,连哪些是冒烟、哪些必须跑回归都无法区分。这种项目的后期维护成本是成倍增长的。
建立一个合理的标记体系,第一件事就是分层。冒烟层(smoke)跑最重要的 10~20 条用例,任何一次提交都要保证它们全绿。回归层(regression)相对完整,每次合入分支前跑一遍。慢速层(slow)是那些耗时较长、涉及外部服务的用例,它们只在夜间任务里跑。
第二件事是描述性注册。在 pytest.ini 里给每个 marker 写一句人类可读的描述,比什么都不写强太多。这样执行pytest --markers时,团队里的每一个人都能快速看到每个标记的意义。
第三件事是善用 pytestmark 做模块级标记。如果某个文件里所有用例都属于同一层,不需要每个函数都加 decorator,直接在模块顶部声明:
import pytest pytestmark = pytest.mark.slow def test_export_big_report(): ... def test_import_many_records(): ...这两个用例在收集阶段都会自动带上 slow 标记,省去重复装饰的噪音。
标记体系的另一大用途是配合条件跳过。比如某个用例只在该标记对应的服务可用时执行,就可以在 conftest.py 里用一个 fixture 判断环境并调用pytest.skip。这样做的好处是:执行逻辑还是同一套参数,但环境不满足时用例不会被硬塞进失败统计里,而是明确标记为 skipped,报告语义更清晰。
4.3 组合实战:一个 CI 场景的参数设计
结合上面的各种参数,我拿一个实际场景来串一下。假设团队有一个中型 Web 项目,测试分三级:冒烟、回归、耗时任务。版本发布前 CI 要做两轮测试。
第一轮是提交触发(commit 触发),执行冒烟和快速回归。配置文件这样设计:
[pytest] addopts = --tb=short --maxfail=10 testpaths = tests脚本里执行:
pytest -m "smoke or regression" -v --env=dev这样每次提交都看核心功能是否受影响。如果某一轮崩了,CI 会终止,日志在截断堆栈后依然能追溯到具体失败用例。
第二轮是夜间任务(nightly),执行全量耗时用例:
pytest -m "slow" -v --env=test -n 4 --dist=loadscope pytest -m "not slow" -v --env=test --tb=long第一行用 xdist 的分布式能力把慢用例并行调度起来,第二行把剩余用例串行跑完。整体流程的耗时压得非常低。执行完后 CI 收集结果,用pytest --junitxml=report.xml输出 JUnit 格式的 XML 结果,方便上层平台聚合展示。
这套参数设计里面,最有价值的是通过标记把用例分成不同层级、不同执行频率。理论上,你可以不写任何自定义参数,仅靠 -m 的排列组合就搭建出完整的测试策略分层结构。这也是 pytest 最核心的设计哲学:用一套灵活的筛选语法替代死板的全量执行逻辑。
5. 常见问题与排查技巧实录
5.1 参数不生效的排查思路
参数不生效这个问题,我碰到过太多次了,每次都是几个固定原因。
第一个原因是配置文件和命令行参数叠加导致的"意外覆盖"。如果你在 pytest.ini 的 addopts 里写了-x,某次执行临时不想让它停,手动加参数也不一定覆盖成功。遇到这种情况,优先检查pytest -h打印出来的实际默认值,看看解析结果里有哪些参数已经被配置固定了。
第二个原因是插件的优先级干扰。pytest 的生态里有很多插件会影响收集和执行行为,比如 pytest-randomly 会打乱用例顺序、pytest-dependency 会改变执行依赖关系。这些插件如果默认激活,你在命令行传的-k、-m筛选结果可能就和你预想的不一样了。排查时先临时禁用一个插件,例如用-p no:randomly禁用,看看行为是否恢复,思路就清晰了。
第三个原因是大小写和表达式语法。pytest -k "Login"和pytest -k "login"匹配的结果完全不一样,因为的表达式默认是大小写敏感的。有时候你以为写对了表达式,实际匹配了 0 个用例,命令行却没有任何提示,只是安静地报告"no tests ran"。这个坑我至今还会偶尔踩到,所以建议在筛选参数后一定先看一眼收集到的用例数量,不要直接往下跑。
5.2 标记警告处理
运行 pytest 时如果看到PytestUnknownMarkWarning,意味着你使用了内部定义但未注册的标记。虽然警告不妨碍用例执行,但很不干净,而且随着项目积累会变成噪音,掩盖真正的告警。
解决办法就是前面说的,在 pytest.ini 里把标记全部注册出来。如果你用的不是 pytest.ini 而是 pyproject.toml,写法如下:
[tool.pytest.ini_options] markers = [ "smoke: smoke tests", "regression: regression tests", "slow: tests that take a long time", ]把标记集中管理还有个好处:团队成员用 IDE 看代码时,能通过 AutoComplete 快速找到所有合法标记,不会自己发明新记号。
如果某个标记真的只是临时用一下,又不想为它单独注册,也可以用pytest.mark.skipif(True, reason="temporary")、pytest.mark.xfail这类内置标记替代,原则上不要频繁发明新标记。
5.3 快速定位问题用例的三个小技巧
案例一:用例太多输出太长,根本找不到失败在哪。解决办法是用--tb=line跑一遍,把每个失败的出错信息压缩成一行,先锁定大概问题方向,比如断言错误还是超时,再用-k "失败用例名"定向复跑。
案例二:偶现失败用例难以复现。可以借助 pytest-repeat 插件做压力复现:
pip install pytest-repeat pytest -k "flaky_test_name" --count=10 -x --tb=short连续跑 10 次,只要出现一次失败就立刻终止并打印堆栈。这类场景里,--count参数配合-x的组合效果非常好。
案例三:想搞清楚某个用例具体执行了哪些步骤,但又不想看完整回溯。除了加-s放行 print 日志,还可以在 fixture 里加入一个调试级别的日志记录器,用--log-cli-level=DEBUG实时输出日志。这是我一直推介的组合,尤其是面对集成测试、链路长的用例时,效果比单纯 print 好得多。
写在最后的经验之谈
用了这么多年 pytest,最大的体会是:执行参数的威力不在于单条命令有多炫,而在于你能把参数组合成一套符合项目节奏的执行策略。刚开始接触的人往往会迷恋各种冷门参数,拿去看一堆花里胡哨的输出,但真正稳定的团队实践通常很朴素——清晰地分层、克制地选参、固定地沉淀到配置文件里。
我个人每次搭新项目,都会在一开始就定好标记体系和默认配置,而不是等用例多了再补。原因很简单:等用例写到几百条再回头拆分执行策略,改造成本远高于一开始就按烟囱、回归、慢速分开管理。另外,参数用的多也不代表好,如果一条命令长到需要换行写,不如重新审视是否有更简洁的表达方式。
最后分享一个小技巧:pytest --fixtures -v可以列出当前项目所有可用的 fixture 及说明。它不是执行参数,但调试参数不生效时特别好用——能看清哪些 fixture 被自动加载了、作用域是什么。很多时候参数的问题不是出在命令行本身,而是 fixture 之间互相干扰。懂得用这个命令,排查效率至少提升一倍。