ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

pytest源码深度解析:从钩子机制到断言重写的底层原理

pytest源码深度解析:从钩子机制到断言重写的底层原理 从实际遇到的一个诡异问题说起某天我运行 pytest 时发现测试用例明明写了assert result expected失败信息却只显示两个对象不相等完全没有中间变量的上下文。当时我第一反应是“这框架是不是坏了”直到我翻开_pytest/assertion/rewrite.py的源码才明白pytest 的断言增强其实是一套完整的字节码改写机制而不是简单的字符串拼接。这事之后我就养成了一个习惯不管用哪个框架先花时间把底层源码通读一遍。pytest 作为目前 Python 生态里最主流的测试框架它的源码值得深入解析因为它不只是“跑用例的工具”更是一个设计精巧的插件系统一个自带字节码改写能力的元编程框架一个把fixture做到极致的依赖注入容器。本文就把我阅读 pytest 源码的核心收获整理出来从启动入口、收集机制、fixture 设计、断言重写、钩子系统到运行协议一层层拆开看不回避细节尽量让每个结论都能对应到实际代码。这篇内容适合两类读者一类是已经写了大量 pytest 用例、想搞清楚“为什么 fixture 作用域是这样”“为什么断言失败能显示那么细”的进阶用户另一类是准备基于 pytest 写插件、做二次开发的框架开发者。看完你会知道 pytest 真正的边界在哪里以及它的优雅和复杂分别藏在哪些地方。1. 从入口出发pytest 命令是如何一步步接管整个测试流程的1.1 入口文件与 main 函数的工作逻辑大多数人都知道在命令行敲一个pytest就能跑测试但几乎不会有人去问这个命令对应的 Python 代码到底长什么样。实际上pytest 安装后会生成一个 console_scripts 入口指向pytest/__main__.py核心内容是# pytest/__main__.py import pytest import sys sys.exit(pytest.console_main())pytest.console_main()定义在_pytest/config/__init__.py它内部调用了main()。而main()才是整个 pytest 的神经中枢# _pytest/config/__init__.py简化还原 def main(argsNone, pluginsNone): config _prepareconfig(args, plugins) ... return config.hook.pytest_cmdline_main(configconfig)关键点就在这里main()做了两件事。第一通过_prepareconfig初始化一个全局唯一的Config对象第二通过config.hook.pytest_cmdline_main(configconfig)把“接下来该干什么”的决定权交给钩子系统。注意这个设计思路main()本身不负责收集用例不负责执行用例它只负责把配置对象准备好然后往外抛一个钩子事件。谁响应这个钩子谁就控制流程。pytest 默认的pytest_cmdline_main实现是_pytest.main.wrap_session这个wrap_session会先创建一个Session对象然后调用session.exit()进入主循环。从这里就能看出 pytest 的第一个底层原则控制流不是硬编码的而是通过事件分发驱动的。所有核心流程收集、运行、报告都是钩子函数的响应结果。这也是 pytest 能支持无数第三方插件的根本原因。1.2 Config 对象与插件初始化之间的秘密关系_prepareconfig这条链路值得单独拿出来讲因为它是 pytest 启动时最容易被忽略却又最重要的部分。简化后的调用链是def _prepareconfig(args, plugins): # 1. 解析命令行参数 # 2. 读取配置文件pytest.ini、pyproject.toml 等 # 3. 注册内置插件 # 4. 注册外部插件 # 5. 执行 pytest_configure 钩子 ...其中有个细节pytest 会先解析命令行参数再读取配置文件然后用这些信息决定加载哪些插件。也就是说你在命令行加的-p no:cacheprovider这类参数确实是在插件加载之前就被解析并生效了的。我最早读源码在这里卡了很久因为我想不明白conftest.py到底什么时候被加载。后来才搞清楚conftest.py不是启动时一次性加载的而是在收集阶段按目录层级逐步导入的。启动阶段加载的只是“初始插件”包括 pytest 自带的所有内置插件如_pytest.fixtures、_pytest.assertion、_pytest.capture等和你通过-p指定或 entry_points 注册的第三方插件。这个加载顺序直接决定了你在conftest.py里定义的 hook 无法覆盖内置插件的某些行为因为内置插件早就注册完了。如果确实需要全局覆盖就必须通过-p参数在更早的阶段把插件注册进去。还有一点很关键Config对象是 pytest 里几乎所有组件都持有的共享上下文。无论是Session、Item、FixtureManager还是Reporter最终都能通过.config拿到这个全局对象。所以你在写插件时尽量不要自己建全局变量直接挂在config上反而更干净这也符合 pytest 内部的使用范式。2. 收集机制与节点树pytest 如何把一个目录变成一棵用例树2.1 Collector、Session、Item 的角色划分与完整链条你运行pytest tests/时pytest 不会“遍历所有文件然后找出所有 test 函数”这么简单它是按照一套严格的对象模型来构建测试集合的。这套模型的核心是Collector和Item。先看_pytest/main.py里的Session类。Session是一个特殊的Collector它的collect()方法会遍历起始路径遇到目录就创建Package收集器遇到.py文件就创建Module收集器。Module.collect()会扫描模块内的所有对象发现满足测试命名规则的类就创建Class收集器发现满足测试命名规则的函数就创建Function项。Item和Collector的区别在于Item是可执行的测试用例它知道“怎么运行自己”Collector是容器它知道“怎么找到下一层的东西”。整个流程可以理解为一种递归下降从Session这个根节点开始逐层调用collect()最终生成一棵节点树。这里有个容易误解的地方Function在 pytest 里通常指的是Function这个Item类型而不是 Python 函数本身。每个测试用例最终会被包装成一个Function实例这个实例既持有原始的 Python 函数对象也持有模块、类等路径信息。Function.__init__时还会解析这个用例的参数化信息、fixture 依赖等。2.2 模块、类、函数各层级的收集细节我们实际看_pytest/python.py里的几个关键类。首先是Moduleclass Module(PyCollector): def collect(self): # 使用 importmode 导入模块 self.session._fixturemanager.parsefactories(self.obj) ... for name, obj in self.obj.__dict__.items(): if isinstance(obj, staticmethod): obj obj.__func__ ... if self.isinitpath(obj): continue if isclass(obj): # 类收集器 elif isfunction(obj): # 函数收集器Module.collect()有个重要动作遍历模块的__dict__根据对象类型分派到不同的收集器。若遇到类就检查类名是否匹配python_classes若遇到函数就检查函数名是否匹配python_functions。这两个配置项默认分别是Test*和test*但你要搞清楚它们匹配的是“名字”而不是“类型”这就是为什么你写class MyTest:而方法叫check_something时pytest 不会收集它。Class.collect()更灵活它会通过collect_members遍历类的属性把方法名匹配python_functions的方法变成Function项。但是只有当类的__init__方法不接收额外参数时pytest 才会允许收集这个类里的测试方法否则会报initfailure。这个细节在实际项目中非常常见特别是当你给测试类写了带参数的__init__时。再说Function。Item的父类Function在收集阶段并不直接执行函数它只负责把调用所需的信息存下来。真正执行发生在运行阶段两者分离是 pytest 设计里很重要的一点收集阶段要快、要安全不能因为某个用例导入出错就拖垮整个测试集合所以 pytest 提供了--continue-on-collection-errors这样的选项让收集阶段的异常不会阻断整个流程。2.3 节点 ID、路径与 parametrize 参数的内存表示每个节点无论是Collector还是Item都有唯一的nodeid这是 pytest 定位用例的“坐标系统”。nodeid通常由文件路径和参数化参数组成比如tests/test_demo.py::test_add[12-3]这个nodeid是执行、筛选、报告的基础。你单独跑某条用例时pytest tests/test_demo.py::test_add[12-3]实际上就是用这个nodeid去匹配收集到的节点。参数化在内存里的表示方式很有意思。当你在测试函数上标记pytest.mark.parametrize(a, [1, 2])时pytest 会在收集阶段调用Function里的_initaxistrans和方法_getobj把参数列表展开成多个“虚拟函数对象”每个虚拟函数对应一个Function实例它们的nodeid会带上参数化片段。这意味着下面两行代码其实创建了多个测试用例pytest.mark.parametrize(num, [1, 2, 3]) def test_num(num): assert num 0test_num会被拆成test_num[1]、test_num[2]、test_num[3]三个Item。展开时 pytest 会先把参数值转换成字符串表示再做合法性清洗去掉文件名不安全字符所以你会在nodeid里看到很多奇怪的转义符号比如参数里包含/会被编码成%2F之类。理解节点树和nodeid的生成规则对排查“为什么我只想跑一条用例却执行了五条”“为什么筛选表达式不生效”这类问题帮助极大。也只有在理解了nodeid之后你才能真正用好-k表达式的匹配逻辑因为它的底层实现就是在遍历节点树、对每个节点的nodeid做正则或表达式匹配。3. Fixture 体系的底层逻辑看似“魔法”的依赖注入是如何实现的3.1 FixtureDef 与 FixtureManager 的核心结构pytest 的 fixture 机制是我认为整个框架最精彩的部分没有之一。从使用者的角度看你只需要写pytest.fixture def db(): return create_db()然后在测试函数里加一个同名参数dbpytest 就会自动把返回值注入进来。但在源码层面这个过程的复杂度远超大多数人认知。核心有两个类FixtureManager和FixtureDef。FixtureManager负责管理和解析所有 fixture 定义它维护了三个关键字典_name2fixturedefsfixture 名字到定义列表的映射_arg2fixturedefs参数名到 fixture 定义列表的映射_holder节点与 fixture 的持有关系当Module.collect()被调用时会同步调用fixturemanager.parsefactories(self.obj)这个函数会扫描模块里所有被pytest.fixture装饰的对象把每个 fixture 注册成FixtureDef。FixtureDef里保存了fixture 函数本身scope作用域params参数化数据autouse是否自动使用以及其他配置信息FixtureDef的内存结构决定了 pytest 在运行用例前就能静态分析出每个用例依赖哪些 fixture而不需要等到执行时才发现问题。3.2 fixture 实例化流程的完整路径当Function需要执行时pytest 会调用fixturemanager.getfixtureclosure获取这个用例所需的全部 fixture 依赖形成一张依赖图。这张图会按依赖顺序排序确保父级 fixture 先于子级 fixture 被实例化。实际实例化发生在FixtureManager的_getautousefixtures和getfixtureinfo配合之下。getfixtureinfo会根据函数签名中的参数名去_arg2fixturedefs里查找匹配的 fixture。找到后pytest 会创建一个FixtureDef实例这个实例内部有cached_result字段用于存缓存值。实例化流程的核心是FixtureDef.cached_result和finish()方法class FixtureDef: def __init__(self, ...): self.cached_result None self._finalizer [] ...当一个 fixture 被请求时pytest 会先检查当前作用域内是否已有缓存结果。如果有就直接返回缓存如果没有就调用 fixture 函数拿到返回值后存入缓存并注册 finalizer。scope的作用本质上就是决定这个cached_result挂在哪个节点上function时挂在用例节点class时挂在类节点module时挂在模块节点session时挂在 session 节点。所以我常说fixture 的scope不是“执行几次”的概念而是“缓存放在哪一层”的概念。当你把scopesession的 fixture 用在多个文件时它只被创建一次因为它的缓存放到了Session节点上但你把它改成scopefunction它就变成每个用例都创建一次因为缓存跟着用例节点走。3.3 yield fixture 与 teardown 的真实调用链yield fixture 是个非常典型的语法糖但如果你只停留在“yield 前半段是 setup后半段是 teardown”的认知那你还没真正理解它。源码层面yield fixture 的处理逻辑在FixtureDef.execute和_pytest.fixtures.py的_FixtureManager中。当 pytest 执行一个 yield fixture 时它实际上把 fixture 函数封装成一个生成器调用。第一次next()获取 yield 出的值将其作为 fixture 结果缓存当整个测试层级结束后pytest 才会继续驱动这个生成器执行 yield 之后的代码也就是 teardown 逻辑。这个“推迟执行”是通过finalizer机制实现的。FixtureDef内部维护了一个_finalizer列表调用生成器的close()或next()让生成器走到最后时所有注册的 finalizer 都会被一一调用。一个实际中的问题如果你在 yield fixture 里写了 try/finally那么 finally 里的代码仍然会执行但如果你在 yield 之后写普通代码它只在 teardown 阶段执行。这里的先后顺序和异常处理逻辑是setup 阶段抛异常测试用例不会执行teardown 依然会执行。这一点在源码里的处理非常精细是_pytest.fixtures.py里几百行代码专门处理的场景。从我踩过的经验看有一条建议特别值得说不要在一个 fixture 里堆太多 teardown 逻辑严格用request.addfinalizer或yield的后半段。因为 pytest 在执行 teardown 时是“反向顺序”的后创建的 fixture 先销毁这和 Python 的with语句嵌套展开是同一个顺序。你如果靠记忆去推断销毁顺序非常容易出错不如把每个资源销毁写清楚再用request.addfinalizer明确注册。3.4 动态 fixture 与 fixture 工厂模式源码里还有个容易被忽略的场景fixture 的“动态请求”。你可以在测试函数内部通过request.getfixturevalue(some_fixture)来动态获取一个 fixture而不是在函数签名里静态声明。这个 API 在_pytest.fixtures.py的FixtureRequest类里是这样实现的def getfixturevalue(self, argname): fixturedef self._get_active_fixturedef(argname) return fixturedef.cached_result[0]它会先检查当前请求上下文中是否有激活的 fixture 定义如果没有就尝试动态创建。动态创建和静态声明的唯一区别是“依赖分析阶段不同”静态声明会在收集阶段就构建依赖图动态请求则是到了执行阶段才临时去解析。所以你在动态请求时如果写错 fixture 名字只有运行到那个用例时才会报错而不是在收集阶段就失败。这个差异对排查“为什么我动态获取 fixture 时报 fixture not found”很有帮助。fixture 工厂模式也很常见你定义一个有返回函数的 fixture外界通过调用这个函数来创建数据。比如pytest.fixture def make_user(db): def _make_user(name): return User(namename) return _make_user本质上没有特殊的底层逻辑只是测 fixture 可以返回任意对象包括函数。但从经验上讲这种模式有利于在用例内动态创建多份数据同时又能共享外部资源比如 db非常推荐在接口自动化项目里使用。4. 断言重写机制pytest 凭什么能让断言失败信息那么详细4.1 基于 AST 的断言改写过程普通 Python 里assert a b失败后只会抛出AssertionError没有更多信息。但 pytest 能把断言失败信息填充得极其丰富比如assert 1 2 E assert 1 2 E where 1 func_a() E and 2 func_b()这个能力来自 pytest 的断言重写assertion rewriting。原理一句话就能说清pytest 会在导入测试模块时先用ast模块解析源码找出所有assert语句把它们改写成一段能收集中间表达式的代码然后再编译执行。这个改写动作发生在_pytest/assertion/rewrite.py的AssertionRewriter类里。核心方法是def visit_Assert(self, assert_): # 计算断言表达式并将中间结果保存到临时变量 ... self.statements.append(ast.fix_missing_locations(...))它会将assert expr改写成类似这样的逻辑__assertion_expr expr if not __assertion_expr: __assert_fail(...)为了收集中间值AssertionRewriter会递归遍历断言表达式把每个子表达式赋值给一个新的临时变量并生成对应的说明文字。这就是你在失败信息里能看到where 1 func_a()的原因因为原表达式里func_a()被单独提取成了一个中间变量。4.2 字节码层面到底发生了什么很多人以为断言重写只是“改一改源字符串”实际上不是它是在更底层的字节码编译阶段做手脚。pytest 的导入钩子PytestAssertRewriteHook挂载在sys.meta_path上当 Python 导入一个模块时会先经过这个钩子。钩子判断模块路径是否匹配需要重写的规则默认测试文件都会重写如果匹配就读取源码文件用ast.parse得到 AST然后由AssertionRewriter遍历并改写 AST最后用compile()编译成 code object。相比源字符串替换AST 改写有两个突出优势一是能把断言表达式和中间收集逻辑精确映射到代码块不易出错二是可以利用ast的位置信息生成更精确的失败定位。这里有个非常有意思的技术细节为了减小性能损耗pytest 只会重写测试模块不会重写标准库和第三方库。判断标准在_pytest/assertion/rewrite.py里写得很清楚但如果在配置里设置了--assertplain那就会完全禁用重写断言就退回 Python 原生行为。--assertreinterp是旧版的重新解释模式性能较差现在基本没人用。4.3 自写断言辅助函数时需要注意的“改写陷阱”断言重写不是万能的它有几个明显的禁忌。第一个就是在conftest.py里定义断言辅助函数时如果这个函数写的是def assert_foo(obj): assert obj.foo 1 assert obj.bar 2这些assert也是会被重写的但要确保这个模块本身被 pytest 判定为“需要重写”。pytest 对conftest.py的断言重写默认是开启的所以一般没问题。但有一个真实的坑是如果你在非测试目录下定义了一个工具模块它内部用了很多assert做参数校验然后在测试代码里调用它如果断言失败pytest 并不会对这个模块做重写因为它不属于测试模块所以失败信息依然很稀薄。想要它也有详细输出需要在pytest.ini或pyproject.toml里配置[tool:pytest] python_files test_*.py assert rewritepython_files决定哪些文件在导入时会被重写。如果你希望某个公共工具模块也进入重写名单可以直接在文件顶部加import pytest pytest.register_assert_rewrite(my_utils.assert_helpers)调用这个函数必须在模块导入之前完成通常放在conftest.py顶部。这个方法我实际用过适合那些深度依赖assert的工具模块。另外还要注意AST 改写也不是完全没有成本的。它的性能开销主要在导入阶段因为要额外解析和编译一份代码。对于测试文件数量很大的项目这个时间会客观存在。好在 pytest 会缓存编译产物到__pycache__源码没变时不会重复解析所以大多数情况下可以接受。5. 钩子机制与插件系统一切皆可插拔的灵魂设计5.1 pluggy 的 Hookspec 与 Hookimpl 约定pytest 的插件系统并不是自己实现的而是依赖一个独立的库叫pluggy。pluggy非常轻量但设计极精妙。它的核心思想是定义好“钩子规范”Hookspec然后让多个“插件实现”Hookimpl注册到同一个钩子上框架在特定时机调用这个钩子让所有实现按顺序依次执行。在 pytest 源码里你会看到大量这样的用法# _pytest/hookspec.py hookspec(firstresultTrue) def pytest_collection_modifyitems(session, config, items): ...# 某个插件里 pytest.hookimpl(tryfirstTrue) def pytest_collection_modifyitems(session, config, items): ...hookspec定义了这个钩子能传入什么参数、返回值怎么处理hookimpl是具体实现。多个实现之间的执行顺序由tryfirst、trylast、hookwrapper等参数控制。firstresultTrue表示只要有一个实现返回了非None结果就不继续调用后面的实现了。pytest_cmdline_main就是这样它需要唯一的结果。5.2 Hook 的执行顺序与 wrapper 的前后环绕pluggy的调用顺序不是简单的注册顺序而是经过排序的。排序参数按“权重”划分tryfirstTrue尽量往前排trylastTrue尽量往后排默认排在中间hookwrapperTrue作为包装器执行可以理解为“包裹住所有其他实现”hookwrapper是最难理解也最强大的概念。一个hookwrapper实现写的不是普通函数而是生成器函数pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() # 拿到 report 后可以修改 reportyield之前的代码在所有普通实现之前执行yield之后的代码在所有普通实现执行完后执行。这样就能实现对钩子结果的“前后夹击”前置逻辑可以做初始化后置逻辑可以修改结果。这个模式在 pytest 插件里极其常用比如pytest-html、pytest-sugar都是靠它拿到测试报告再做二次加工的。搞清楚这个之后你才算真正解锁了 pytest 插件的玩法不只是注册一个函数而是可以让插件“监听”某个阶段、“包裹”某个阶段、“修改”某个阶段的结果。5.3 如何自己动手写一个最小可用的插件理解了底层逻辑之后写一个最小插件其实很简单。一个插件本质上就是包含若干hookimpl的模块然后被注册到 pytest 的插件系统里。最简单的注册方式是放到conftest.py里# conftest.py import pytest pytest.hookimpl(tryfirstTrue) def pytest_collection_modifyitems(config, items): for item in items: item.add_marker(pytest.mark.slow)这段代码会在收集完成后执行给所有用例打上一个slow标记。你可以把它当做一个最简单的插件示例。真正的独立插件则需要一个入口点entry point比如在pyproject.toml里注册[project.entry-points.pytest11] myplugin myplugin这样 pytest 启动时会自动加载myplugin模块。pytest11是 pytest 插件的标准入口点组名后面跟的名字是插件名值是对应的模块位置。写插件的核心挑战其实是理解“在哪个钩子点做最合适”这需要你对 pytest 的运行阶段有全局认识。我的经验是先花时间看懂_pytest/hookspec.py里所有钩子的注释再根据需求挑对应的钩子而不是拿着一堆钩子瞎试。6. 用例执行协议从 runtest 到测试报告的完整链路6.1 pytest_runtest_protocol 与 setup/call/teardown 三段式收集完成后pytest 进入执行阶段。执行的最小单位是Item也就是单个测试用例。pytest 定义了一个标准的“运行协议”核心钩子是def pytest_runtest_protocol(item, nextitem): ...这个钩子的职责是给定一个测试项和下一个测试项安排它的完整执行流程。默认实现中会调用pytest_runtest_setup(item)执行 setuppytest_runtest_call(item)执行测试函数本身pytest_runtest_teardown(item, nextitem)执行 teardown也就是说一个用例的完整生命周期就是这三段。你在用例里看到的setup_module、setup_function、fixture的 setup/teardown 逻辑最终都会汇聚到这个三层协议里。nextitem参数很重要它表示“下一个要执行的用例”。pytest 用它在 teardown 时判断作用域如果一个 session 级 fixture 在下一条用例还要用就暂时不销毁如果在当前用例之后不再需要就立刻销毁。这是_pytest/runner.py里非常关键的一段逻辑也是 fixture 能跨用例存活的核心原因。6.2 CallInfo 与 TestReport 的数据结构执行阶段会产生大量的运行信息。CallInfo是 pytest 里用来记录“一次调用结果”的数据结构它包含when调用时机setup/call/teardown、result调用结果、exc异常信息等。每完成一个阶段pytest 就会生成一个TestReport最终汇报给用户。TestReport的定义大致是class TestReport: def __init__(self, nodeid, location, keywords, outcome, longrepr, when, ...): ...其中outcomepassed、failed、skipped之一longrepr失败时的详细内容内容类型可能是字符串、异常信息或 tracebackwhen标记这个 report 属于 setup、call 还是 teardownTestReport不仅用于终端展示也是生成各种报告HTML、JUnit XML的基础数据源。pytest_runtest_makereport这个钩子就是负责在三个不同阶段分别生成TestReport你可以在hookwrapper里捕获三次报告然后做汇总判断很多插件都是这么判断“这个用例是否最终失败”的。6.3 skipped、xfail、参数化执行时的状态流转状态流转是执行协议里最容易出 bug 的部分。比如一个用例在 setup 阶段就失败了还会不会执行 teardown答案是一般会执行 teardown但不会执行 call。这个“哪些阶段跑、哪些阶段跳过”由pytest_runtest_makereport根据异常类型和之前的报告结果来决策。skipped通常发生在 fixture 或 setup 中抛了pytest.skip.Exception。xfail分两种情况一是声明了pytest.mark.xfail且实际失败状态是xfailed预期失败但没有真正失败时记xpassed二是在测试内部调用pytest.xfail()主动标记。这些状态最终都会反映到报告里但各自的计数和展示逻辑不同。参数化对执行协议的影响值得单独提一句一个参数化的用例在收集阶段会被展开成多个Function实例所以执行阶段对 pytest 来说就是“按顺序执行 N 个独立用例”参数化展开发生在收集阶段跟执行阶段没有耦合。这也是为什么你在参数化用例里用fixture时每次参数值都会重新走一遍 fixture 的获取逻辑因为它们是不同的Item。6.4 从运行协议看--pdb和--lf的实现位置理解运行协议后你就能看懂很多常用选项的底层实现。--pdb失败进入调试器是在pytest_runtest_makereport阶段处理的当 report 的failed为真时pytest 检查是否有--pdb选项如果启用就调用pdb.post_mortem进入调试。--lf只跑上次失败的用例是在收集阶段之后、执行之前实现的。pytest_collection_modifyitems钩子里pytest 会根据上次运行的报告缓存cacheprovider插件维护的.pytest_cache目录判断哪些用例上次失败然后把失败用例重新排到前面或过滤出来。这也是为什么--lf能立刻生效它不需要去分析历史日志只需读取.pytest_cache/v/cache/lastfailed这样一个简单文件。这个文件的内容就是上次失败用例的nodeid集合。明白了这一点你甚至可以手动修改这个文件来“伪造”历史失败数据从而精确控制下一次--lf的运行范围。7. 二次开发与调试基于源码层面的排错经验7.1 调试 pytest 源码的实用方法读源码不光是为了“读懂”更重要的是能在实际遇到问题时快速定位。我的调试方法一般有三种。第一种直接打印 hook 调用链。在conftest.py里加一个 hookwrapperpytest.hookimpl(hookwrapperTrue) def pytest_runtest_protocol(item, nextitem): print(fRUN {item.nodeid}) yield这样你能看到用例执行的真实顺序对照节点树是否符合预期。这个方法比看终端输出直观得多特别是排查 fixture 的 setup/teardown 顺序。第二种利用pytest --trace-config。这个选项会打印 pytest 启动时的配置加载过程包括从哪里读取的配置、加载了哪些插件。排查“我的配置怎么没生效”时非常有用。第三种是用 Python 的breakpoint()结合--pdb。你可以在自己写的插件代码里埋breakpoint()pytest 执行到那行时会自动打开pdb然后你就可以单步跟踪源码了。不过要注意--pdb通常指的是测试用例失败时进入pdb跟在自己代码里加breakpoint()是两回事自己加的不需要--pdb。7.2 常见源码级问题的排查思路我整理了几个网上问得非常多的、且确实需要源码知识才能解决的典型问题问题 1为什么 confest.py 里的 fixture 用不到大概率原因是conftest.py的作用域只覆盖它所在目录及子目录。这是_pytest/python.py里Package收集器在导入conftest.py时遵循“目录层级最近”原则导致的。解决方案是检查用例文件的目录层级确定conftest.py与用例文件的相对位置。问题 2为什么 session 级 fixture 在同一个测试函数里被调了两次这通常不是 fixture 本身的问题而是你用了request.getfixturevalue()动态获取了几次但缓存没生效。原因可能是你把 scope 写成了function或没有写 scope 默认就是 function。如果是scopesession还出现多次创建检查是否在子进程中运行如pytest-xdist每个 worker 都有自己的 fixture 缓存空间。问题 3为什么删除某个临时文件失败报错发生在 teardown 阶段这是因为 teardown 阶段会执行finalizer如果你的 fixture 里的 teardown 代码抛了异常pytest 会把这个异常记录为一个 teardown 错误并通过pytest_runtest_makereport状态合并到当前用例的结果里。此时测试函数本身可能通过了但报告里依然会显示失败。排查时看whenteardown的TestReport就能定位。7.3 性能优化视角下的源码阅读价值最后说一个很多人没意识到的点读懂源码对性能优化帮助巨大。pytest 在大型项目上的执行速度往往被诟病但其实大部分性能瓶颈都可以通过源码找到依据。比如--collect-only只是完成了收集阶段没有执行阶段所以速度很快。如果一个项目--collect-only都很慢问题就出在收集阶段的模块导入。而收集阶段会导入每个测试文件如果这些测试文件顶层做了大量耗时操作比如创建数据库连接、加载大模型那收集速度自然慢。再比如 fixture 的作用域选择。把scopesession的 fixture 用在每个测试函数里并不意味着所有测试函数都共享同一个实例因为缓存是在Session节点上但如果你的测试是分布式执行每个 worker 依然会创建一份。这也就是pytest-xdist存在的必要性同时你在写 session 级 fixture 时要清楚它只能保证“单进程内共享”不是“跨进程全局共享”。从源码角度看性能优化思路就会清晰很多收集慢就优化导入执行慢就分析夹具缓存报告慢就看终端插件是否做了过多格式化。这些都能从源码中找到对应实现。8. 写在最后的几个技巧根据我长期使用和阅读 pytest 源码的经验有几个小技巧值得分享。第一个技巧善用pytest_fixture_setup和pytest_fixture_post_finalizer这两个钩子做 fixture 的全局监控。如果你想知道项目里哪些 fixture 是性能瓶颈可以在这两个钩子里记录时间差。第二个技巧直接改PYTEST_DISABLE_PLUGIN_AUTOLOAD1环境变量可以禁用所有第三方插件自动加载。当怀疑某个插件导致测试行为异常时用这个变量做对照实验能在不卸载插件的情况下快速定位问题。第三个技巧通过nodeid的精确筛选可以规避参数化用例之间的相互干扰。例如只跑某个参数化分支直接用pytest test_demo.py::test_add[12-3]带引号是因为命令行里[、]会被 shell 解释加引号能避免匹配错误。读源码的最终意义不在于“背出每一行的实现”而在于建立一种“对框架行为有预判”的直觉。我见过太多测试工程师在遇到奇怪问题时第一反应是“pytest 有 bug”或者“一定是我的运气不好”但大多时候回到源码里排查一遍都能找出真实原因。养成这种习惯之后你再写 pytest 用 pytest心态会完全不同。你不再是“调用这个框架”而是“和这个框架协同工作”这种感受是我最想通过这篇源码解析传达给读者的。
返回列表