先用一个真实场景把这篇文章带出来。
我在给团队做测试基建的时候,接过一个最头疼的案子:某模块的测试永远能在本地全绿,一到 CI 上就随机挂几条,而且挂的都是不同用例。反复排查之后发现,问题根源根本不是测试代码,而是两个第三方 pytest 插件在抢同一个 hook 的执行顺序。那一刻我才意识到,不懂 pytest 插件系统的底层机制,你连“插件为什么不按预期工作”都分析不出来。
这篇博文不是 pytest 使用教程,而是一份带着源码走的插件系统拆解。我会从PytestPluginManager出发,把插件注册、发现、调用、排序这些链路全部过一遍,最后再手写一个统计插件来验证理解。无论你是想搞懂 conftest.py 到底在干什么,还是打算自己维护一个内部测试框架插件,这篇文章的目标就是让你从“照着文档抄”变成“看着源码心里有数”。
1. 插件系统在 pytest 里的定位
1.1 先接受一个事实:pytest 自己就是最大的插件
很多人第一次接触 pytest 插件,会觉得插件只是个“锦上添花”的外部扩展。但你把 pytest 源码打开会发现一个反直觉的事实:pytest 的核心功能本身全部是由插件实现的。捕获输出、断言重写、缓存、参数化、fixture、marker 处理,这些你天天在用的功能,在源码级别看就是一个个注册在 PluginManager 上的插件模块。
pytest包里有_pytest这个子包,里面是 assertion、cacheprovider、capture、config、fixtures、main、mark、monkeypatch、recwarn、runner 等等模块。这些模块在_pytest.config初始化 PluginManager 的阶段,会被逐个consider_module注册成插件。也就是说,你装第三方插件和使用 pytest 自带功能,底层走的是同一条链路。
理解了这一点,很多之前的困惑就解开了。比如为什么 conftest.py 里能改 pytest 的收集行为?因为 conftest.py 文件本身会被当成插件模块加载。为什么 conftest.py 里的 fixture 不需要pytest_plugins声明就能生效?因为它天然就是一个插件,只是加载时机比较特殊。为什么不同 conftest 之间有作用域隔离?因为 conftest 插件对象是独立注册、独立管理的。
这个设计思路在源码层面非常清晰:pytest 定义了一组 hook 规范,然后各类插件模块去实现这些 hook,最后由 PluginManager 统一调度。核心框架只做事件分发和结果回收,具体行为全部下沉到插件。谁想扩展什么,就往 hook 链上挂一个实现即可。
1.2 PluginManager 到底是谁
先看 pytest 里插件管理器的真身,它定义在_pytest/config/__init__.py:
class PytestPluginManager(PluginManager): def __init__(self, ...): super().__init__(project_name="pytest", ...)这是个继承类,父类是pluggy库的PluginManager。所以很多时候我们讨论 pytest 插件系统,其实是在讨论 pluggy 这个独立库。pluggy 很小,核心源码就几个文件,但它定义了 pytest 整个插件体系的地基。
PluginManager 的核心职责可以概括成三件事:注册插件、登记 hook 实现、按规则调用 hook。三件事分别对应源码里的register()、_add_hookimpl()、_multicall()这几个关键方法。后面我会一个个展开。
先记住一个关键公式:pytest 插件 = 一个 Python 对象(通常是模块、类实例),对象上挂着若干个用@pytest.hookimpl装饰的方法,这些方法的名字必须是 pytest 预定义的 hook 名称,比如pytest_runtest_call、pytest_configure、pytest_collection_modifyitems。
1.3 插件系统解决的核心问题
试想如果没有插件系统,pytest 想要支持 pytest-xdist 分布式执行、pytest-cov 覆盖率、pytest-allure 报告,就得把所有这些逻辑写成 if-else 分支,然后版本管理变成噩梦。有了 hook 机制之后,框架和扩展彻底解耦:pytest 只负责在合适的时机发出“事件”,至于有没有人响应、怎么响应,它完全不关心。
这个机制的优雅之处在于,它用一套一致的规则解决了三个问题:
- 如何发现插件(入口点、conftest、pytest_plugins 变量)
- 如何声明能力(hookspec 定义接口)
- 如何调度多实现(执行顺序、wrapper、结果合并)
接下来的章节,就按这个线索深入源码。
2. 插件发现的完整链路
2.1 三条发现通道
pytest 加载插件主要有三条路径。掌握这三条路径,你才能确定自己写的插件到底有没有被加载,以及会在什么时机被加载。
- 通过
setuptools入口点注册的插件,这也是第三方插件最常用的方式。你pip install pytest-xdist之后,pytest 会自动从pytest11这个 entry point group 里找到插件模块并导入。 - 通过
conftest.py文件加载。每个测试目录下的 conftest.py 在收集阶段会按目录层级被扫描并注册成插件,conftest 之间还有目录作用域的隔离。 - 通过
pytest_plugins变量显式声明。在 conftest 或插件模块里写pytest_plugins = ["某个插件模块路径"],就能手动拖一个插件进来。
这三条路径最后都会汇到PluginManager.register()这个方法,区别只是谁来触发注册。
2.2 入口点注册的源码追踪
我们去看 pluggy 的manager.py,找到load_setuptools_entrypoints()方法。这个方法做的事情,本质上就是遍历entry_points(group="pytest11")拿到的所有插件模块名,逐个导入然后 register。
def load_setuptools_entrypoints(self, group, name=None): eps = entry_points() if hasattr(eps, "select"): eps = eps.select(group=group) # 新版 setuptools 的 API else: eps = eps.get(group, []) for ep in eps: if name and ep.name != name: continue plugin = ep.load() self.register(plugin, name=ep.name)看到那个ep.load()吗?这一步是真正的导入动作,它执行的是模块级代码。如果插件模块有重名或导入异常,会发生什么?我后面在问题排查章节会专门说。
对于刚入门的朋友,有个容易忽略的点:不是所有第三方包的插件都会自动加载。只有那些在打包时声明了pytest11entry point 的包,pytest 才会自动找到它。如果是你自己在自己项目里写了个插件文件,但没配置 entry point,那 pytest 是感知不到的,必须通过 conftest 或pytest_plugins声明。
2.3 conftest 与 pytest_plugins 的加载时机
conftest.py 的加载比入口点晚。pytest 是在收集测试用例阶段,扫描到某个目录时,才去尝试加载该目录下的 conftest.py。这个逻辑顺着_pytest/config/__init__.py的consider_conftest()继续往下走,最后会调到_getconftestmodules(),按目录层级从根目录到测试目录逐个导入 conftest。
这里有一个行为差异值得注意:常规第三方插件是“全局注册”,而 conftest 是“目录作用域”。说白了,conftest 的 fixture、hook 只对当前目录及其子目录下的测试生效。这是很多新手栽跟头的地方——在子目录 conftest 里定义了一个 fixture,跑到根目录测试里用,结果报 fixture not found。
pytest_plugins这个变量的加载时机就更挑剔了。如果你在一个非 conftest 的插件模块里写pytest_plugins = ["xxx"],pytest 会在注册该模块时顺手把这些插件也注册了。但如果在 conftest.py 里写,那有个硬性要求:必须写在模块顶部,不能放在 if 判断或函数内部。原因是 conftest 的加载流程会预先读取pytest_plugins这个字符串变量,读不到就会跳过;等模块执行完之后再读,模块已经被 import 完,很多东西已经来不及处理了。
2.4 pytest 插件的命名与重复注册
每个插件注册时都可以带一个名字。入口点注册会自动用 entry point 的名字;conftest 则默认用文件路径做名字;手动 register 时可以自己传name参数。
pluggy 在 register 的时候做了重名检查:如果同名插件已经注册过,新的注册请求会直接失败并抛异常。这个设计是为了在插件互相引用时避免重复加载。实际中经常有人遇到“插件被加载两次”的问题,八成是入口点和 conftest 两条路径重复指向了同一个插件。源码里PytestPluginManager.register()做了 override 处理:如果新注册的插件和旧插件是同一个对象,直接忽略;如果不是同一个对象但名字相同,就会抛ValueError。
3. hook 调用机制深度拆解
3.1 hookspec 与 hookimpl 的配对
整个 hook 体系的“接口定义”部分是_pytest/hookspec.py。这个文件里每个函数都带@pytest.hookspec()装饰器,比如:
@pytest.hookspec() def pytest_runtest_call(item): ...这行代码的含义是:pytest 声明了一个名为pytest_runtest_call的事件,事件发生时会带上item这个参数。至于谁会响应、响应之后返回什么,接口本身不关心。
@pytest.hookspec()装饰器在 pluggy 里也就是HookspecMarker("pytest")的调用。同理,@pytest.hookimpl()是HookimplMarker("pytest")的调用。这两个 marker 内部会用闭包把函数元信息记录下来,比如这个函数是不是 hookwrapper、有没有 tryfirst 标志、是否要求 firstresult 等等。这些元信息在注册时会被塞进HookImpl对象里,后续排序调度全靠它。
有意思的是,pytest 自己定义这些 hookspec 的时候,很多函数体是空的,就写个 docstring。这说明 hookspec 的作用纯粹是“签名定义”,而不是“默认实现”。任何插件没有实现这个 hook,也不会报错,事件照发,只是没人响应而已。
3.2 register 之后发生了什么
当插件对象被 register,pluggy 会遍历这个对象的所有属性,把带hookimpl标记的函数找出来,逐一向对应名字的 hook 注册。核心代码在 pluggy 的 manager.py,简化后大概长这样:
def register(self, plugin, name=None): # ... 各种检查 ... for attr_name in dir(plugin): hookimpl = getattr(plugin, attr_name, None) if isinstance(hookimpl, HookImpl): hook = self.hook(attr_name) # 取出或创建 HookCaller hook._add_hookimpl(hookimpl)也就是说,如果我在插件类里写了两个pytest.hookimpl方法,register 一次就会往两个 hook 链上各挂一个实现。如果你写了一个hookimpl方法但方法名不是合法 hookspec,pluggy 并不是直接报错的,而是在调用pytest_runtest_call这类 hook 时根本不会找到它。后来 pluggy 增加了一次校验:如果注册方法名在 hookspec 里不存在,会抛ValueError,这个校验在 pytest 新版本里是开启的。
所以有个非常常见的报错长这样:function is already registered或者plugin has no attribute,多半就是因为你把一个带@pytest.hookimpl的方法写成了私有方法,或者名字和前一个插件冲突了。
3.3 一个 hook 调用的完整旅程
前面注册只是登记,真正的执行发生在事件触发时。任意插件代码里调用config.hook.pytest_runtest_call(item=item),就会走进 pluggy 的HookCaller.__call__()。
我强烈建议大家自己打开pluggy/callers.py看一眼_multicall这个函数。它干的事情,是把某个 hook 上的所有 hookimpl 按顺序逐个调用,然后把返回值收集起来。
这里有个容易误解的地方:普通 hookimpl 的调用顺序不是按注册顺序的先后,而是按“后注册的先执行”的 LIFO 规则。为什么这么设计?因为 pytest 想让后来者能够覆盖前面插件的默认行为。比如核心插件先注册,你在 conftest 里后注册了一个同名 hookimpl,那你的实现就会先跑,你的代码能决定后续要不要继续往下走。
tryfirst=True和trylast=True这两个参数就是给这个 LIFO 规则打补丁的。源码里_sort_hookimpls()会对实现列表排序,规则是带tryfirst的排最前,带trylast的排最后,其余按“后注册优先”。这也解释了为什么第三方插件喜欢给自己的 hookimpl 加trylast=True,就是为了让自己在所有其他插件的实现之后跑,比如收集完所有 item 之后再统一修改。
3.4 hookwrapper 的工作机制
hookwrapper=True是 pytest 插件系统里最强大但最容易被误解的能力之一。一个 wrapper 类型的 hookimpl 长这样:
@pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(item): start = time.time() outcome = yield # 关键点 duration = time.time() - start item._duration = duration先理解 wrapper 的含义:它在整个 hook 调用链的外围包了一层皮。yield之前的代码在任何普通 hookimpl 之前执行,yield之后的代码在所有普通 hookimpl 执行完之后执行。也就是说,wrapper 让你能往事件的前后两侧插入逻辑,而不影响普通 hookimpl 的执行队列。
我当年第一次看源码时也被outcome = yield搞懵过,完全不符合直觉。正常 Python 代码都是 yield 之后执行收尾逻辑,这里不是 Python 生成器的常规用法,而是刻意用生成器协议来实现“前后挂钩”。pluggy 在_multicall里会先把普通 hookimpl 的执行结果捕获到_Result对象里,然后当 wrapper 里执行到yield时,把_Result传回去。如果 wrapper 想修改最终返回值,可以直接调outcome.force_result(...)或者outcome.get_result()。一旦 force_result,普通 hookimpl 的结果直接作废,后续 wrapper 拿到的就是被强制覆盖的新结果。
这个机制非常适合做统计、埋点、审计这类横切需求,因为你能在不侵入测试执行流程的前提下,把每次测试调用的前后状态都抱起来。
4. 手写一个统计插件:源码视角的实战
4.1 需求与 hook 选型
光看源码容易看晕,手写一个插件是最好的验证方式。我这边要写一个简单的性能统计插件:它能统计每个测试用例的执行耗时,并在 session 结束时打印 Top 5 最慢用例。
hook 选型上我选了一个 wrapper 历史悠久的经典组合:pytest_runtest_call负责记录耗时,pytest_sessionfinish负责输出结果。为什么不选pytest_runtest_setup和pytest_runtest_teardown?因为一个用例的总耗时应该覆盖 setup、call、teardown 三个环节,用pytest_runtest_call只能包裹中间的执行段。如果单纯为了演示 hookwrapper,用pytest_runtest_call足够,而且它确实是测试执行最核心的事件。想要统计整个用例生命周期,可以包pytest_runtest_protocol,不过那个 hook 的参数模型更复杂一点,先不展开。
4.2 插件完整实现
直接看代码,我把它写成一个独立的 Python 模块文件slowest_plugin.py:
import time import pytest class SlowestPlugin: def __init__(self): self._durations = [] @pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(self, item): start = time.perf_counter() outcome = yield duration = time.perf_counter() - start # 把耗时挂到 item 上,方便其他 hook 读取 item._slowest_duration = duration self._durations.append((item.nodeid, duration)) if outcome.excinfo is not None: # 用例失败也记录,后面打印时能分辨 item._slowest_failed = True @pytest.hookimpl(trylast=True) def pytest_sessionfinish(self, session, exitstatus): if not self._durations: return ranked = sorted(self._durations, key=lambda x: x[1], reverse=True)[:5] print("\n=== Top 5 slowest tests ===") for nodeid, dur in ranked: print(f"{dur:.3f}s {nodeid}") pytest_plugin = SlowestPlugin()这里面有三个点值得总结。
第一,pytest_runtest_call是hookwrapper=True,所以yield返回的是outcome,而outcome.excinfo能让你知道被包裹的普通 hookimpl 是否抛了异常。在源码级别,这个outcome就是pluggy._result._Result的实例,它的get_result()会把异常原样抛出来,而excinfo属性保留异常信息不抛。
第二,pytest_sessionfinish我加了trylast=True,目的是尝试让自己在所有其他插件的pytest_sessionfinish之后执行,避免我的 print 输出被别的插件的输出刷掉。这里也能验证排序逻辑:如果去掉trylast,你的输出大概率会插在别的插件输出中间。
第三,插件类实例化放在模块底部。因为 pluggy 注册插件时,是把整个传入对象(模块或实例)的属性扫描一遍。我传的是实例pytest_plugin = SlowestPlugin()这种模式,而不是直接传模块本身。如果直接把模块传进去,模块里的非 hookimpl 函数如果碰巧也叫 hook 名字,也会被当成 hookimpl 处理,容易踩坑。所以最稳妥的做法是,显式创建一个实例,只把实例注册给 pytest。
4.3 让插件被 pytest 加载
这个插件文件目前只是个普通 Python 模块,pytest 不会自动知道它。有三种方式让它生效:
最简单的方式,写进 conftest.py 顶部:
pytest_plugins = ["slowest_plugin"]这个字符串是模块名,pytest 会去 importlib 导入slowest_plugin模块。如果你目录结构里没有slowest_plugin.py,就会导入失败。注意pytest_plugins在 conftest.py 里必须写在顶部,不要塞到if __name__ == "__main__"这种条件里。
第二种方式,配置pyproject.toml,把它做成可安装的 entry point 插件:
[project.entry-points.pytest11] slowest = "slowest_plugin"然后pip install -e .。这种方式的优势是全局可用,不限制在某个测试目录的 conftest 作用域里。第三个方式是直接手动注册,在 conftest 或某个插件模块里:
plugin = SlowestPlugin() pytest_plugins = [plugin] # 注意:这里传的既可以是字符串也可以是对象不过pytest_plugins里传对象有个前提,这个对象必须已经被 import 并且能在当前命名空间引用到。真实项目中我用得最多的还是pyproject.tomlentry point,因为内部工具库本来就已经是可安装包了,加一段 entry point 配置成本最低。
4.4 插件的完整生命周期
把这个插件注册进去之后,跑一次pytest -s,你会看到 session 结束时打印出最慢的 5 条用例。整个过程在源码层面是这样串起来的:
- pytest 初始化
PytestPluginManager - 扫描
pytest11entry points,发现slowest_plugin,register - register 扫描插件对象属性,把
pytest_runtest_call和pytest_sessionfinish两个 hookimpl 分别挂到对应 hook 链上 - 收集测试,进入
pytest_runtest_protocol,触发pytest_runtest_call事件 - 调用链进入 wrapper,你的 start 时间点被记录;
yield时调用链继续往下执行普通 hookimpl(包括 pytest 内置的 runner 逻辑),执行完再回到你的代码 - session 结束时触发
pytest_sessionfinish,打印统计结果
这个流程里最值得品味的是第 5 步。wrapper 的yield不是简单返回,而是把整个剩余调用链“暂时挂起”,等内部执行完再回来。这就是 hookwrapper 的本质:不是并行,而是嵌套。所以你完全可以在 wrapper 里加 try/except/finally,控制异常传播。
5. 插件系统的扩展能力与调试手段
5.1 为命令行注入参数
很多插件希望用户能通过命令行参数控制行为,比如--browser=chrome。hook 规范里留给插件的方法叫pytest_addoption。它的实现大概是:
@pytest.hookimpl() def pytest_addoption(self, parser): group = parser.getgroup("slowest") group.addoption( "--show-slowest", action="store_true", default=False, help="Print slowest test durations", )然后在pytest_sessionfinish里通过session.config.getoption("--show-slowest")读取。这个能力非常强大,几乎所有的 pytest 插件 CLI 参数都是这么来的。
源码里pytest_addoption的 hookspec 是 firstresult=False,也就是说所有插件的 addoption 实现都会执行。注意这里有个坑:pytest_addoption必须在命令行参数被解析前注册完毕,因此 pytest 设计了两阶段初始化——先收集所有插件的 addoption,再解析命令行参数,最后才进入后续流程。你可以自己翻_pytest/config/__init__.py里_preparse和parse两个方法,就能看到这个分离设计。
5.2 查看插件加载情况的几个手段
排查插件问题时,第一件事永远是确认插件到底有没有被加载。推荐顺序:
pytest --trace-config是最直观的,它会打印每个插件注册时的详细信息,包括插件名字和注册来源。
pytest --help能看到所有已注册插件贡献的命令行参数。如果你写的插件没出现在 help 里,大概率压根没注册成功。
自己写调试代码也一样行,在 conftest 里塞一段:
def pytest_configure(config): names = config.pluginmanager.list_name_plugin() print("\n".join(sorted(names)))list_name_plugin()是 PluginManager 的方法,返回当前所有已注册插件的名字到实例的映射。这个函数我看源码时经常用,排查重复注册特别有效。
5.3 fixture 与插件的交叉
fixture 系统本身也是通过插件机制工作的。FixtureManager被注册为一个插件模块,fixture 的发现、解析、依赖注入全部由它实现。当你写@pytest.fixture时,本质上是在给 FixtureManager 提供数据,而 pytest 执行测试时,fixture的创建和销毁由pytest_fixture_setup、pytest_fixture_post_finalizer这些 hook 驱动。
这意味着你可以在插件里直接实现这两个 hook,从而在 fixture 创建前后插入逻辑,甚至覆盖 fixture 的返回值。不过这种玩法不常见,因为 fixture 本身就是很优雅的复用机制,大多数需求用 fixture 就能解决,不太需要动到底层 hook。
6. 实操中踩过的坑
6.1 插件明明装了却不生效
入口点注册是最容易出问题的环节。常见原因:包使用了旧版 setup.py 且没有pytest11entry point,或包尚未安装成可导入状态。排查思路很简单,先在 Python 里直接执行:
from importlib.metadata import entry_points eps = entry_points() if hasattr(eps, "select"): eps = eps.select(group="pytest11")看能不能扫到你的插件名。如果扫不到,说明打包声明有问题;如果扫得到,再在插件模块里加一行print,看导入会不会执行。我个人见过很多次pip install -e .之后 entry point 没刷新的情况,重装一遍就好。
6.2 conftest 里 pytest_plugins 的位置限制
前面提过pytest_plugins在 conftest.py 里必须写在顶部。源码层面是_pytest/config/__init__.py里consider_module会对 conftest 特殊处理,模块刚 import 完就会立刻读取pytest_plugins属性。如果这个属性定义在条件分支里,那一刻去读就是AttributeError或空值,插件自然不会被加载。
更隐蔽的坑:不要在 conftest.py 里同时定义pytest_plugins并把 conftest 自己加进去。conftest 模块本身已作为插件注册过了,再注册一遍会造成重复注册。behavior 上表现为有些 hook 执行两次,fixture 定义却有冲突。
6.3 hookimpl 参数签名不匹配
pytest 调用 hook 时,是按 hookspec 定义的参数名传参的。如果你的 hookimpl 参数列表和 hookspec 不一致,pluggy 在调用时会直接报错。报错信息会明确指出哪个 hook、哪个插件、参数不匹配。常见于升级 pytest 后大版本 hook 签名变化。
应对方法:别硬猜,去_pytest/hookspec.py查一下对应 hook 的最新签名。另外,如果你只想监听事件但对参数不感兴趣,可以在 hookimpl 函数里用**kwargs兜底:
@pytest.hookimpl() def pytest_runtest_logreport(self, **kwargs): ...这样即使 pytest 后续版本在 hookspec 里新增参数,你的插件也不会崩。但注意,有些 hook 是按参数名做关键字传参的,如果丢掉了具名参数,你后续想访问就取不到了。
6.4 tryfirst 与 trylast 的滥用
一个常见错误是给所有 hookimpl 都加trylast=True,觉得这样“最安全”。实际上 trylast 只是让这个实现排在其他排序之后,但如果有多个插件都加 trylast,那他们之间还是按注册顺序 LIFO 排序。你无法保证自己永远在最后。源码里_sort_hookimpls()对同类优先级内部用的还是反向注册顺序。
规则越简单越可靠:除非你明确想让自己的实现尽量靠前(tryfirst)或尽量靠后(trylast),否则不加就是最标准的做法。加了反而让调用顺序更难预测,排查成本直线上升。
还有一类问题是 hook 结果合并。有些 hookspec 带firstresult=True,比如pytest_report_header。这意味着调用链在第一次得到非 None 返回值时就提前返回。所以当你写插件给 header 追加文本时,其他插件的返回值可能根本没机会被框架看到,这也是多插件协作时的常见摩擦点。
我在实际项目里就因为pytest_report_header和另一个插件的返回顺序问题排查了两个小时,最后翻源码才发现firstresult=True的短路行为。这也是为什么我一直强调:用 pytest 插件系统,别只看文档里那几段 hook 说明,最好直接把对应 hook 的 hookspec 定义翻出来,看它是不是firstresult=True、是不是允许 wrapper,这两个信息决定了你能怎么用它。
最后聊两句
在做测试基建这几年里,我越来越觉得 pytest 的插件系统是最值得花时间研究的源码之一。它代码量不大,但设计思想非常完整——事件驱动、控制反转、约定优于配置,这些计算机领域的老概念在 pluggy 里表现得非常克制和优雅。弄懂这一套之后,再看 pytest-xdist、pytest-cov 这类插件的源码,基本就是顺水推舟了。
最后分享一个我自己的调试习惯:遇到奇怪的插件行为,先不去猜,直接在pytest_sessionstart里把config.pluginmanager.list_name_plugin()打出来,再用--trace-config看注册顺序。九成问题在这一步就能定位。剩下的,多半是 hook 语义理解偏差,那就静下心翻开pluggy/callers.py,一行一行读,总比盲改代码强。
希望这份源码视角的插件系统拆解,能让你下次遇到 pytest 的怪异行为时,多一分掌控感。