1. 为什么要读 Pytest 源码
做 Python 测试开发的,几乎没人绕得过 Pytest。但绝大多数人对它的理解停留在“会用”——Fixture 加个@pytest.fixture,参数化加个@pytest.mark.parametrize,报错了就看一眼堆栈往上爬。真出了问题,比如 Fixture 顺序不对、插件不生效、收集到不该收集的用例、断言失败信息不完整、conftest 的作用域理解错,基本就只能靠试错和猜。
我自己在接手一个大型测试平台的重构时被逼着开始啃 Pytest 底层源码。那个平台上有上千个测试用例,几十个插件,Fixtures 套 Fixtures,conftest 层层嵌套。线上环境一旦跑挂,定位问题的时间经常比修复时间还长。后来花了大概三个星期把_pytest目录下的源码通读了一遍,从config、main、python、fixtures、assertion到hookspec、pluggy,整个框架的运行机制才算在心里拼出了完整拼图。
这篇文章就是把这一路读源码的心得整理出来。我不会把每个函数都贴一遍,那跟看文档没区别。我打算按 Pytest 从启动到执行结束的完整链路,拆开几个关键模块,讲清楚它们内部到底发生了什么,每个核心类、核心函数在做什么,为什么这样设计。读完你可以获得两个能力:第一,遇到诡异的测试问题,能直接从机制层面推算原因,而不是瞎猜;第二,想写自定义插件或者二次开发,知道该在哪个 hook 上下手,而不是到处复制网上的代码片段。
适合读这篇文章的人:已经在用 Pytest 写测试、但想更进一步的中级开发者;准备研究测试框架源码但不知道从哪入门的读者;以及长期被 fixture、插件、断言问题折磨的测试开发同学。
2. 从入口开始:Pytest 启动时到底做了什么
很多人以为 Pytest 的执行入口就是pytest.main(),其实背后是一个多阶段的初始化流程。跟着调用链走一遍,先建立整体认知,后面读任何一个子模块都不容易迷路。
2.1 入口函数与被“藏起来”的主流程
先看最直观的入口。命令行的pytest命令实际执行的是_pytest/__init__.py里的console_main(),最后落到main()函数。main()并不是一个大方法,而是一个薄薄的分发器,内部真正干活的是_main.py里暴露出来的pytest_cmdline_main这个 hook 的默认实现。
def main(args=None, plugins=None): config = _prepareconfig(args, plugins) ... return wrap_session(config, doit)也就是说,Pytest 把整个命令行的主流程设计成了一个 hook——pytest_cmdline_main。任何插件都可以通过实现这个 hook 来替换、包装、甚至完全接管 Pytest 从解析参数到输出报告的整个生命周期。这个设计意图一开始就要抓住:Pytest 不是一个函数调用的堆叠,而是一个巨大的 hook 调用系统。整个框架的主线就是:加载插件 → 收集插件提供的 hook 实现 → 按优先级调用。
看源码的时候,我建议先打开_pytest/config/__init__.py。这个文件是整个框架最核心的文件之一,PytestPluginManager和Config都定义在这里。PytestPluginManager继承自 pluggy 的PluginManager,负责注册插件、加载插件的 hook 实现、分发 hook 调用。
启动顺序可以简化成四步:
- 解析命令行参数,产出
args和plugins。 - 调用
_prepareconfig,创建PytestPluginManager实例并注册内置插件与外部插件。 - 使用
Config对象承载全局配置。 - 通过
wrap_session开启会话,进入pytest_cmdline_main主循环。
2.2 插件加载的时机与顺序
插件加载是一个绝对不能忽视的重点。很多人写插件时发现自己的 hook 不生效,或者优先级不对,多半是没搞懂 Pytest 加载插件的顺序。
PytestPluginManager在实例化时,consider_pluginarg、consider_conftest、consider_env、consider_module这些方法分别对应不同来源的插件:命令行参数传入的插件、conftest 文件、环境变量PYTEST_PLUGINS、以及通过setuptools入口点注册的第三方插件。
加载顺序大致是:
- 先注册内置的
_pytest下的各个核心插件模块(比如_pytest.capture、_pytest.fixtures、_pytest.assertion等); - 再加载
PYTEST_PLUGINS环境变量里指定的插件; - 然后加载通过
-p参数传入的插件; - 接着是通过入口点自动发现的第三方插件;
- 最后是 conftest 文件里注册的插件。
这个顺序直接决定了 hook 的调用顺序和覆盖关系。内置插件最先注册,hook 基本都处于最底层,第三方插件可以“覆盖”它们,同优先级的 hook 按注册顺序从早到晚执行,而 conftest 作为最后注册的一批插件,拥有天然的高优先级。
我用一个很常见的问题验证过这个机制:为什么 conftest 里定义的 fixture 能被所有测试文件“看见”?因为在 Pytest 的插件体系里,每个 conftest 文件都会被视为一个局部插件,注册到与该目录路径关联的节点上。这也就引出了另一个有趣的问题:Pytest 里的插件并不是全局均匀分布的,有些插件在特定目录下才可见。
读源码时你会反复看到_pytest.config.PytestPluginManager._path2hookimpls和_pytest.config.PytestPluginManager._conftest_plugins这两个内部字典。前者存储每个路径对应的 hook 实现列表,后者存储每个路径对应的 conftest 插件模块。Pytest 在收集测试文件时,每进入一个目录节点,就会把该目录及所有上级目录的 conftest 插件注册进当前收集上下文。这就实现了“上一级 conftest 对下一级项目所有测试文件可见,同级目录之间互相不可见”的作用域隔离。
2.3 作用域隔离是怎么用代码实现的
展开讲一下_conftest_plugins的细节,因为这是读源码时最容易困惑的地方。
Config对象会维护两棵“树”:一棵是文件系统的目录树,一棵是插件可见性的作用域树。_pytest.config模块里有一个类_Conftest,它负责单个 conftest 文件的加载。当 Pytest 遇到一个目录,比如tests/api/,它会在_conftest_plugins里检查这个目录是否已经存在对应的插件模块;如果不存在,就加载该目录下的 conftest.py,创建一个模块对象并注册进PytestPluginManager。
注册进 manager 之后,这个 conftest 定义的 hooks 会带上一个基于路径的作用域标记。每当pytest_collectstart或pytest_collect_file等 hook 被调用时,Pytest 内部会构建一个_CollectorPath上下文,把当前收集路径与每个 hook 实现的作用域进行匹配,只有作用域允许的 hook 才会被推入待调用栈。
理解这个机制后,network 上有一类经典误区就不攻自破:很多人以为 conftest 里的 fixture 是“全局全局”的,其实它只是“对当前目录及子目录可见”。如果你把 conftest 放在tests/根目录,它能作用于整个tests/树;但如果你希望某个 fixture 只对某个子目录生效,就把 conftest 放到那个子目录去,而不是在代码到处用条件判断。源码里_path2hookimpls字典做的就是这件事:同一个 hook 名字下,来自不同层级的 conftest 的 hook 实现是分路径存储的,调用时按路径匹配,而不是简单地从全局列表里拿。
提示:读
_pytest/config源码时,建议时刻提醒自己“路径作用域”这个概念,它是理解 Pytest 前期初始化阶段的钥匙。
3. 收集机制:从文件夹到测试用例的变身术
Pytest 会遍历目录树,把每个文件、每个类、每个函数转换成树状结构。这个过程在源码里叫 Collection,是整个框架的骨架支撑。不理解收集机制,后面所有与“测试用例如何被找到、如何被跳过、如何被标记”相关的问题都无法深入。
3.1 收集树的结构设计与收集器类型
Pytest 的收集结果是一棵Collector树。整棵树的根节点是Session,它是由_pytest/main.py里的Session类承载的。往下是目录级别的收集器,再往下是文件级收集器,最末端是测试用例节点。
Collector是一个抽象基类,关键方法只有两个:collect()和reportinfo()。collect()返回子节点列表,这个过程是递归的——Session.collect() 找到所有顶层目录和文件,生成子 Collector;每个子 Collector 再调用自己的 collect(),以此类推。直到某类 Collector 的collect()返回的节点是Item类型(也就是具体的测试用例),这棵树才算长到叶子。
从源码的角度看,这部分的实现集中在_pytest/main.py和_pytest/python.py:
Session:根收集器,持有全局配置和插件管理器。Package:对应一个目录,内部进一步收集子目录和模块文件。Module:对应一个.py文件,负责解析模块内的测试类与测试函数。Class:对应一个测试类,收集类内的方法级测试项。Function:对应一个具体的测试函数。
每个收集器的collect()方法都是 hook 调用点。比如Module.collect()实际上调用的是pytest_pycollect_makemodule和pytest_collect_file这些 hook,而真正决定一个函数是否被识别为测试函数的逻辑,则封装在_pytest/python.py里的PyCollector和pytest_collectstart相关的代码中。
3.2 默认命名规则是怎么生效的
收集器在“决定某个对象是不是测试用例”时,遵循默认命名规则:函数名或类名以test开头(或者以_test结尾,取决于版本差异),类名以Test开头。这条规则的执行位置在_pytest/python.py的is_test_function和is_test_class中。
跟着Module.collect()走一遍,它会先对模块对象进行inspect.getmembers扫描,然后对每个成员调用is_test_function判断是否纳入收集。如果是,就通过Function.from_parent创建一个Function节点。
from_parent是 Pytest 中一个极其重要的工厂方法模式。由于 Pytest 核心支持多个版本的 Python 与插件组合,所以在对象初始化时保持兼容与稳定非常重要。from_parent接收父节点与关键字参数,传入parent和nodeid等核心信息。在源码中,Function.from_parent会再调用Function.__init__,并把callspec(参数化信息的载体)一并传递进去。这一层的抽象,使得收集器可以灵活地在不同场景下创建测试项,而不用直接暴露复杂的__init__签名。
3.3 自定义收集器要动的几个 hook
读源码最大的收益,是你能知道该在什么位置“切断”默认行为,注入自定义逻辑。
如果不想用默认的命名规则,比如要让CheckXxx也被识别为测试类,你就得在插件或 conftest 里实现pytest_pycollect_makemodule的配套逻辑,或者直接覆盖pytest_collect_file。实际开发中更常见的做法是实现pytest_collection_modifyitems这个 hook 来修改已经收集到的用例列表,比如按标记过滤、按文件路径排序、动态添加或删除用例。
源码级别来理解:pytest_collection_modifyitems在Session.perform_collect()中的调用点在收集完成后、运行测试前。perform_collect()返回收集结果后,会依次调用pytest_collection_modifyitems(session, config, items)。也就是说,所有测试用例都已经以Item对象的形式存在于items列表里,你的 hook 可以直接对这些对象做任何操作。
我自己处理过一个场景:某个历史项目的测试函数命名完全没有规律,不能动源码,我就在 conftest 里写了一个pytest_collection_modifyitems,通过读取函数源码中的注释标记(比如# testcase: verify_login)来动态重写item.nodeid和item.name,让报告输出和筛选行为全部符合新的规范。这个方案不需要修改被测代码,只依赖收集机制本身,就是靠读源码才想到的。
注意:在
pytest_collection_modifyitems里修改item.nodeid要非常谨慎。因为nodeid不仅是报告里显示的标识,还用于去重、缓存、失败重跑选择等逻辑,随意重写可能导致数据不一致。我一般只重写item.name,保留nodeid的原始路径信息。
4. Fixture 机制的核心源码:看似简单的依赖注入
Fixtures 是 Pytest 最受欢迎的功能,也是源码中最绕的一部分。_pytest/fixtures.py这个文件有大概 2000 多行代码,值得反复阅读。很多人看了一两遍还是懵,原因是 Fixture 的底层实现不是一个单纯的对象,而是由FixtureManager、FixtureDef、SubRequest、FixtureFunctionDefinition等多个类协作完成的,每个类各管一段。
4.1 FixtureManager 的职责边界
FixtureManager是一个挂在Config对象上的组件。它负责三件事:
- 解析所有 fixture 定义,建立 fixture 名称到定义对象的映射;
- 根据依赖关系,生成执行顺序;
- 管理 fixture 实例缓存和作用域生命周期。
在源码中,FixtureManager有一个非常重要的内部数据结构:_arg2fixturedefs,这是一个从参数名到 fixture 定义列表的字典。当 Pytest 准备运行一个测试用例时,会解析该用例的函数签名,找出所有参数,然后逐个从_arg2fixturedefs里查找对应的 fixture 定义。
如果某个参数在_arg2fixturedefs中不存在会怎样?就会报fixture 'xxx' not found。这里的重点在于:_arg2fixturedefs查找时并不是完全持平地全局查找,而是携带了当前节点的“位置上下文”,因此同一个 fixture 名在不同目录下可能对应完全不同的定义——这就是 conftest 作用域隔离在 fixture 层的再一次体现。
FixtureManager还会维护一个_fixturedefs缓存,记录那些已经被扫描过的 fixture。避免重复解析,提高性能。当你在 conftest 里定义一个 fixture,本质上就是把一个函数对象注册进了_fixturedefs,等测试用例运行到时再按需实例化。
4.2 FixtureDef 是如何“按需生产”的
FixtureDef封装了一个 fixture 的完整信息:函数对象、参数名、作用域、是否自动使用、fixture 实现本身的依赖项。它有一个核心方法execute(),负责执行 fixture 函数并返回值。
这里有一个非常容易踩坑的细节:fixture 函数的“参数”就是它的依赖项。FixtureDef在创建时就会检查 fixture 函数的签名,识别它所需的参数名,并把它们作为依赖记录下来。执行时,FixtureDef.execute()会先解析所有依赖 fixture,再执行本体。
所以如果你写了这样的 fixture:
@pytest.fixture def user(db_connection): return create_user(db_connection)那么在收集到user这个 fixture 定义时,源码会解析出依赖项db_connection,然后在user被请求时,先实例化db_connection,再执行user函数体。整个过程是递归的:db_connection可能也有依赖,Pytest 会一直向上解析,直到没有依赖为止。
递归依赖解析的实现位置在FixtureDef.cached_result和FixtureManager.getfixturedefs的配合中。getfixturedefs负责找到 fixture 定义,cached_result负责存储和执行结果。
4.3 scope 是怎么控制缓存和清理的
scope是 fixture 的灵魂选项。源码中fixture装饰器接收scope参数后,会把它写进FixtureFunctionDefinition,最终由FixtureDef使用。scope直接决定了两件事:
- 缓存存活的时间范围;
- 缓存失效后如何清理。
举个具体例子:
@pytest.fixture(scope="module") def data_loader(): loader = Loader() yield loader loader.close()当测试用例请求data_loader时,FixtureManager会检查当前作用域的缓存:如果缓存里有结果且作用域未结束,就直接复用;如果没有,就执行 fixture 的 yield 部分(到 yield 为止),把返回的loader缓存起来。等模块收集结束、作用域退出时,再执行 yield 之后的代码,也就是loader.close()。
源码中控制这个逻辑的核心是fixtures.py里的_FixtureManager与SubRequest的_get_next_fixture方法。如果你自己去看源码,建议从fixture装饰器开始追,顺着FixtureDef、SubRequest、_pytest.fixtures._get_fixturestack这几个线索往下读。
实际项目中我遇到过一个问题:一个session级 fixture 返回了数据库连接,但测试跑完后连接没有关闭,导致后续其他测试进程连不上数据库。排查后发现原因是这个 fixture 的清理逻辑写在yield之后,但作用域是session,而我只在模块内 import 了 conftest,以为模块结束时清理会执行。其实 session 级 fixture 的清理要等整个 pytest 会话结束。读源码后就看明白了:scope="session"的 fixture 由FixtureManager的 session 级缓存持有,只有Session节点退出时才触发finish()清理。
4.4 关于 fixture 实例化的线程与进程边界
还有一个经常被忽略但源码里有明确实现的点:FixtureDef缓存数据不是全局共享的。默认情况下 fixture 缓存挂在FixtureManager实例上,而这个实例属于当前运行进程。如果你用pytest-xdist跑多进程并行,每个 worker 进程都会有一套独立的 fixture 缓存。session 级 fixture 在 xdist 下会被每个 worker 分别执行一次,除非用了特殊机制(如tmp_path_factory配合sessionscope)来共享文件系统。
源码中,xdist与 fixture 的交互是通过pytest_sessionfinish和pytest_sessionstart等 hook 来实现的,没有绕开 FixtureManager 的核心逻辑,而是在会话级生命周期上做了额外处理。读到这里,如果你理解了 Pytest 中“对象是挂在节点树上的”这一核心思想,就不难推理出:每个测试进程启动时都会构建一棵独立的节点树,因此 fixture 缓存天然是进程隔离的。
5. 断言重写的实现:连“失败信息”都被精心设计过
Pytest 最让人惊讶的一点是:当你写assert result == expected失败时,控制台能打印出左右两侧的值、类型差异,甚至还能提示“右侧多了两个元素”。这不是 Python 原生 assert 能做到的,而是 Pytest 在收集阶段对测试模块的字节码“动过手脚”。
5.1 断言重写到底改了什么
Python 原生的 assert 语句在被解释器编译成字节码时会被优化:如果__debug__为 False(即运行在-O模式下),assert 语句会被直接丢弃。即便保留下来,也只会产生一个 AssertionError 和一条简短的失败描述,比如assert result == expected,根本没有左右值的详细信息。
Pytest 用_pytest/assertion/__init__.py和_pytest/assertion/rewrite.py实现了一套断言重写机制。核心做法是:在收集测试模块时,不直接使用 Python 原有的编译结果,而是先读取模块的源码,通过ast模块解析成抽象语法树,然后遍历这个语法树,找到所有Assert节点,把它们改写成一个自定义函数的调用。
改写后的伪代码大概长这样:
# 原始断言 assert result == expected # 改写后 import _pytest.assertion.rewrite as _pytest_assertion_rewrite _pytest_assertion_rewrite.rewrite_asserts( result == expected, "assert result == expected" )rewrite_asserts内部会把同一个表达式的各部分文档化,保留左值、右值、操作符等语义信息,生成一个AssertionError,携带完整内容。这样我们才能在失败报告里看到assert 1 == 2,然后跟随一个箭头注释+ where 1 = result。
5.2 断言调试信息的生成原理
(前面提到“断言重写”是源码中比较独立且复杂的模块,这里单列一节,具体展开信息生成流程。)
如果你想深入读这个模块,留意assertion/rewrite.py里的AssertionRewriter类。这个类的visit_Assert方法负责处理每一个断言节点,它会做这几件事:
- 将比较表达式拆开,把左值、右值分别提取为临时变量;
- 为每个子表达式生成说明字符串;
- 调用
_pytest.assertion.rewrite._format_explanation生成实际输出; - 返回一组新的 AST 节点,嵌入新的调用逻辑。
这里有个隐藏知识点:Pytest 重写断言不仅作用于 assert 那个表达式,还会递归处理表达式里的布尔运算符,比如and、or。如果你写assert a and b,源码会把每个子表达式单独看管,用“分段判断”的方式来提取失败的具体位置。这也就是为什么你在报告里看到assert (a and b)失败时会显示:
assert a and b E assert False以及伴随每个子表达式的where信息。
5.3 哪些场景下断言不会被重写
读源码时我还注意到一个实用结论:断言重写只作用于被 Pytest 收集的测试模块,不是所有 Python 文件都会被重写。如果你在 conftest.py 里写 assert,Pytest 默认不重写;如果你在第三方库里写 assert,Pytest 也不会重写。只有当你设置了--assert=rewrite模式并且该模块是测试模块(或被pytest_assertrepr_comparehook 显式处理)时,断言改进才生效。
这是因为重写会显著改变模块的加载行为,如果对 Pytest 自身的模块做重写会破坏插件系统。所以源码里有一块逻辑会排除_pytest自己的模块。掌握这一点,你就知道为什么有时在源码里看见简单的 assert 但报错信息却很简陋——因为那段代码不是测试模块。
5.4 自定义断言比较信息
_pytest/assertion/__init__.py里暴露了一个极其实用的 hook:pytest_assertrepr_compare。它允许你自定义失败报告中最底下那两行对比信息。
举个例子。默认情况下,当你比较两个列表时,Pytest 会列出差异项。但如果你比较的是两个“领域对象”,默认输出就没有太大意义。你可以这样自定义:
# conftest.py def pytest_assertrepr_compare(config, op, left, right): if op == "==" and isinstance(left, DomainObject) and isinstance(right, DomainObject): return [ "DomainObject 比较失败:", " 左边: %s" % left.repr_for_assert(), " 右边: %s" % right.repr_for_assert(), ] return None这个 hook 的返回值是一个字符串列表,每一行都会按原样打印到报告中。源码里,当断言重写模块构造失败信息时,会调用这个 hook,如果返回值不为 None,就覆盖默认的 diff 内容。这个 hook 是扩展测试可读性最直接的手段。
注意:不是所有版本的 Pytest 都会把
pytest_assertrepr_compare应用到每一个断言上。建议读源码时检查rewrite.py里_format_assertmsg的调用条件,你会发现它只在部分操作符和表达式上被触发。如果你发现自定义 hook 在某些场景不生效,优先回去看那段调用条件。
6. 插件与 Hook 调度的底层架构
Pytest 之所以能被无限扩展,靠的就是 pluggy 库提供的 hook 系统。理解了这套系统,你才能真正理解“插件如何影响框架运行”这个问题。
6.1 pluggy 的核心抽象:hookspec 与 hookimpl
精确地说,Pytest 使用的 hook 框架是pluggy,它是由 pytest 团队维护的独立库。pluggy中有两个核心装饰器:
@hookspec:标记这个接口是“规范”。@hookimpl:标记这个函数是某个规范的“实现”。
Pytest 内部会定义很多hookspec,例如:
@hookspec def pytest_runtest_setup(item): """Run before test"""第三方插件可以任意实现这些 hook:
@pytest.hookimpl def pytest_runtest_setup(item): print(f"Before test: {item.name}")pluggy 的PluginManager接收所有注册进来的插件模块,扫描它们内部所有被@hookimpl修饰的函数,收集到一张“hook 名称 → 实现列表”的映射表。之后,当 Pytest 想调用某个 hook 时,就会通过HookCaller对象来“广播”这一事件。
HookCaller是 pluggy 里的核心执行器。它内部维护所有已注册的 hookimpl,并按以下规则排序:
- 显示指定
tryfirst=True的排在前; - 显示指定
trylast=True的排在后; - 未指定的按注册顺序排列,先注册的先执行。
6.2 hook 调用的四种模式
读 pluggy 源码时,区分hook的调用模式非常重要。按返回值处理方式,HookCaller大概有四种模式:
- firstresult:只取第一个非 None 的返回值,后续 hookimpl 不再执行。
- lastresult:取最后一个非 None 的返回值。
- apply:每次调用都会把所有 hookimpl 跑一遍,返回值不合并。
- multicall 模式:所有 hookimpl 都会被调用,并把返回值收集到结果列表中。
Pytest 中最常见的pytest_collection_modifyitems就是一个“无返回值但有副作用”的 hook,pluggy 会执行所有注册的 impl,无论返回值是什么。而pytest_assertrepr_compare则属于firstresult语义:只要某个 impl 返回了非 None,就停止调用后面的 impl,直接把该返回值作为最终结果。
这个机制直接关系到插件的“覆盖能力”。如果你希望“替换”默认行为,就应该尽早介入(tryfirst=True),并且返回一个结果来阻断后面的 impl 继续执行。反之,如果你只是“观察”,就老老实实让 impl 返回 None,让后面的实现继续。
6.3 hookwrapper:包裹整个调用链路
pluggy还有一类特殊的hookimpl,称为hookwrapper。它的特点是:可以让一个函数“看到”整个 hook 调用的全过程,并在调用前后注入逻辑。
@pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: print(f"Test failed: {item.name}")这段代码会被 pluggy 包装成一个_HookCaller内部调用流程中的一环。yield之前的部分在所有非 wrapper impl 之前执行;yield之后的部分在所有非 wrapper impl 执行完毕后执行。
这个机制在源码的_pytest/runner.py中很常见,比如pytest_runtest_makereport的实现就大量使用了 hookwrapper 来包裹测试周期中的 setup、call、teardown 三个阶段。使用 hookwrapper 可以让你很方便地在测试执行前后做日志埋点、性能统计、异常上报,而不需要侵入 runner 的核心代码。
6.4 从源码出发的插件调试技巧
如果你想确定某个 hook 在当前项目中到底被谁实现、执行顺序如何,有个特别实用的源码调试方法:在 pluggy 的_HookCaller.__call__方法上打断点,观察传入的hook_impls列表,里面能看到所有实现函数所在模块与顺序。
我自己写插件时踩过一个坑:pytest_sessionfinish在 conftest 里无论怎么写都不生效,排查了半天,最后才发现是因为这个 hook 在session结束时已经被某个第三方插件用tryfirst=True先行占用并返回了非 None,导致后续 impl 被跳过。看源码里的_HookCaller.execute判断逻辑,一目了然。
所以重要经验是:写插件 debug 时优先看 hookimpl 的调用顺序与返回模式,而不是怀疑代码执行了却没有效果。当你在 conftest 里定义了一个不存在于任何 hookspec 的同名函数,pluggy 不会报错,只会把它当成一个普通函数忽略掉。这也是很多插件问题的根源。
7. 几个实战问题的源码级排查思路
学了底层机制,最终要落到排查问题上。这一节整理几个我真实处理过的疑难问题,全部从源码角度给排查思路,不靠堆日志盲猜。
7.1 “fixture 找不到”的常见误导
遇到fixture 'X' not found这类报错,第一反应是“我没定义这个 fixture”。但源码级别的排查要这样走:
- 看
_arg2fixturedefs里是否真的有该名称的 fixture 定义;如果没有,说明 fixture 定义的作用域确实不覆盖当前测试路径。 - 检查
conftest.py是否被加载。Pytest 是通过 conftest 所在目录路径去注册插件模块的。如果 conftest 文件名打错了、或所在目录根本不是测试目录的子路径、甚至__init__.py缺失导致包结构异常,conftest 都可能静默不加载。 - 检查插件加载顺序。某些第三方插件可能在收集阶段就把 conftest 屏蔽了;真实遇到过的案例是
pytest-ini插件里的某个 hookimpl 拦截了pytest_load_initial_conftests,导致 conftest 根本没进初始化链。
从源码上看,核心定位方法是:在PytestPluginManager.consider_conftest里加日志,观察 conftest 是否被找到并注册;再在FixtureManager.getfixturedefs里观察查找结果。这两个断点位置能覆盖大多数 fixture 找不到问题。
7.2 fixture 作用域越界与“缓存污染”
当你在一个 session 级 fixture 里返回可变对象,某个模块修改了它,后续模块读到的数据就被污染了。源码层面解释:session 级 fixture 的缓存实例挂在Session节点对应的上下文上,整个测试会话期间不会重新创建。排查时可以用pytest_fixture_setup和pytest_fixture_post_finalizer这两个 hook 来打印 fixture 实例的id或内容 hash,判断是否同一份对象在多个模块间被复用。
真正的治本方案往往不是清理缓存,而是把 fixture 设计成“每次只提供构造方式,不直接提供共享可变对象”。或者使用scope="module"控制粒度,避免 session 级跨模块污染。
7.3 插件 hook 不执行/执行顺序错乱
这类问题最常见的根源有:
- 插件的
hookimpl函数没有被@pytest.hookimpl装饰,导致 pluggy 扫描不到; - 插件模块没有被注册(比如没有在
pyproject.toml或setup.py的 entry points 里声明,也没有通过-p加载); - 同名 hook 被某个 tryfirst/trylast 实现抢占;
- hookimpl 的返回模式不对,导致后续没有执行。
从源码角度的排查方法是:在pluggy的_HookCaller.__call__中打印hook_impls列表,你会看到每个 impl 的function和plugin_name。如果某个自定义函数的 impl 没有出现在列表里,说明注册环节出了问题;如果出现了但顺序不对,就要检查tryfirst/trylast选项。
7.4 调试源码的三个核心断点
如果在读源码或做二次开发时想快速定位问题,我建议先打好这几个断点作为起点:
_pytest/config/__init__.py里的PytestPluginManager.register:所有插件的入口。pluggy/_hooks.py里的_HookCaller.__call__:所有 hook 的调度中心。_pytest/fixtures.py里的FixtureManager.getfixturedefs:所有 fixture 查找的入口点。
这三处一旦能熟练打断点、观察调用栈,就等于给整个 Pytest 装上了“监控眼”。之后不管问题出在哪个层面,都能顺着调用栈层层下探。
8. 写在最后:源码阅读的顺序与建议
先交代一下我推荐的阅读顺序,按这个顺序读下来,理解成本最低:
- 先读
_pytest/config/__init__.py:建立Config与PytestPluginManager的整体模型。 - 再读
_pytest/main.py:看Session和收集流程的骨架。 - 接着读
_pytest/python.py:理解测试项如何从 Python 对象中诞生。 - 然后读
_pytest/fixtures.py:这是最费脑但收益最大的一步。 - 最后读
_pytest/assertion/rewrite.py和 pluggy 源码:前者满足好奇心,后者是扩展能力的基石。
读源码时不要逐行读,要有“目标地”读。比如说,我今天就想搞清楚为什么 conftest 的 fixture 作用域和我想的不一样,那我就只读fixtures.py里与作用域相关的部分,够用就行。等下次遇到新问题,再回来补新的分支。这种按问题驱动的阅读方式,比从第一行看到最后一行有效得多。
我个人在实际操作中的体会是:Pytest 的源码并不可怕,可怕的是没有方法论地乱读。抓住三个关键词——“路径作用域、hook 调用链、fixture 缓存生命周期”,大多数问题都能在源码里找到答案。真正吃透之后,写测试、写插件、排查线上环境,都会有一种“心里有地图”的感觉。
最后再分享一个小技巧:如果你要在公司内部推广测试框架二次开发,建议先让团队成员实践“从 conftest 里加一个 hookwrapper,给所有测试的 setup/call/teardown 阶段打日志”这个小任务。这个任务几乎触碰了 Pytest 所有核心模块——插件注册、hook 调度、fixture 生命周期、收集机制,但门槛又不高,是快速上手源码分析的最佳练手项目。