测试做得越久,越会发现真正拉开效率差距的不是写用例本身,而是对测试框架底层机制的理解。pytest进阶这个标题看起来像是讲几个隐藏API,实际上围绕的是fixture的作用域与生命周期、parametrize的参数化计算方式、mark机制的条件控制,以及一套可以自定义的hook链路。想解决线上问题先能复现、改一处公共逻辑不想影响二十个用例、跑一遍全量测试从半小时压到三分钟这些事,靠的都是把pytest从“会用”推进到“会用透”。这篇东西原本是我整理给自己团队的工程笔记,压成一篇长文放出来,适合已经写过一段时间pytest、但总觉得用例之间藕断丝连、并发一开就挂、或者想把手头重复的测试逻辑沉淀成内部插件的同学。文中所有代码都是我实际跑过的,直接抄就能用。
1. 工程化布局与fixture的进阶玩法
1.1 把fixture改造成工厂,解决“每条用例要的数据不一样”
很多项目里的fixture长这样:
@pytest.fixture def user(db): return db.create_user(name="tester", age=20)这个fixture看似方便,但用上几次就难受了。十条用例需要十个不同昵称的账号,就只能在测试函数内部再调一次创建逻辑,fixture形同虚设。我见过不少团队的conftest.py因此膨胀出几十个fixture,每个都是某个特定账号的变体,维护成本极高。
更好的做法是让fixture返回一个工厂函数,把具体参数留给调用方:
@pytest.fixture def make_user(db): created = [] def _make_user(name, age, **kwargs): user = db.create_user(name=name, age=age, **kwargs) created.append(user) return user yield _make_user for user in created: db.delete(user)这里有两个关键点。一是fixture用yield而不是return,因为yield之前的部分是setup,yield之后的部分是teardown,这样创建的临时用户可以在用例结束后统一清理,不会越测越脏。二是created列表存在fixture闭包里,跨用例隔离,不会出现A用例创建的用户跑到B用例里去。
工厂化的fixture一样能和其他fixture组合。比如注册流程要求同时产生一个店铺和一个店主,那就再包一层:
@pytest.fixture def make_store_with_owner(db, make_user): def _make_store(name, owner_name): owner = make_user(name=owner_name, age=30) store = db.create_store(name=name, owner_id=owner.id) return store, owner return _make_store这套模式的本质是“先描述能力,再按需调用”,fixture本身不产生数据,只有测试真正调用时才产生。这避免了fixture自动执行导致大量无用数据堆积,也让数据条件一目了然。
注意:工厂函数内部创建的资源,尽量还是集中在fixture里统一回收,不要指望用例自己清理。否则出现断言失败、用例中途return,清理代码很可能被跳过。
1.2 作用域与autouse的边界,不要拿到session就到处挂
pytest的fixture作用域有function、class、module、package、session五档,优先级从低到高。function作用域每次用例都重建,session作用域整个测试会话只建一次。很多刚进阶的同学一看到session就两眼放光,把数据库连接、配置对象全都挂上去,结果就是用例之间互相污染,排查起来非常痛苦。
如果你确实要跨用例复用连接,至少要意识到两点。
第一,session级fixture不会随参数化重新计算。比如一个session级fixture依赖某个参数化过的fixture,pytest会在第一次创建后就把结果缓存住,后续参数组合拿到的都是同一份。这个问题极端隐蔽,我从外部数据源读取配置时踩过,查了半天才发现是作用域层级不匹配。解决方法很简单:要么把“计算一次”的公共数据放session,把“每条用例独立”的数据放function,绝不混用。
第二,autouse不是免死金牌。autouse的语义是“当前作用域下所有用例自动注入”,但很多人忽略了它也是分作用域的,把autouse写在conftest里的function作用域,意味着每个测试都会执行它。如果你在里面做了耗时操作,整个测试套件会被拖慢好几倍。我的习惯是:只对像环境变量打桩、日志格式、临时目录这类“所有用例都必须有且无副作用”的操作使用autouse,其余一律显式声明。
内置fixture里最值得花时间的是tmp_path、monkeypatch和capsys。tmp_path天然为每条用例提供独立临时目录,避免测试写文件互相覆盖;monkeypatch用来替换外部依赖的返回值,比如把datetime.now()换成固定时间;capsys捕获标准输出和错误输出,断言日志是否符合预期。这三个配合工厂化fixture,基本能覆盖八成的测试隔离场景。
1.3 并发下的fixture隔离,xdist一开就挂的根源
pytest-xdist是很多项目必装的插件,但装上之后常常出现“单线程全绿,多线程红灯”。最典型的原因是session级fixture被每个worker重复执行了一次。xdist默认把测试分发给多个worker进程,每个worker都是独立的Python解释器,session级fixture自然在每个worker里各跑一遍,不是在主进程里跑一次然后分发。
所以千万别写这种代码:
@pytest.fixture(scope="session") def global_config(): cfg = load_config() cfg.cleanup() return cfg你以为是全局只跑一次,实际是每个worker都cleanup一次,数据竞争就来了。想确认当前是哪个worker,可以用内置的worker_id,主进程返回master,各worker返回gw0、gw1等:
import os @pytest.fixture(scope="session") def worker_tmp(tmp_path_factory): worker = os.environ.get("PYTEST_XDIST_WORKER", "master") return tmp_path_factory.mktemp(worker)我建议从两个方向解决并发下的fixture问题。第一,把“并发会互相干扰的公共资源”从session级降级成function级,虽然会损失一些性能,但正确性优先。第二,对数据库这种重资源,用连接池而不是单个连接挂在session级fixture上,并且每个测试显式开启事务回滚的隔离模式。在pytest里我常用fakeredis这类内存实现做高速隔离,这比真实服务快得多,也更稳。
另一个坑是临时文件目录。默认的临时目录由pytest生成,如果用tmp_path,天然按用例隔离,不会串。但如果是直接写/tmp/abc这种固定路径,并发时两个worker同时读写同一个文件,必炸。要用固定的临时目录,请跟worker_id关联,确保每个worker有自己的子目录。
2. parametrize与mark机制,把用例变成数据矩阵
2.1 parametrize的隐藏技能:ids、字典解包与组合计算
parametrize是pytest里提升覆盖率性价比最高的功能。基础用法是把一组数据变成多条用例:
import pytest @pytest.mark.parametrize("num, expected", [(1, 2), (3, 6), (10, 11)]) def test_double(num, expected): assert num * 2 + 1 == expected但很多人不知道几个进阶用法。第一,参数可以是一整个dict,测试函数里用关键字参数解包:
@pytest.mark.parametrize("case", [ {"num": 1, "expected": 3}, {"num": 3, "expected": 6}, ]) def test_from_dict(case): assert case["num"] * 2 + 1 == case["expected"]第二,ids参数可以替换测试ID的显示名。默认情况下,测试节点ID就是参数值的repr,遇到长字符串或者包含特殊字符时,命令行和HTML报告里一串乱糟糟。用ids把它变成可读的标签:
@pytest.mark.parametrize( "num, expected", [(1, 3), (3, 6)], ids=["case-positive-1", "case-positive-3"], ) def test_with_ids(num, expected): assert num * 2 + 1 == expected也可以传一个函数,接收参数值返回字符串,适合批量生成可读名。
第三,多个parametrize装饰器叠加时,用例数量是笛卡尔积而不是并行相加:
@pytest.mark.parametrize("a", [1, 2]) @pytest.mark.parametrize("b", [10, 20]) def test_cartesian(a, b): pass上面会产生4条用例,顺序为(1,10)、(1,20)、(2,10)、(2,20)。如果你只是想两两配对,不要叠加装饰器,要在一个parametrize里用zip或者元组展开。这个坑非常常见,我见过测试矩阵莫名其妙翻倍,最后发现是装饰器叠加逻辑搞错了,本来20组数据直接变成400条用例。
parametrize还有一个indirect参数,作用是让参数值传给同名fixture而不是测试函数。这在高阶场景里很实用,比如“不同参数对应不同fixture数据源”:
@pytest.fixture def user(request): return request.param @pytest.mark.parametrize("user", [{"name": "A"}, {"name": "B"}], indirect=True) def test_user(user): assert user["name"]indirect=True时测试函数里的user不是直接拿到字典,而是拿到fixture处理之后的结果。这个能力用来把参数和fixture串起来,非常顺手。
2.2 mark的注册、跳过与条件执行,别再用裸mark
pytest的mark是一套轻量标签机制,但它有一个很多团队都没做好的前提:mark应该先注册,否则只是靠字符串硬顶。没有注册的mark,在pytest启动时会飘出一堆PytestUnknownMarkWarning,而一旦开启--strict-markers,整个测试直接报错。
我建议团队里统一在pytest.ini里维护mark清单:
[pytest] markers = slow: 标记耗时用例,默认不运行 smoke: 冒烟测试集 api: 接口测试 unit: 单元测试然后命令行用-m参数做精准选择:
pytest -m "smoke" # 只跑冒烟 pytest -m "not slow" # 跳过慢用例 pytest -m "api and not slow" # 接口但排除慢跳过逻辑也有讲究。pytest.mark.skipif适合“环境不满足时跳过”,比如只在Windows下执行的用例:
import sys @pytest.mark.skipif(sys.platform != "win32", reason="仅Windows环境验证") def test_windows_only(): pass还有一个被我经常用的:模块级importorskip。当某个第三方依赖没装或版本不对时,跳过整个模块而不是让用例报ImportError:
pd = pytest.importorskip("pandas")注意importorskip要写在模块顶层,不要写在测试函数内部,否则一个用例失败会导致全部用例被重复执行导入逻辑。
xfail这个mark比skip更进阶一点。它标记的是“我知道这条用例会失败,并且接受它失败”,常见于已知bug或者尚未实现的功能。xfail默认strict=False,即失败时显示xfail,通过时显示xpass并产生warning。如果设成strict=True,意外通过反而会被记为失败,这个特性非常适合用来盯住“bug被修复了但没人知道”的情况。
2.3 pytestmark与usefixtures,批量注入的优雅姿势
当你想让一个模块或一个类的所有测试都使用某些fixture,又不想在每个函数签名里写fixture参数时,用pytestmark:
import pytest pytestmark = pytest.mark.usefixtures("client_context") def test_a(): pass def test_b(): pass这里client_context可以是一个只做准备工作、不返回数据的fixture。注意usefixtures拿不到fixture的返回值,它只负责让fixture执行。如果你需要返回值,还是得在测试函数里显式声明参数。
同一模块里也可以叠加多个mark:
pytestmark = [ pytest.mark.usefixtures("client_context"), pytest.mark.usefixtures("mock_time"), pytest.mark.slow, ]类级别也支持类似的声明,写在类上面的pytestmark只作用于该类的用例。这样一个十几个用例的模块,就不用每个函数都重复写fixture参数了,改动公共逻辑时只动一处。
这个特性的最大价值是给热切换提供出口。比如一组用例需要切换“真实存储”和“内存存储”,你只需要在conftest里根据环境变量选择不同的fixture实现,加上usefixtures统一注入,测试代码完全不用动。
3. 命令行、配置与插件生态,把测试从玩具变成工程
3.1 pytest.ini里写addopts,别在命令行手敲一长串
我从一开始就不赞同在命令行里敲二三十个参数跑测试。每次换人执行,参数都会悄悄少几个,测试行为就漂了。正确做法是把稳定参数写进pytest.ini:
[pytest] minversion = 7.0 addopts = -ra -q --strict-markers --tb=short testpaths = tests filterwarnings = error::DeprecationWarning这里的-addopts是重点。它让每次运行都带上这些默认参数,不需要手动敲。-ra表示报告所有short summary(包括skip、xfail),--tb=short让失败回滚信息简洁,--strict-markers强制mark必须注册。filterwarnings里把DeprecationWarning升级为error,能在早期发现依赖API变更,而不是让代码带病运行。
命令行参数和addopts是叠加的,存在冲突时命令行优先级更高。所以临时要看完整traceback,可以直接敲pytest --tb=long覆盖配置里的--tb=short。
另一个好习惯是设置cacheprovider相关参数。pytest自带.lastfailed缓存,--lf只跑上次失败的用例,--ff先跑失败用例再跑其余全部。这两个选项在调试阶段非常猛,配合-f(看到第一个失败就停)能极大缩短反馈循环。
我实际的工作流常常是这样:先pytest -x搞定第一个错误,再用pytest --lf只验证修复是否生效,最后跑pytest -m "not slow"确认全量不回归。整套下来比闷头跑全量快五倍以上。
3.2 常用插件怎么用、怎么组合,避免装了等于白装
pytest-xdist是我首推的插件。它的核心参数是-n auto,让pytest自动根据CPU核心数分配worker。但直接-n auto在共享数据库的项目里几乎必然出事,我建议加--dist loadscope,按模块分发而不是按用例分发,这样同一个模块的用例大概率在同一个worker上执行,能减少很多资源竞争:
pytest -n auto --dist loadscopexdist有几点容易被忽略。第一,它和pytest-timeout配合时,超时控制是每个worker独立的,不会因为worker数为0就失去作用。第二,如果你的用例本身依赖固定执行顺序,xdist会打乱顺序导致失败,正确的做法是修好用例隔离性,而不是关掉并发。第三,--max-worker-restart参数可以控制在worker异常退出时的重启次数,如果频繁遇到worker崩溃,用它来定位现场。
pytest-timeout是专门治用例卡死的。它的原理是给每个用例设置最大执行时间,超时直接判失败:
pytest --timeout=30默认timeout依赖signal模块,在Windows上只能使用线程方式,会有一些限制。如果你的CI跑在Windows容器里,建议显式设--timeout-method=thread。我一般把timeout放在pytest.ini的addopts里,全局兜底,个别超长用例用@emark.timeout(120)单独覆盖。
pytest-rerunfailures严格来说应该叫“偶发失败重试器”,而不是“让失败变绿的遮羞布”。我只推荐在依赖外部网络、消息队列这类不稳定资源的场景使用,重试次数控制在2以内,并且重试之间加delay:
pytest --reruns 2 --reruns-delay 1如果一个测试稳定失败,重试多少次都不会变绿,只会让CI多等几十秒。遇到这种情况请直接修用例,不要靠重试掩盖问题。
pytest-random-order的价值在于打破用例之间的隐式依赖。你觉得自己用例写得很独立,但当随机排序跑出三个原本从没出现过的失败时,往往意味着有全局状态在悄悄串。我的建议是:在CI上跑随机顺序,发现失败就修,直到随机排序能稳定全绿。虽然刚开始会很痛苦,但治好了之后整个测试套件的质量会上一个台阶。
报表方面,我常用pytest-html生成HTML报告支持自定义风格,pytest-cov配合coverage输出覆盖率。覆盖率不是越高越好,但低于60%的项目基本等于没测。建议把--cov=your_pkg --cov-report=term-missing写进CI脚本,让每次提交都看到覆盖率变化。
3.3 用conftest统一管理环境变量,按环境切换测试数据
这个技巧解决的是“本地测的是本地库,CI测的是CI库”问题。不要在测试代码里写死数据库地址,应该读取环境变量,在conftest里根据环境变量创建对应的fixture:
import os import pytest ENV = os.getenv("TEST_ENV", "dev") @pytest.fixture def api_client(): if ENV == "ci": from services.ci_client import Client else: from services.dev_client import Client return Client(timeout=5)这样本地跑不用改代码,CI上设置TEST_ENV=ci即可。用pytest_addoption注册自定义选项也能实现类似的切换,而且命令行可读性更好:
def pytest_addoption(parser): parser.addoption("--env", action="store", default="dev", help="测试环境: dev/ci/staging") @pytest.fixture def env(request): return request.config.getoption("--env")再配合parametrize的indirect,就能实现一套用例跑多环境:
@pytest.mark.parametrize("env", ["dev", "staging"], indirect=True) def test_login(api_client_factory, env): client = api_client_factory(env) assert client.login("admin", "pass")注意这里的api_client_factory要接收env参数,根据环境创建客户端。这套结构一旦建立,新环境接入只需要在工厂里加一个分支。
4. 从使用者到开发者:hook链与一个小插件的实战
4.1 断言重写原理,理解pytest为什么能有那样的报错
用过pytest的同学都知道,它的断言失败信息比原生Python清晰得多,能把表达式里的每个子值都展示出来。这是因为pytest在导入测试模块时做了AST重写,把原生的断言语句改写成了包含上下文信息的调用。这个过程由内置的assertion plugin完成。
但有一个很多人没注意过的边界:pytest默认只对测试文件所在的目录执行断言重写,对通过import导入的外部模块不保证全部重写。如果你写了一个工具包,里面的函数有断言逻辑,想在测试里看到它的详细失败信息,就需要在conftest最顶部调用:
pytest.register_assert_rewrite("my_utils.assertions")关键是顺序:必须在模块导入之前调用,一般写在conftest.py的import区域之后、其他业务导入之前。命名有一个官方限制:名称包含pytest的模块不会被重写,这是有意为之,防止重写pytest自身导致不可预知的行为。
这个机制是“pytest进阶”理解起来比较容易绕的部分,但理解了它,你才真正明白pytest在执行链路上做了什么。测试框架不是单纯跑几个函数,它在加载阶段就改变了测试模块的字节码。
4.2 自定义hook:从“用插件”到“写插件”
pytest的强大不仅仅在于现成插件多,而在于它预留了一整套hook接口。所有插件本质上都是hook的实现,包括xdist、timeout、html报告,都是围绕pytest的hook规范开发的。
我自己写过一个内部插件,用来在分布式执行时确认每个用例到底跑在哪个worker上。排查并发资源冲突时特别有用。思路很简单,先注册一个命令行选项,然后实现几个hook:
def pytest_addoption(parser): parser.addoption("--show-worker", action="store_true", help="显示每条用例所在worker") def pytest_runtest_logreport(report): if report.when == "call" and report.failed: print(f"FAILED on {report.nodeid}")更完整地,用pytest_collection_modifyitems在收集阶段修改用例顺序,用pytest_terminal_summary在测试结束后输出汇总。这些hook的触发时机是:pytest_configure在命令行解析完成后调用,pytest_collection_modifyitems在收集所有用例后调用,pytest_runtest_logreport在每个测试阶段结束后调用。
把上面整合成一个有效的小插件,大致是这样:
# 目录结构 worker_report/ ├── pyproject.toml ├── pytest_worker_report.py └── tests/ └── test_demo.pypytest_worker_report.py:
import os def pytest_addoption(parser): group = parser.getgroup("worker_report") group.addoption("--report-worker", action="store_true", default=False, help="运行结束后输出worker统计") def pytest_configure(config): if config.getoption("--report-worker"): config.worker_report_enabled = True else: config.worker_report_enabled = False @pytest.hookimpl(wrapper=True) def pytest_runtest_makereport(item, call): report = yield if config.worker_report_enabled: worker = os.environ.get("PYTEST_XDIST_WORKER", "master") setattr(report, "worker", worker) return report def pytest_terminal_summary(terminalreporter, exitstatus, config): if getattr(config, "worker_report_enabled", False): terminalreporter.section("Worker Summary") for stat in terminalreporter.stats.get("passed", []): print(stat.nodeid, getattr(stat, "worker", "unknown"))其中pytest_runtest_makereport使用了wrapper模式,先执行yield拿到原始report对象,再对其做属性扩展。这样每个测试节点的report都带上了worker信息,排查分布式问题非常直观。
如果你完全不想写插件文件,也能在conftest.py里直接实现这些hook。conftest本身就是一个局部插件,项目根目录的conftest对这个项目下的所有测试生效。我的建议是:先在conftest里验证hook逻辑,确认有效后再抽成独立插件安装,这样迭代成本低。
4.3 从“能用”到“工程化”,再加一点配置管理
最后聊一个很多团队都会遇到的问题:测试代码怎么组织才能长期维护。我的默认布局是:
tests/ ├── conftest.py ├── unit/ │ └── test_user.py ├── integration/ │ └── test_api.py ├── e2e/ │ └── test_flow.py └── fixtures/ ├── __init__.py ├── user_fixtures.py └── data_builder.pyunit、integration、e2e分目录,conftest只放全局夹具,业务夹具按模块拆分后import进conftest或者直接在需要的地方引用。这里值得注意的一点是:fixtures目录里的夹具文件不要急着都扔进conftest,按需引用比全局自动加载更清晰。全局autouse一多,排查执行顺序的成本就上来了。
关于pytest升级,建议在CI里同时跑当前版本和最新版本,或者至少做好pytest 7到8的大版本预案。很多项目因为插件依赖问题在升级时挂了,问题往往不是pytest本身,而是插件没有跟上hook签名变化。遇到这类情况,先查插件是否声明了python_requires,再检查hook实现是否用了wrapper模式。
我自己的经验是,pytest进阶这条路本质上是从“写测试”到“设计测试框架”的跨越。你会慢慢意识到,用例数量不是目标,测试的独立性、反馈速度和可诊断性才是真正值得投入的方向。如果今天只让你带走三件事:fixture写工厂函数而不是写死数据,parametrize按数据矩阵组织用例而不是复制粘贴,hook链是解决所有“工具不顺手”的最终手段。把这三点串起来,你的测试基础设施就能支撑起大部分业务迭代了。