Skip to content

第 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 都可以写"包装器"在前后插入逻辑。
python
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 配置

python
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:读取配置、初始化全局对象

python
def pytest_configure(config):
    # 此时命令行与 ini 已解析完毕,可安全读取选项
    config._api_base = config.getoption("--run-slow")

pytest_collection_modifyitems:收集后筛选/重排

python
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)            # 原地给用例加 skip

pytest_runtest_makereport:捕获每个用例的结果

它是一个 firstresult 且处于执行路径上的 hook,标准用法是配合 wrapper 抓取结果:

python
@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 才运行,并在结束统计数量:

python
# 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():
#     ...
bash
$ pytest -m "not slow"        # marker 方式(第 4 章)
$ pytest                      # 插件方式:自动跳过 slow,无需记表达式
$ pytest --run-slow           # 显式打开

相比让每个人记住 -m "not slow",插件把团队约定固化进了工具本身——这正是写插件的价值。

16.4 conftest.py 就是本地插件

官方文档明确:conftest.py 是目录级本地插件。它的加载规则值得背下来:

  1. 启动时按"命令行路径 → 父目录向上到 rootdir"的顺序加载沿途所有 conftest;
  2. 越靠近测试文件的 conftest 中的 fixture/hook 优先级越高(LIFO);
  3. conftest 里也可以声明 pytest_plugins = ["mycompany.plugin"] 再加载其他模块级插件。

而要让插件被任何项目安装使用,则需要在包的入口元数据中注册 entry point(以 pyproject.toml 为例):

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 即本地插件,加载遵循"由根到叶、越近越优先";发布插件靠 pytest11 entry point。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. pytest_addoption 注册的自定义命令行选项应该最先写在哪里?

2. 关于 hook wrapper,正确的写法是?

3. 想在收集完成后把名称含 smoke 的用例移到最后执行,最合适的 hook 是?

4. 第三方插件被 pip 安装后 pytest 如何发现它?

🛠️ 动手实践

  1. 给 16.3 的插件再加一个 --fail-fast 选项:出现第一个失败后停止整个会话(提示:研究 pytest_runtest_protocol 或在 makereport wrapper 里抛 pytest.UsageError 之外的退出手段)。
  2. 写一个 hook wrapper 统计每个用例耗时并输出 Top5 慢用例(参考 call.start/call.stop 或自己计时)。
  3. 把你的插件抽成独立包并用 pyproject.toml 注册 pytest11 entry point,在一个新项目里 pip install -e 后验证自动生效。

工具已经武装到牙齿。下一章进入异步代码的测试