第 16 章 · 自定义插件与 hook 开发
本章目标:理解 pytest 基于 pluggy 的 hook 体系,掌握四个最常用 hook 的写法,亲手实现一个"自定义命令行选项 + 收集过滤"的本地插件。
16.1 hook 机制:pytest 的骨架
pytest 自身的配置、收集、运行、报告全部是通过调用一组规范化的 hook 函数实现的。底层库是 pluggy,核心语义有三条:
- 1:N 调用:一个 hook 规范可对应 N 个实现。所有注册的实现按 LIFO(后注册先执行)顺序被依次调用;
- 参数名裁剪:hook 实现只需声明自己关心的参数,pluggy 按名字注入。这是 pytest 长期兼容的秘密——新增规范参数不会破坏旧插件;
- firstresult 与 wrapper:部分 hook 标记
firstresult=True(拿到第一个非 None 结果即停);任何 hook 都可以写"包装器"在前后插入逻辑。
import pytest
# 普通 hook 实现:只声明需要的参数
def pytest_collection_modifyitems(config, items):
# 收集完成后被调用,可以原地修改 items 列表
...
# firstresult 语义:返回非 None 后,其余实现不再执行
# hook 包装器:生成器函数,恰好 yield 一次
@pytest.hookimpl(wrapper=True)
def pytest_pyfunc_call(pyfuncitem):
print("测试函数执行前")
result = yield # 此处执行真正的测试
print("测试函数执行后")
return result # 返回/改写结果;异常也会从 yield 处抛出重要约束
除 pytest_runtest_* 系列(测试执行阶段)外,其他 hook 不允许抛异常,否则会中断整个 pytest 进程。
16.2 四个最常用的 hook
pytest_addoption:注册命令行选项 / ini 配置
def pytest_addoption(parser):
group = parser.getgroup("myproj") # --help 里显示的分组名
group.addoption(
"--run-slow",
action="store_true",
default=False,
help="同时运行标记为 slow 的测试",
)
parser.addini("api_base", help="被测服务地址", default="http://localhost:8000")注意:pytest_addoption 必须写在根 conftest.py 或已安装的插件里,因为它在解析命令行之前就要生效。
pytest_configure:读取配置、初始化全局对象
def pytest_configure(config):
# 此时命令行与 ini 已解析完毕,可安全读取选项
config._api_base = config.getoption("--run-slow")pytest_collection_modifyitems:收集后筛选/重排
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) # 原地给用例加 skippytest_runtest_makereport:捕获每个用例的结果
它是一个 firstresult 且处于执行路径上的 hook,标准用法是配合 wrapper 抓取结果:
@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item, call):
rep = yield # 真正的报告对象在这里产生
if rep.when == "call":
# rep.outcome ∈ {"passed", "failed", "skipped"}
print(f"{item.nodeid} -> {rep.outcome}")
return rep这个模式是 pytest-rerunfailures、失败截图、Allure 报告等插件的基石。
16.3 动手做一个完整的小插件
把上面的零件组装成一个本地插件:默认跳过 slow 用例,传 --run-slow 才运行,并在结束统计数量:
# conftest.py —— 本地插件示例
import pytest
def pytest_addoption(parser):
group = parser.getgroup("tutorial")
group.addoption("--run-slow", action="store_true", default=False,
help="包含 @pytest.mark.slow 的用例")
def pytest_configure(config):
config.addinivalue_line("markers", "slow: 运行较慢的集成测试")
if not config.getoption("--run-slow"):
config._skip_slow = True
else:
config._skip_slow = False
def pytest_collection_modifyitems(config, items):
if not getattr(config, "_skip_slow", False):
return
skip = pytest.mark.skip(reason="未指定 --run-slow")
kept = []
for item in items:
if "slow" in item.keywords:
item.add_marker(skip)
kept.append(item)
def pytest_terminal_summary(terminalreporter):
stats = terminalreporter.stats
skipped = len(stats.get("skipped", []))
terminalreporter.write_sep("=", f"共跳过 slow 用例 {skipped} 个")
# 测试文件 test_api.py 中:
# import pytest
#
# @pytest.mark.slow
# def test_full_export():
# ...$ pytest -m "not slow" # marker 方式(第 4 章)
$ pytest # 插件方式:自动跳过 slow,无需记表达式
$ pytest --run-slow # 显式打开相比让每个人记住 -m "not slow",插件把团队约定固化进了工具本身——这正是写插件的价值。
16.4 conftest.py 就是本地插件
官方文档明确:conftest.py 是目录级本地插件。它的加载规则值得背下来:
- 启动时按"命令行路径 → 父目录向上到 rootdir"的顺序加载沿途所有 conftest;
- 越靠近测试文件的 conftest 中的 fixture/hook 优先级越高(LIFO);
- conftest 里也可以声明
pytest_plugins = ["mycompany.plugin"]再加载其他模块级插件。
而要让插件被任何项目安装使用,则需要在包的入口元数据中注册 entry point(以 pyproject.toml 为例):
[project.entry-points.pytest11]
myplugin = "myplugin.plugin"pytest11 是 pytest 保留给插件的名字组;安装该包后 pytest 通过 entry point 自动发现并加载。开发调试时也可以临时用 pytest -p myplugin.plugin 手动指定加载。
本章小结
- hook 是 pytest 的实现方式:1:N 调用 + 参数名裁剪 + firstresult/wrapper 三件套;
addoption → configure → collection_modifyitems → runtest_makereport覆盖了 80% 的插件需求;- 非 runtest 类 hook 不能抛异常;wrapper 用
@pytest.hookimpl(wrapper=True)+ 单次 yield; - conftest.py 即本地插件,加载遵循"由根到叶、越近越优先";发布插件靠
pytest11entry point。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. pytest_addoption 注册的自定义命令行选项应该最先写在哪里?
2. 关于 hook wrapper,正确的写法是?
3. 想在收集完成后把名称含 smoke 的用例移到最后执行,最合适的 hook 是?
4. 第三方插件被 pip 安装后 pytest 如何发现它?
🛠️ 动手实践
- 给 16.3 的插件再加一个
--fail-fast选项:出现第一个失败后停止整个会话(提示:研究pytest_runtest_protocol或在 makereport wrapper 里抛pytest.UsageError之外的退出手段)。 - 写一个 hook wrapper 统计每个用例耗时并输出 Top5 慢用例(参考
call.start/call.stop或自己计时)。 - 把你的插件抽成独立包并用 pyproject.toml 注册 pytest11 entry point,在一个新项目里 pip install -e 后验证自动生效。
工具已经武装到牙齿。下一章进入异步代码的测试。