pytest 的 conftest.py 和插件机制是怎么回事?常用插件有哪些?
简化版
conftest.py 是 pytest 的「本地插件」——它不需要 import,pytest 会自动发现并加载,而且作用范围是它所在的目录及其所有子目录(所以可以在根目录放全局的 fixture、在 tests/api/ 放该模块专用的)。它能定义三样东西:fixture、钩子函数(hook)、以及自定义标记和命令行选项。pytest 的整个架构就是插件架构——连 parametrize、fixture、assert 重写这些核心功能都是以内置插件形式实现的,所有扩展点都通过钩子暴露出来。最常用的五个钩子:pytest_addoption(加命令行选项,比如 --env=staging)、pytest_configure(注册自定义标记、初始化全局资源)、pytest_collection_modifyitems(收集完成后批量改用例——按标记跳过、自动加标记、重排顺序)、pytest_runtest_makereport(拿到每个测试的结果,用来在失败时保存现场)、pytest_generate_tests(动态参数化)。必装的插件按价值排:pytest-cov(覆盖率)、pytest-xdist(并行,-n auto)、pytest-randomly(打乱顺序暴露依赖)、pytest-timeout(防卡死)、pytest-mock(mocker fixture,自动还原),再往后是 pytest-asyncio、pytest-django、respx、freezegun。两个容易踩的点:conftest.py 里的 fixture 不能被 import(它靠名字注入,所以 IDE 常报「未定义」);多层 conftest 的 fixture 同名时,离测试最近的那个生效(这是特性,可以用来局部覆盖)。核心记忆:conftest 自动加载、作用域是所在目录及子目录;pytest 是插件架构、扩展靠钩子;五个常用钩子;必装 cov/xdist/randomly/timeout/mock。
详细版
conftest.py 能做的四件事:
| 用途 | 说明 |
|---|---|
| 定义 fixture | 自动可用,不需要 import |
| 实现钩子 | 修改 pytest 的行为 |
| 注册标记/选项 | pytest_configure、pytest_addoption |
| 共享工具函数 | 但需要显式 import(不推荐) |
# ① ★conftest.py 的层级结构★
# tests/
# ├── conftest.py ★← 全局 fixture(所有测试可用)★
# ├── unit/
# │ └── conftest.py ★← 只对 unit/ 下的测试生效★
# └── api/
# ├── conftest.py ★← 只对 api/ 下的测试生效★
# └── test_users.py
# ★★同名 fixture:离测试最近的覆盖远的★★
# ② ★★pytest_addoption:自定义命令行选项★★
# tests/conftest.py
def pytest_addoption(parser):
parser.addoption("--env", action="store", default="local",
choices=["local", "staging", "prod"], help="测试环境")
parser.addoption("--runslow", action="store_true", help="运行慢测试")
parser.addoption("--db-url", action="store", default=None)
@pytest.fixture(scope="session")
def env(request):
return request.config.getoption("--env") # ★★读取选项★★
# ★用法:pytest --env=staging★
# ③ ★★pytest_configure:注册标记(消除警告)★★
def pytest_configure(config):
config.addinivalue_line("markers", "slow: 慢测试,默认跳过")
config.addinivalue_line("markers", "integration: 需要外部依赖")
config.addinivalue_line("markers", "flaky: 已知不稳定")
# ★不注册的话用 -W error 时会报 PytestUnknownMarkWarning★
# ★也可以写在 pytest.ini 的 markers = 下★
# ④ ★★pytest_collection_modifyitems:批量改用例★★
def pytest_collection_modifyitems(config, items):
# ★场景一:没加 --runslow 就跳过慢测试★
if not config.getoption("--runslow"):
skip_slow = pytest.mark.skip(reason="需要 --runslow")
for item in items:
if "slow" in item.keywords:
★item.add_marker(skip_slow)★
# ★场景二:按路径自动加标记(不用每个文件手写)★
for item in items:
if "/integration/" in str(item.fspath):
★item.add_marker(pytest.mark.integration)★
if "/unit/" in str(item.fspath):
item.add_marker(pytest.mark.unit)
# ★场景三:把慢测试排到最后(快速反馈)★
★items.sort(key=lambda i: "slow" in i.keywords)★
# ⑤ ★★pytest_runtest_makereport:失败时保存现场★★
@pytest.hookimpl(★hookwrapper=True★, tryfirst=True)
def pytest_runtest_makereport(item, call):
outcome = yield
rep = outcome.get_result()
★item.stash[phase_key] = rep★ # 存起来给 fixture 用
if rep.when == "call" and rep.failed:
# ★失败时截图/存日志/dump 数据库状态★
logger.error("测试失败: %s", item.nodeid)
# ★fixture 里判断本次测试是否失败★
@pytest.fixture
def browser(request):
driver = start_browser()
yield driver
★if request.node.rep_call.failed:★ # 需要上面的钩子配合
driver.save_screenshot(f"fail_{request.node.name}.png")
driver.quit()
# ⑥ ★★pytest_generate_tests:动态参数化★★
def pytest_generate_tests(metafunc):
if "backend" in metafunc.fixturenames:
backends = metafunc.config.getoption("--backends").split(",")
★metafunc.parametrize("backend", backends)★
# ★pytest --backends=sqlite,postgres★
# ⑦ ★常用插件★
# pytest-cov --cov=myapp --cov-report=term-missing --cov-fail-under=80
# pytest-xdist -n auto --dist=loadfile
# pytest-randomly (自动生效,打乱顺序)
# pytest-timeout --timeout=60
# pytest-mock def test_x(★mocker★): mocker.patch(...) ★自动还原★
# pytest-sugar 更好看的进度条
# pytest-clarity 更清晰的 diff
⚠️ 三个必须记住的点:①
conftest.py不需要(也不应该)被 import——pytest 在收集测试时会自动发现并加载它,里面定义的 fixture 通过「参数名匹配」注入到测试函数。这带来两个后果:一是 IDE 经常报「未定义的名称」(因为静态分析看不出这种注入关系,PyCharm 和 VS Code 的 pytest 插件能识别,纯 linter 不行);二是 conftest 里的普通函数不会自动可用——如果你想共享工具函数,要么做成 fixture,要么放到单独的模块里显式 import(放 conftest 里再 import 是反模式,因为 conftest 的路径不稳定)。②conftest.py的作用域是「所在目录及其所有子目录」,而且可以多层嵌套——根目录的 conftest 对所有测试生效、tests/api/conftest.py只对 api 目录生效。同名 fixture 时,离测试文件最近的那个覆盖上层的——这是特性而非冲突,常用来「全局定义一个默认实现、在特定目录换成专用实现」。③pytest_collection_modifyitems是最强大的钩子,因为它能在「收集完成、执行开始之前」拿到所有测试用例的列表并任意修改:批量加标记(按目录自动分类,不用每个文件手写)、按条件跳过(没传--runslow就跳过慢测试)、重新排序(快测试先跑,实现快速反馈)、甚至过滤掉一部分。很多「测试基础设施」的需求都靠它实现。
完整版教学
一、conftest.py 的机制
★ ★pytest 怎么发现 conftest★:
① 从 ★rootdir★ 开始(由 pytest.ini / pyproject.toml 等确定)
② ★沿着测试文件的路径逐层向上收集 conftest.py★
③ ★从最外层到最内层依次加载★
★ 例:运行 tests/api/v1/test_users.py 时加载:
★conftest.py(项目根)★
★tests/conftest.py★
★tests/api/conftest.py★
★tests/api/v1/conftest.py★
★ ★★fixture 的解析优先级(就近覆盖)★★:
# tests/conftest.py
@pytest.fixture
def client(): return TestClient(app) # ★默认★
# tests/api/conftest.py
@pytest.fixture
def client(): return AuthedTestClient(app) # ★★api/ 下用这个★★
★ ★不是冲突,是有意的覆盖机制★
★ 也可以复用上层的:
@pytest.fixture
def client(★client★): # ★★同名参数拿到上层的★★
client.headers["X-Test"] = "1"
return client
★ ★conftest 里能放什么★:
✓ ★fixture★(最主要的用途)
✓ ★钩子函数★(pytest_ 开头的)
✓ ★pytest_plugins = ["myplugin"]★(★只能在 rootdir 的 conftest★)
✗ ★普通工具函数★(不会自动可用,要 import——★但 conftest 不该被 import★)
→ ★放 tests/helpers.py 里★
✗ ★测试用例★(不会被收集)
★ ★★为什么 conftest 不该被 import★★:
✗ from tests.conftest import make_user
★ 原因:
① ★conftest 的加载由 pytest 控制,import 会重复执行模块级代码★
② ★rootdir 变化时 import 路径会失效★
③ ★多个 conftest 同名时 import 哪个不确定★
✓ ★共享工具函数放 tests/helpers.py 或 tests/factories.py★
✓ ★或者做成 fixture★:
@pytest.fixture
def make_user():
def _make(**kw): return User(**{**DEFAULTS, **kw})
return _make # ★★返回工厂函数★★
def test_x(make_user):
u = make_user(name="alice")
★ ★rootdir 和配置文件★:
★rootdir 的确定顺序★:
① 命令行参数的公共父目录往上找
② ★找 pytest.ini / pyproject.toml([tool.pytest.ini_options]) /
tox.ini / setup.cfg★
★ ✗ ★rootdir 不对会导致 conftest 加载不全、相对路径错乱★
✓ ★项目根放一个 pyproject.toml 明确 rootdir★
✓ pytest 输出的第一行会打印 rootdir,★出问题先看它★
★ ★testpaths 与 pythonpath★:
[tool.pytest.ini_options]
★testpaths = ["tests"]★ # ★不传参数时默认测哪里★
★pythonpath = ["src"]★ # ★★src layout 必备(7.0+)★★
addopts = "-ra --strict-markers"
★ --strict-markers:★用了未注册的标记直接报错★(防拼写错误)
pytest 从 rootdir 开始沿路径逐层向上收集 conftest.py,从外到内依次加载——所以运行 tests/api/v1/test_x.py 时会加载四层 conftest。同名 fixture 就近覆盖是特性而非冲突,而且可以用同名参数拿到上层的 fixture 再加工(def client(client):)。conftest 不该被 import——因为它的加载由 pytest 控制、rootdir 变化时路径会失效、多个同名时不确定 import 哪个;共享工具函数应该放 tests/helpers.py,或者做成「返回工厂函数」的 fixture。配置上有两个关键项:pythonpath = ["src"] 是 src layout 的必备(pytest 7.0+),--strict-markers 让未注册的标记直接报错(防止 @pytest.mark.slwo 这种拼写错误静默失效)。
二、钩子机制
★ ★pytest 的插件架构(pluggy)★:
★ pytest 自身的核心功能都是内置插件:
- fixtures、parametrize、assertion rewriting
- capture(捕获输出)、tmpdir、monkeypatch
- junitxml、cacheprovider
★ ★所有扩展点都是"钩子"(hook)★
★ 三种实现钩子的位置:
① ★conftest.py(本地插件)★
② ★安装的第三方插件★
③ ★setuptools entry point(自己发布插件)★
★ ★★测试的生命周期与对应钩子★★:
┌──────────────────────────────────────────────────────┐
│ ★启动★ │
│ pytest_addoption(parser) ★加命令行选项★ │
│ pytest_configure(config) ★注册标记、初始化★ │
│ pytest_sessionstart(session) │
│ ★收集★ │
│ pytest_collection(session) │
│ pytest_generate_tests(metafunc) ★动态参数化★ │
│ ★pytest_collection_modifyitems(config, items)★ │
│ ★★批量改用例★★ │
│ ★执行(每个测试)★ │
│ pytest_runtest_setup(item) ★setup 前★ │
│ pytest_runtest_call(item) ★执行测试体★ │
│ pytest_runtest_teardown(item) │
│ ★pytest_runtest_makereport(item, call)★ ★生成报告★ │
│ ★结束★ │
│ pytest_sessionfinish(session, exitstatus) │
│ ★pytest_terminal_summary(terminalreporter)★ ★自定义汇总★│
└──────────────────────────────────────────────────────┘
★ ★hookwrapper:包裹型钩子★:
@pytest.hookimpl(★hookwrapper=True★)
def pytest_runtest_makereport(item, call):
# ★yield 之前:其他实现执行前★
★outcome = yield★ # ★★让其他实现先跑★★
rep = outcome.get_result() # ★拿到结果★
# ★yield 之后:可以修改结果★
if rep.failed and "known_issue" in item.keywords:
rep.outcome = "skipped" # ★★改判★★
★ ★用途:需要"前后都做事"或"修改其他插件的结果"★
★ ★hookimpl 的顺序控制★:
@pytest.hookimpl(★tryfirst=True★) # 尽量先执行
@pytest.hookimpl(★trylast=True★) # 尽量后执行
★ ★多个插件实现同一个钩子时用来排序★
★ ★firstresult 钩子★:
★ 有些钩子是"第一个返回非 None 的结果生效"★
# 例:自定义 assert 的失败信息
def pytest_assertrepr_compare(config, op, left, right):
if isinstance(left, Money) and isinstance(right, Money) and op == "==":
return [f"金额不相等:",
f" 实际: {left}", f" 期望: {right}",
f" 差额: {left - right}"]
★ ✓ ★让领域对象的断言失败信息可读★
★ ★常用钩子速查★:
┌────────────────────────────────┬──────────────────────┐
│ pytest_addoption │ ★命令行选项★ │
│ pytest_configure │ ★注册标记、初始化★ │
│ ★pytest_collection_modifyitems★│ ★★批量改用例(最有用)★★│
│ pytest_generate_tests │ ★动态参数化★ │
│ pytest_runtest_makereport │ ★拿测试结果★ │
│ pytest_runtest_setup │ ★每个测试前的检查★ │
│ pytest_assertrepr_compare │ ★自定义断言信息★ │
│ pytest_terminal_summary │ ★自定义结尾汇总★ │
│ pytest_sessionfinish │ 全部结束后 │
└────────────────────────────────┴──────────────────────┘
★ ★写自己的插件(发布复用)★:
# pyproject.toml
[project.entry-points.pytest11]
★myplugin = "mypackage.pytest_plugin"★
★ → 安装后自动生效,不用写 conftest
★ ✓ 适合:★团队内多个项目共享的测试基础设施★
pytest 的整个架构建立在 pluggy 插件系统上——连 fixture、parametrize、assertion rewriting 这些核心功能都是内置插件。钩子按测试生命周期分成启动、收集、执行、结束四个阶段。hookwrapper=True 是包裹型钩子——yield 之前是其他实现执行前、yield 之后能拿到并修改结果(比如把已知问题的失败改判成 skip)。pytest_assertrepr_compare 很实用但少有人知——它能让领域对象的断言失败信息变得可读(比如「金额不相等,差额 X 元」而不是一堆对象 repr)。如果测试基础设施要在多个项目间共享,可以做成真正的插件用 entry point pytest11 发布,安装后自动生效。
三、必装插件详解
★ ★① pytest-cov(覆盖率)★
pytest ★--cov=myapp --cov-report=term-missing --cov-report=html★
# pyproject.toml
[tool.coverage.run]
★source = ["src/myapp"]★
★branch = true★ # ★★分支覆盖率(比行覆盖有意义)★★
omit = ["*/migrations/*", "*/tests/*"]
[tool.coverage.report]
★fail_under = 80★
★exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:",
"raise NotImplementedError"]★
★ ★注意:和 xdist 一起用时要 --cov-append 或用 coverage combine★
★ ★★② pytest-xdist(并行)★★
pytest ★-n auto★ # 按 CPU 核数
pytest -n 4 ★--dist=loadfile★ # ★同文件的测试在同一 worker★
pytest -n 4 --dist=loadscope # ★同 class/module 在同 worker★
★ ✓ ★大套件提速最直接的手段★
★ ✗ 坑:
- ★测试间不能共享状态★(各 worker 是独立进程)
- ★session 级 fixture 每个 worker 都会执行一遍★
- ★数据库/端口/临时文件要按 worker 隔离★
- ★输出顺序乱★(用 -q 或看报告文件)
✓ ★worker_id fixture 做隔离★:
@pytest.fixture(scope="session")
def db_name(★worker_id★):
return "test" if worker_id == "master" else f"test_{worker_id}"
★ ★③ pytest-randomly(打乱顺序)★
★ ✓ ★每次运行打乱测试顺序 → 暴露顺序依赖★
★ ✓ ★自动为 random/numpy 设种子并打印★
★ ✓ ★失败时用 --randomly-seed=X 完全复现★
pytest ★-p no:randomly★ # 临时关闭
★ ★新项目建议一开始就装★(顺序依赖是逐渐累积的)
★ ★④ pytest-timeout(防卡死)★
pytest ★--timeout=60★
★@pytest.mark.timeout(300)★ # 单个测试放宽
# pyproject.toml
timeout = 60
★timeout_method = "thread"★ # 或 "signal"
★ ✓ ★CI 上必装★——避免一个卡死的测试让整个 job 挂 6 小时
★ ★⑤ pytest-mock(mocker fixture)★
def test_x(★mocker★):
m = ★mocker.patch("app.service.send_email")★ # ★★自动还原★★
mocker.patch.object(obj, "method")
spy = ★mocker.spy(module, "func")★ # ★保留真实行为 + 记录调用★
stub = ★mocker.stub(name="callback")★
★ ✓ ★比 unittest.mock.patch 少写装饰器嵌套★
★ ✓ ★测试结束自动 undo,不会泄漏★
★ ★其他常用★:
┌──────────────────────┬────────────────────────────┐
│ ★pytest-asyncio★ │ 异步测试(asyncio_mode=auto)│
│ ★pytest-django★ │ Django 集成(db fixture) │
│ ★pytest-flask★ │ Flask 集成 │
│ ★pytest-httpx/respx★ │ ★拦截 HTTP★ │
│ ★pytest-socket★ │ ★★禁用真实网络★★ │
│ ★pytest-benchmark★ │ 性能基准 │
│ ★pytest-repeat★ │ --count=N 反复跑 │
│ ★pytest-rerunfailures★│ 重试(★慎用★) │
│ pytest-sugar │ 好看的进度条 │
│ ★pytest-clarity★ │ ★更清晰的 assert diff★ │
│ ★pytest-env★ │ 设置环境变量 │
│ ★pytest-freezegun★ │ 冻结时间 │
└──────────────────────┴────────────────────────────┘
★ ★插件之间的冲突★:
★ ✗ pytest-xdist + pytest-cov:★需要 --cov-append 或配置★
★ ✗ pytest-randomly + 依赖顺序的插件
★ ✗ 多个 asyncio 插件(pytest-asyncio vs anyio)★别同时用★
✓ ★pytest --trace-config 看加载了哪些插件★
✓ ★pytest -p no:插件名 临时禁用★
五个必装插件里:pytest-cov 要开 branch = true(分支覆盖率比行覆盖有意义得多),和 xdist 一起用时要处理 --cov-append。pytest-xdist 是大套件提速最直接的手段,但要注意各 worker 是独立进程——session 级 fixture 会执行多遍、数据库和端口要按 worker_id 隔离。pytest-timeout 在 CI 上必装——避免一个卡死的测试让整个 job 跑 6 小时。pytest-mock 的 mocker fixture 比 unittest.mock.patch 好用(少写装饰器嵌套、自动还原、还有 mocker.spy 保留真实行为同时记录调用)。排查插件问题用 pytest --trace-config 看加载了哪些、-p no:插件名 临时禁用。
四、标记与选择性执行
★ ★注册标记(避免拼写错误)★:
# pyproject.toml
[tool.pytest.ini_options]
markers = [
"slow: 慢测试(默认跳过,用 --runslow 启用)",
"integration: 需要数据库等外部依赖",
"e2e: 端到端测试",
"flaky: 已知不稳定(必须带 issue 号)",
]
★addopts = "--strict-markers"★ # ★★未注册的标记直接报错★★
★ ✗ 不加 --strict-markers 时,@pytest.mark.slwo(拼错)会★静默失效★
★ ★★-m 表达式(组合筛选)★★:
pytest ★-m slow★
pytest ★-m "not slow"★
pytest ★-m "integration and not slow"★
pytest ★-m "unit or smoke"★
★ ✓ ★CI 里分阶段跑的基础★
★ ★-k 按名字筛(模糊)★:
pytest ★-k "user"★ # 名字含 user
pytest -k "user and not admin"
pytest ★-k "test_login or test_logout"★
★ ★-k 匹配的是测试名和参数化 id★
★ ★★按标记自动跳过(最常用的模式)★★:
# conftest.py
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
return
skip = pytest.mark.skip(reason="慢测试,加 --runslow 运行")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip)
★ ✓ ★默认快速反馈,需要时再跑全量★
★ ★条件跳过★:
★@pytest.mark.skipif(sys.version_info < (3, 11), reason="需要 3.11+")★
★@pytest.mark.skipif(not shutil.which("docker"), reason="需要 docker")★
★@pytest.mark.xfail(reason="已知 bug #123", strict=True)★
★ ★strict=True:修复后变成 XPASS 会算失败,提醒你删标记★
# 运行时跳过
def test_x():
if not has_gpu(): ★pytest.skip("需要 GPU")★
# ★整个模块跳过★
★pytestmark = pytest.mark.skipif(...)★ # 模块级变量
★ ★★CI 分阶段的典型配置★★:
# ★阶段一:快速反馈(每次 push,2 分钟)★
pytest -m "not slow and not integration and not e2e" -n auto
# ★阶段二:集成测试(PR 合并前,10 分钟)★
pytest -m "integration" --maxfail=5
# ★阶段三:全量 + 慢测试(nightly)★
pytest --runslow -n auto
# ★阶段四:flaky 隔离 job(允许失败)★
pytest -m flaky || true
★ ★按目录自动打标记(省去手写)★:
def pytest_collection_modifyitems(config, items):
for item in items:
p = str(item.fspath)
if "/tests/unit/" in p: item.add_marker(pytest.mark.unit)
elif "/tests/integration/" in p: item.add_marker(pytest.mark.integration)
elif "/tests/e2e/" in p: item.add_marker(pytest.mark.e2e)
★ ✓ ★目录结构即分类,不用每个文件加装饰器★
★ ★缓存与增量运行★:
pytest ★--lf★ # ★只跑上次失败的★
pytest ★--ff★ # ★先跑上次失败的,再跑其他★
pytest ★--nf★ # 先跑新增的文件
pytest ★--sw★ # ★stepwise:失败就停,下次从那里继续★
pytest ★--cache-clear★
★ ★本地开发时 --lf 和 --sw 能大幅提升迭代速度★
标记要在配置里注册并开 --strict-markers——否则 @pytest.mark.slwo(拼错)会静默失效,你以为标记了慢测试其实没有。-m 支持表达式组合("integration and not slow"),这是 CI 分阶段执行的基础。「按标记自动跳过」是最常用的模式——默认跳过慢测试保证快速反馈,加 --runslow 才跑。xfail 建议加 strict=True——bug 修复后会变成 XPASS 算失败,提醒你删掉标记。按目录自动打标记能省去在每个文件手写装饰器。本地开发时 --lf(只跑上次失败的)和 --sw(stepwise)能大幅提升迭代速度。
五、实用的 conftest 模式
★ ★模式一:工厂 fixture(★比直接返回对象灵活★)★
@pytest.fixture
def make_user(db):
created = []
def _make(**kwargs):
u = User(**{"name": "test", "email": "t@x.com", **kwargs})
db.add(u); db.flush()
created.append(u)
return u
yield _make
for u in created: db.delete(u) # ★★统一清理★★
# 用法
def test_x(make_user):
alice = make_user(name="alice")
bob = make_user(name="bob", is_admin=True)
★ ★模式二:autouse 做全局清理★
@pytest.fixture(★autouse=True★)
def _reset_state():
yield
cache.clear()
app.dependency_overrides.clear()
SomeSingleton._instance = None
★ ✓ ★不用每个测试记得清理★
★ ✗ ★autouse 太多会让测试变慢且难以理解★ → 只用于必要的清理
★ ★模式三:根据标记做 setup★
@pytest.fixture(autouse=True)
def _setup_by_marker(request):
if request.node.get_closest_marker("integration"):
★start_docker_services()★
yield
stop_docker_services()
else:
yield
★ ★模式四:session 级的重资源 + function 级的隔离★
@pytest.fixture(scope="session")
def engine(): # ★★整个 session 建一次★★
e = create_engine(TEST_DB_URL)
Base.metadata.create_all(e)
yield e
Base.metadata.drop_all(e); e.dispose()
@pytest.fixture # ★function 级★
def db(engine):
conn = engine.connect(); trans = conn.begin()
s = Session(bind=conn)
yield s
s.close(); ★trans.rollback()★; conn.close() # ★★每个测试回滚★★
★ ★这是"慢资源共享 + 数据隔离"的标准组合★
★ ★模式五:捕获日志和输出★
def test_logs(★caplog★): # ★pytest 内置★
with caplog.at_level(logging.WARNING):
do_something()
★assert "库存不足" in caplog.text★
★assert caplog.records[0].levelname == "WARNING"★
def test_output(★capsys★):
print("hello")
★assert capsys.readouterr().out == "hello\n"★
★ ★模式六:临时文件和目录★
def test_file(★tmp_path★): # ★pathlib.Path,自动隔离★
f = tmp_path / "test.txt"
f.write_text("content")
assert process_file(f) == "CONTENT"
★ ★tmp_path 每个测试独立,pytest 自动清理(保留最近 3 次)★
★ tmp_path_factory:session 级的临时目录
★ ★模式七:跳过整个目录(缺少依赖时)★
# tests/integration/conftest.py
★pytest.importorskip("docker")★ # ★没装就跳过整个目录★
★collect_ignore = ["test_gpu.py"] if not has_gpu() else []★
★ ★模式八:终端汇总自定义★
def pytest_terminal_summary(terminalreporter, exitstatus, config):
slow = [r for r in terminalreporter.stats.get("passed", [])
if r.duration > 1.0]
if slow:
terminalreporter.write_sep("=", "★慢测试 TOP 10★")
for r in sorted(slow, key=lambda x: -x.duration)[:10]:
terminalreporter.write_line(f"{r.duration:.2f}s {r.nodeid}")
★ ✓ ★每次跑完自动提醒哪些测试变慢了★
八个实用模式里几个高价值的:工厂 fixture(make_user(name="alice") 比固定对象灵活,还能统一清理)、session 级重资源 + function 级隔离(建库一次、每个测试事务回滚,这是标准组合)、caplog 和 capsys(pytest 内置,测日志和输出)、tmp_path(每个测试独立的临时目录,自动清理)、pytest.importorskip(缺少依赖时跳过整个目录)。最后那个 pytest_terminal_summary 自定义汇总很实用——每次跑完自动列出最慢的 10 个测试,能持续发现性能退化。
六、实践清单
★ ★推荐的 pyproject.toml 配置★:
[tool.pytest.ini_options]
★testpaths = ["tests"]★
★pythonpath = ["src"]★ # ★src layout 必备★
addopts = [
"-ra", # ★显示所有非通过的原因★
★"--strict-markers"★, # ★未注册标记报错★
★"--strict-config"★, # 配置项拼错报错
"--tb=short",
★"--durations=10"★, # ★★显示最慢的 10 个★★
"--cov=src", "--cov-report=term-missing",
]
markers = [
"slow: 慢测试",
"integration: 需要外部依赖",
"e2e: 端到端",
"flaky: 已知不稳定(必须带 issue 号)",
]
★filterwarnings = ["error", "ignore::DeprecationWarning:third_party.*"]★
★timeout = 60★
★ 检查清单:
【conftest】
□ ★conftest 不被 import(工具函数放 helpers.py)★
□ ★分层放置(全局的放根,专用的放子目录)★
□ ★autouse fixture 只用于必要的清理★
【标记】
□ ★所有自定义标记都注册了★
□ ★开了 --strict-markers★
□ ★xfail 用 strict=True★
【插件】
□ ★装了 cov / xdist / randomly / timeout / mock★
□ ★CI 上配了 --timeout★
□ ★xdist 下资源按 worker_id 隔离★
【CI】
□ ★分阶段执行(快速反馈 / 集成 / nightly)★
□ ★--durations 监控慢测试★
□ ★junitxml 归档用于统计★
★ ★排查 conftest/插件问题★:
pytest ★--collect-only★ # 看收集到哪些用例
pytest ★--fixtures★ # ★★列出所有可用 fixture 及来源★★
pytest ★--fixtures-per-test★ # 每个测试用了哪些 fixture
pytest ★--trace-config★ # ★加载了哪些插件和 conftest★
pytest ★--markers★ # 列出所有标记
pytest ★--setup-show★ # ★★显示 fixture 的执行顺序★★
pytest ★-p no:randomly★ # 禁用某个插件
★ ★常见问题★:
┌────────────────────────────────────┬──────────────────┐
│ fixture not found │ ★conftest 位置不对★│
│ 标记不生效 │ ★拼写错(开 strict)★│
│ import 报 ModuleNotFoundError │ ★pythonpath 没配★ │
│ 加了 -n 后 session fixture 跑多次 │ ★★各 worker 独立★★ │
│ 覆盖率在 xdist 下不对 │ ★--cov-append★ │
│ rootdir 不对导致配置没生效 │ ★看输出第一行★ │
└────────────────────────────────────┴──────────────────┘
★ 一句话总结:
★"conftest.py 是自动加载的本地插件,作用范围是所在目录及子目录,
同名 fixture 就近覆盖;pytest 本身是插件架构,扩展靠钩子——
最有用的是 pytest_collection_modifyitems(批量改用例)、
pytest_addoption(命令行选项)、pytest_configure(注册标记);
必装 cov/xdist/randomly/timeout/mock,
标记要注册并开 --strict-markers。"★
推荐配置里几个值得加的:--strict-markers(未注册标记报错)、--strict-config(配置项拼错报错)、--durations=10(显示最慢的 10 个测试)、filterwarnings = ["error", ...](把警告当错误,及早发现弃用)。排查问题的命令里最有用的是 pytest --fixtures(列出所有可用 fixture 及其来源,解决「fixture not found」)和 pytest --setup-show(显示 fixture 的实际执行顺序)。
记忆钩子:「★conftest.py 是 pytest 的『本地插件』——不需要 import,pytest 自动发现并加载★,★作用范围是它所在的目录及所有子目录★,pytest 会★从 rootdir 沿测试文件路径逐层向上收集、从外到内依次加载★。★同名 fixture 时离测试最近的覆盖上层的——这是特性不是冲突★,还能★用同名参数拿到上层的再加工(def client(client))★。★conftest 不该被 import★(加载由 pytest 控制、rootdir 变化路径会失效、多个同名时不确定),★共享工具函数放 tests/helpers.py 或做成『返回工厂函数』的 fixture★。★pytest 本身就是插件架构(pluggy),连 fixture/parametrize/assert 重写都是内置插件★,扩展点全是钩子。★五个最常用的钩子★:★pytest_addoption(加命令行选项如 —env)★、★pytest_configure(注册标记、初始化)★、★pytest_collection_modifyitems(最强大——收集完成后拿到全部用例列表任意修改:按标记跳过、按目录自动打标记、重排顺序)★、★pytest_runtest_makereport(拿每个测试的结果,失败时截图存日志)★、★pytest_generate_tests(动态参数化)★;★hookwrapper=True 是包裹型钩子,yield 前后都能做事还能改结果★。★必装五插件★:★pytest-cov(要开 branch=true,和 xdist 一起用要 —cov-append)★、★pytest-xdist(-n auto 提速最直接,但各 worker 是独立进程所以 session fixture 会跑多遍、数据库端口要按 worker_id 隔离)★、★pytest-randomly(打乱顺序暴露依赖,新项目一开始就装)★、★pytest-timeout(CI 必装,避免卡死的测试跑 6 小时)★、★pytest-mock(mocker fixture 自动还原,还有 mocker.spy 保留真实行为同时记录调用)★。★标记必须注册并开 —strict-markers★——否则 @pytest.mark.slwo 拼错会★静默失效★;★xfail 要加 strict=True★(修复后变 XPASS 算失败,提醒你删标记)。★CI 靠 -m 表达式分阶段★:快速反馈跑 ‘not slow and not integration’、集成测试单独跑、全量放 nightly、★flaky 隔离到允许失败的 job★。实用配置:★pythonpath=[‘src’] 是 src layout 必备★、★—durations=10 显示最慢的测试★、★filterwarnings=[‘error’] 把警告当错误★。排查用 ★pytest —fixtures(列出所有可用 fixture 及来源)★、★—setup-show(显示 fixture 执行顺序)★、★—trace-config(看加载了哪些插件)★。」
七、常见误区与追问
- 误区:
conftest.py里的东西要 import 才能用。 恰恰相反——conftest.py不需要也不应该被 import。pytest 在收集测试时会自动发现路径上的所有conftest.py并加载,里面定义的 fixture 通过参数名匹配注入到测试函数(def test_x(db)会去找名为db的 fixture)。手动from tests.conftest import make_user有三个问题:① 会导致模块被重复执行(pytest 已经加载过一次);② rootdir 或目录结构变化时 import 路径会失效;③ 多层 conftest 同名时不确定 import 到哪个。如果确实需要共享普通工具函数(不是 fixture),正确做法是放到tests/helpers.py或tests/factories.py里显式 import;或者做成返回工厂函数的 fixture(def make_user(): return _make),这样既能享受自动注入又能带参数。顺带一提,IDE 报「未定义的名称」是正常现象——静态分析看不出 fixture 的注入关系。 - 误区:标记(marker)写上就生效了,不用注册。 不注册的话,拼写错误会静默失效。
@pytest.mark.slwo(把 slow 拼错)不会有任何报错——pytest 允许任意名字的标记,你以为标记了慢测试,实际上-m slow根本筛不到它,CI 里就漏跑了。解法是在pyproject.toml的markers里注册所有自定义标记,并且开启--strict-markers——之后任何未注册的标记都会直接报错而不是静默通过。顺带推荐--strict-config(配置项拼错时报错,防止addopts里写了个不存在的选项却没人发现)。注册标记还有个附带好处:pytest --markers能列出所有标记及其说明,成为团队的测试分类文档。另外@pytest.mark.xfail建议加strict=True——这样当 bug 被修复、测试意外通过时会报XPASS失败,提醒你把标记删掉,而不是让一个「已经不该 xfail 的标记」永远留在代码里。 - 误区:加了
pytest -n auto并行,测试就应该正常跑。 各个 worker 是完全独立的进程,这带来三类问题。① session 级 fixture 会执行多遍——scope="session"的含义是「每个 session 一次」,而 4 个 worker 就是 4 个 session,所以「建数据库表」这种操作会跑 4 次(可能互相冲突)。② 共享资源冲突——多个 worker 同时写同一个数据库、绑定同一个端口、读写同一个临时文件。解法是用 pytest-xdist 提供的worker_idfixture 做隔离:每个 worker 用独立的数据库名(test_gw0、test_gw1)、独立的 Redis db 号、端口用 0 让系统分配。③ 测试间不能共享内存状态——任何依赖「前一个测试留下的全局变量」的写法都会崩(这其实是好事,它暴露了本来就存在的顺序依赖)。另外覆盖率统计在 xdist 下需要额外配置(--cov-append或coverage combine),否则数据会不完整。 - 误区:
pytest_collection_modifyitems只是个高级功能,用不太上。 它其实是实现「测试基础设施」最常用的钩子,因为它能在「所有用例收集完成、但还没开始执行」的时刻拿到完整的用例列表并任意修改。四个高频用途:① 按目录自动打标记——不用在每个测试文件顶部写pytestmark = pytest.mark.integration,直接根据文件路径批量添加,目录结构即分类;② 条件跳过——「没传--runslow就给所有 slow 标记的用例加上 skip」,实现「默认快速反馈、需要时跑全量」;③ 重新排序——把慢测试排到最后,让开发者更快看到快测试的结果;④ 过滤——在特定环境下移除某些用例。它的签名是pytest_collection_modifyitems(config, items),items是一个可变列表,你对它的增删改排序都会生效。几乎所有「我希望测试框架能自动帮我做 X」的需求,第一反应都应该是看这个钩子能不能实现。 - 误区:
conftest.py越集中越好,全放在根目录。 分层放置才是设计意图。pytest 支持多层 conftest 正是为了「不同范围用不同的基础设施」:根目录的conftest.py放所有测试都需要的(配置、全局清理、通用工厂);tests/api/conftest.py放 API 测试专用的(HTTP client、认证 header);tests/unit/conftest.py放单元测试专用的(Fake 对象)。这样做有三个好处:① 加载开销小——单元测试不会因为集成测试的 fixture 而付出代价;② 意图清晰——看 conftest 的位置就知道这个 fixture 的适用范围;③ 可以局部覆盖——同名 fixture 时离测试最近的生效,比如全局定义一个连真实数据库的db,在tests/unit/conftest.py里覆盖成内存版。反过来,把所有东西堆在根 conftest 会让它膨胀到几百行、fixture 之间的依赖关系混乱、而且每个测试都要加载全部内容。 - 追问:
pytest --fixtures和--setup-show分别解决什么问题? 两个都是排查 fixture 问题的利器。pytest --fixtures列出当前上下文下所有可用的 fixture 及其来源文件和文档字符串——遇到fixture 'db' not found时,第一件事就是跑它看看:是不是 conftest 放错了位置(不在测试文件的路径上)、是不是插件没装、是不是名字拼错了。加上具体的测试文件路径(pytest --fixtures tests/api/test_users.py)能看到该文件实际可见的 fixture 集合。pytest --setup-show则显示每个测试执行时 fixture 的实际调用顺序和作用域(SETUP S engine/SETUP F db (fixtures used: engine)/TEARDOWN F db),用来回答「为什么这个 session fixture 执行了两次」「fixture 的销毁顺序对不对」「这个 autouse fixture 到底有没有生效」这类问题。配套还有--fixtures-per-test(每个测试用了哪些 fixture)和--trace-config(加载了哪些插件和 conftest,排查插件冲突时用)。 - 追问:什么时候该把 conftest 里的东西抽成真正的 pytest 插件? 判断标准是**「是否需要跨项目复用」。
conftest.py的作用范围限于当前项目的目录树,如果团队里有多个项目需要同样的测试基础设施——比如统一的数据库测试隔离方案、公司内部服务的 mock 工具、统一的测试报告格式——那就值得抽成独立的包。做法是在pyproject.toml里声明 entry point:[project.entry-points.pytest11]下写myplugin = "mypackage.pytest_plugin",安装这个包之后 pytest 会自动加载它,不需要任何 conftest 配置**。插件模块里可以定义 fixture 和钩子,写法和 conftest 完全一样。好处是:版本化管理(各项目可以用不同版本)、可测试(插件本身可以有自己的测试,pytest 提供了pytesterfixture 专门用于测插件)、文档化。成本是多了一个包要维护和发布——所以单个项目内的东西留在 conftest 就好,只有确实要共享时才抽出去。 - 追问:
pytest-mock的mocker相比直接用unittest.mock.patch好在哪? 三点。① 自动清理——mocker.patch(...)打的补丁会在测试结束时自动撤销,不需要用装饰器或with语句管理作用域,也就不会出现「忘记 stop 导致污染后续测试」的问题(这是顺序依赖类 flaky 的常见来源)。② 写法更平——用unittest.mock打多个补丁时要么堆叠装饰器(@patch的参数顺序是从下往上,很容易搞错),要么嵌套多层with;而mocker是普通的函数调用,m1 = mocker.patch(...)、m2 = mocker.patch(...)一行一个,顺序直观。③ 额外的便利方法——mocker.spy(module, "func")会保留原函数的真实行为同时记录调用(这正是「Spy」的准确形态,用unittest.mock要自己写side_effect);mocker.stub()创建带名字的桩;mocker.patch.object、mocker.patch.dict也都有。唯一要注意的是它是第三方插件(要装pytest-mock),而且底层仍然是unittest.mock,所以 patch 路径的规则完全一样——依然要 patch「使用处」而不是「定义处」。
八、加强记忆
conftest.py 是 pytest 的「本地插件」——不需要 import,pytest 会自动发现并加载,作用范围是它所在的目录及所有子目录;pytest 会从 rootdir 沿着测试文件的路径逐层向上收集、从外到内依次加载。同名 fixture 时离测试最近的覆盖上层的——这是特性不是冲突,还能用同名参数拿到上层的再加工(def client(client):)。conftest 不该被 import(加载由 pytest 控制、rootdir 变化时路径会失效、多个同名时不确定加载哪个),共享工具函数应该放 tests/helpers.py 或做成「返回工厂函数」的 fixture。pytest 本身就是插件架构(pluggy),连 fixture、parametrize、assert 重写都是内置插件,所有扩展点都是钩子。五个最常用的钩子:pytest_addoption(加命令行选项如 --env)、pytest_configure(注册标记、初始化)、pytest_collection_modifyitems(最强大——收集完成后拿到全部用例列表任意修改:按标记跳过、按目录自动打标记、重排顺序)、pytest_runtest_makereport(拿到每个测试的结果,失败时截图存日志)、pytest_generate_tests(动态参数化);hookwrapper=True 是包裹型钩子,yield 前后都能做事、还能修改其他实现的结果。必装的五个插件:pytest-cov(要开 branch = true,和 xdist 一起用要 --cov-append)、pytest-xdist(-n auto 是提速最直接的手段,但各 worker 是独立进程,所以 session fixture 会跑多遍、数据库和端口要按 worker_id 隔离)、pytest-randomly(打乱顺序暴露依赖,新项目一开始就装)、pytest-timeout(CI 必装,避免卡死的测试跑 6 小时)、pytest-mock(mocker fixture 自动还原,还有 mocker.spy 保留真实行为同时记录调用)。标记必须注册并开 --strict-markers——否则 @pytest.mark.slwo 这种拼写错误会静默失效;xfail 要加 strict=True(修复后变 XPASS 算失败,提醒你删标记)。CI 靠 -m 表达式分阶段执行:快速反馈跑 "not slow and not integration"、集成测试单独跑、全量放 nightly、flaky 隔离到允许失败的 job。几个实用配置:pythonpath = ["src"] 是 src layout 的必备、--durations=10 显示最慢的测试、filterwarnings = ["error"] 把警告当错误。排查问题用 pytest --fixtures(列出所有可用 fixture 及来源)、--setup-show(显示 fixture 的执行顺序)、--trace-config(看加载了哪些插件)。