pytest 历史演进笔记解读:从 Marker 重构、字符串条件到配置与缓存机制的兼容性指南
【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest
本篇文章以 pytest 官方仓库的 doc/en/historical-notes.rst 为骨架,系统梳理 pytest 各版本演进过程中被替代或调整的旧特性:包括 3.6 版 Marker 机制的彻底重构、缓存插件并入核心、pytest_funcarg__前缀与yield_fixture的退场、[pytest]配置节名的变更、parametrize旧式标记语法、字符串 skipif/xfail 条件的求值原理等。读者读完本文将掌握:如何将旧式 Marker 访问代码迁移到iter_markers/get_closest_marker新 API、如何理解字符串条件求值时的命名空间构造、以及如何识别并规避已被弃用的历史 API。
本文定位与阅读前提
historical-notes.rst是 pytest 官方为"查看旧代码的用户"保留的历史档案,记录了曾经存在、如今已变化的行为。它不等同于推荐用法,而是兼容性地图:当你面对遗留代码库、老教程或第三方插件中出现的get_marker、yield_fixture、pytest.set_trace()等写法时,本文能帮你准确判断它们的语义、弃用状态与迁移方向。
写作时以当前仓库(包含 src/_pytest 源码与 testing 测试)为事实依据,所有结论均可回溯到对应源码文件验证。
一、Marker 机制重构(pytest 3.6 起):从MarkerInfo到iter_markers
1.1 旧设计的三大缺陷
versionchanged:: 3.6是本文档的第一处关键变更标记。3.6 之前,pytest 的 Marker 实现方式是直接往函数的__dict__里累加写入属性,由此衍生出一系列设计缺陷:
- 跨类层级意外传播:因为标记写进了函数对象的
__dict__,标记会以难以预期的方式沿类继承层级传递,出现"兄弟类互相染色"的诡异现象。 - 获取 API 不统一:三种来源的标记存储形态各异——
- 来自
@pytest.mark装饰器的标记是MarkerInfo(内部对象,可能还混入兄弟类的标记); - 来自参数化(parametrize)的标记是
MarkDecorators; - 通过
node.add_marker添加的标记会覆盖丢弃之前的标记; - 而
MarkerInfo表面上像一个单独的 mark,实际却是多个同名 mark 的合并视图。
- 来自
- 模块/类/函数访问不一致:标记即使声明在类或模块上,也只能在函数上被访问到。
这些问题的组合让高级用户几乎不可能在不深挖内部实现的前提下正确使用 Marker 数据,进而引发各种隐蔽 bug。重构前这些问题在 issue 系统中积累了大量的真实案例(详见下文"相关问题清单")。
1.2 新 API:iter_markers与get_closest_marker
pytest 3.6 起引入统一的新 API 并重写了内部实现,核心是 src/_pytest/nodes.py 中Node基类上的两个方法:
def iter_markers(self, name: str | None = None) -> Iterator[Mark]: """Iterate over all markers of the node.""" def get_closest_marker( self, name: str, default: Mark | MarkDecorator | None = None ) -> Mark | None: """Return the first marker matching the name, from closest (for example function) to farther level (for example module level)."""从源码可以看到二者的实现基础:
iter_markers委托给iter_markers_with_node,后者沿iter_parents()(从节点自身向上直到收集树根,8.1 版本新增该方法)遍历每个父节点,并逐一遍历node.own_markers,实现"收集整条继承链上的所有同名标记";get_closest_marker则调用next(self.iter_markers(name=name), default)——即取最近层级(函数优先于类,类优先于模块)上第一个命中的标记;若找不到,返回default(既可以是普通值,也可以是MarkDecorator,源码中会自动解包成其内部Mark)。
1.3 旧代码迁移指南(两种场景)
旧 APINode.get_marker(name)之所以被弃用,是因为它返回内部的MarkerInfo合并对象(包含所有同名标记合并后的 name、*args和**kwargs),语义暧昧且与真实存储不一致。文档给出了按语义分场景的两步迁移法:
场景一:标记互相覆盖("以最近者为准")——比如模块级log_level('info')被某条测试函数级log_level('debug')覆盖,你只关心"当前生效的那一个":
# 替换前: marker = item.get_marker("log_level") if marker: level = marker.args[0] # 替换后: marker = item.get_closest_marker("log_level") if marker: level = marker.args[0]场景二:标记叠加组合("全部都要")——比如skipif(condition),多个条件应全部参与求值,顺序无关,应当把它们当作一个集合:
# 替换前: skipif = item.get_marker("skipif") if skipif: for condition in skipif.args: # eval condition ... # 替换后: for skipif in item.iter_markers("skipif"): condition = skipif.args[0] # eval condition这一迁移不仅是 API 换名,更是语义从"合并视图"到"原始对象"的纠正。在当前的 src/_pytest/skipping.py 中,evaluate_condition正是通过item.iter_markers("skipif")逐个取条件并求值,可以视为新 API 在核心功能中的直接使用范例。
1.4 参数化标记与add_marker的现代形态
文档提到旧版参数化产生的标记是MarkDecorator,且node.add_marker会覆盖丢弃先前标记。当前源码中两者都已被理顺:
- 参数化标记:现代写法使用
pytest.param(..., marks=...),见 src/_pytest/mark/init.py 的param()工厂函数与 src/_pytest/mark/structures.py 的ParameterSet。每个参数集可携带MarkDecorator | Collection[MarkDecorator | Mark],从而与装饰器标记统一为同一种存储形态(ParameterSet.marks),不再产生"旧版MarkDecorator与MarkerInfo两套体系并存"的问题。 node.add_marker:在 src/_pytest/nodes.py 中,接受字符串(会经MARK_GEN解析为对应 mark)或MarkDecorator,并通过append参数控制是追加到own_markers末尾还是插入头部,不再存在"丢弃先前标记"的副作用。
1.5 新实现修复的相关 issue 清单(非穷举)
文档列出重构所修复的历史问题,可作为阅读旧 issue 与理解动机的索引:
- Marks don't pick up nested classes(#199)
- Markers stain on all related classes(#568)
- Combining marks - args and kwargs calculation(#2897)
request.node.get_marker('name')对类上应用的标记返回None(#902)- 参数化中应用的标记被存储为 markdecorator(#2400)
- 向后不兼容地修复 marker 交互(#1670)
- 重构 marks 以摆脱 "marks transfer" 机制(#2363)
- 引入 FunctionDefinition 节点并在 generate_tests 中使用(#2522)
- 移除命名 marker 属性并在 items 中收集 markers(#891)
- 参数化产生的 skipif 标记隐藏模块级 skipif(#1540)
- skipif + parametrize 不跳过测试(#1296)
- marker 传递与继承不兼容(#535)
更多细节可查看原 PR(文档中标注为 #3317)。
注意:文档同时预告,未来某个 pytest 主版本将引入基于类的 markers(class based markers),届时 markers 将不再局限于
pytest.Mark实例。
二、缓存插件并入核心:pytest-cache→ 内置 cache
文档说明:核心缓存插件的功能此前以第三方插件pytest-cache分发,并入核心后命令行选项与 API 用法保持兼容,唯一的硬性限制是——只能在测试运行之间存取 JSON 可序列化的数据。
当前实现位于 src/_pytest/cacheprovider.py。源码印证了文档的两个关键点:
- JSON 序列化约束:
get方法通过json.load(f)读取(src/_pytest/cacheprovider.py),set方法通过json.dumps(value, ensure_ascii=False, indent=2)写入(src/_pytest/cacheprovider.py)。因此写入自定义对象前需要自行转换为 JSON 兼容结构。 - 命令行选项(
pytest_addoption中注册):--lf/--last-failed:只重跑上次失败的测试;--ff/--failed-first:全部运行但失败者优先;--nf/--new-first:新文件优先;--cache-show:显示缓存内容(可选 glob 参数,默认'*'),不执行收集与测试;--cache-clear:测试运行开始时清除全部缓存;- 另有 ini 项
cache_dir,默认.pytest_cache,若环境变量TOX_ENV_DIR存在则默认改为$TOX_ENV_DIR/.pytest_cache。
在测试中通过request.config.cache(或cachefixture)访问缓存;文档要求 key 使用/分隔的字符串,且首段通常为插件名以避免冲突——这一约定在 src/_pytest/cacheprovider.py 的 cache fixture 文档字符串中被再次确认。
三、fixture 演进史:pytest_funcarg__、yield_fixture与 autouse
3.1 2.3 之前的魔法前缀pytest_funcarg__
在 2.3 版本之前,没有@pytest.fixture装饰器,声明 fixture 工厂函数必须使用魔法前缀pytest_funcarg__NAME。文档明确表示:这一旧语法至今仍受支持,但已不再是声明 fixture 的主要推荐方式。也就是说,遇到遗留代码中的:
def pytest_funcarg__tmpdir(request): return ...可以放心保留运行,但新代码应迁移到@pytest.fixture。
3.2 2.10 起yield_fixture不再必要
2.10 之前,要用yield编写 teardown 代码,必须给 fixture 打上yield_fixture标记;2.10 之后普通 fixture 可直接yield,该装饰器被弃用。当前仓库中 src/_pytest/fixtures.py 依然保留着yield_fixture的兼容实现,并带有明确的弃用提示:
@deprecated( "@pytest.yield_fixture is deprecated. Use @pytest.fixture instead; they are the same.", category=None, # We have our own runtime warning logic ) def yield_fixture(...):同时 src/_pytest/fixtures.py 中_teardown_yield_fixture的实现表明:现代 fixture 的 yield 化 teardown 已成为一等公民,yield之后的部分通过request.addfinalizer注册执行。
3.3pytest.setup演变为 autouse fixture
开发期曾短暂使用pytest.setup名称,但在 2.3 发布前被重命名并融入通用 fixture 机制,即autouse fixtures(自动应用的 fixture,无需显式请求)。这是 pytest 中"隐式 setup"概念的最终形态。
四、配置节名迁移:[pytest]→[tool:pytest]
3.0 之前,setup.cfg中支持的节名是[pytest]。由于该名称可能与某些 distutils 命令冲突,推荐节名改为[tool:pytest]。注意区分:
setup.cfg使用[tool:pytest];pytest.ini与tox.ini中节名仍然是[pytest]。
这一点在 doc/en/example/customdirectory/pytest.ini 等示例配置中可以得到印证。
五、parametrize的历史写法两则
5.1 给参数值打标记:旧式内联语法(3.1 之前)
3.1 之前,对参数值应用标记的机制是直接把pytest.mark.xfail(...)包在参数元组外层:
import pytest @pytest.mark.parametrize( "test_input,expected", [("3+5", 8), ("2+4", 6), pytest.mark.xfail(("6*9", 42))] ) def test_eval(test_input, expected): assert eval(test_input) == expected文档明确指出这只是一次"初始 hack":它无法传入函数,也无法对同名不同参数的多个标记正确应用,因此计划在 pytest 4.0 移除。现代等价写法是 src/_pytest/mark/init.py 的pytest.param:
@pytest.mark.parametrize( "test_input,expected", [ ("3+5", 8), pytest.param("6*9", 42, marks=pytest.mark.xfail), ], ) def test_eval(test_input, expected): assert eval(test_input) == expectedpytest.param还支持id参数(8.4 起可用pytest.HIDDEN_PARAM隐藏该参数集在测试名中的显示),见 src/_pytest/mark/structures.py。
5.2 参数名元组写法(2.4 之前)
2.4 之前,argnames必须写成元组:
@pytest.mark.parametrize(("a", "b"), [(1, 2), (3, 4)])该写法至今有效,但逗号分隔字符串"a,b"更简洁、行噪音更少,文档推荐优先使用字符串形式。
六、skipif/xfail 条件:字符串求值的历史与机制
6.1 字符串条件及其求值命名空间
2.4 之前,skipif/xfail 条件只能用字符串书写:
import sys @pytest.mark.skipif("sys.version_info >= (3,3)") def test_function(): ...求值发生在测试函数 setup 阶段,等价于eval('sys.version_info >= (3,0)', namespace)。命名空间按如下规则构造:
- 初始放入
sys、os模块和 pytest 的config对象; - 再用该测试函数的模块 globals更新。
当前源码在 src/_pytest/skipping.py 的evaluate_condition中完全对应了这一描述:字符串条件会被编译后eval,globals_字典中注入os、sys、platform与item.obj.__globals__,语法错误会得到精心格式化的报错信息。
由于config对象在命名空间中可用,旧代码可以这样按配置项跳过:
@pytest.mark.skipif("not config.getvalue('db')") def test_function(): ...6.2 为何推荐布尔条件 + reason
2.4 起官方推荐布尔条件,理由是标记可以自由地在测试模块间导入:字符串条件要求导入的不只是标记本身,还包括条件里用到的全部变量,破坏了封装性。布尔条件的现代写法:
@pytest.fixture(autouse=True) def skip_if_no_db(request): if not request.config.getoption("--db", default=False): pytest.skip("--db was not specified") def test_function(): pass注意:文档末尾附带了重要更正——pytest.config全局对象已在 pytest 5.0 移除,应改用request.config(通过requestfixture)或pytestconfigfixture。字符串条件则"将保持完全支持",若无需跨模块导入标记,可以继续使用。
七、调试 API:pytest.set_trace()的退场
2.4 之前,设置断点需要使用pytest.set_trace():
import pytest def test_function(): ... pytest.set_trace() # invoke PDB debugger and tracing如今不再需要,直接使用原生import pdb; pdb.set_trace()即可。pytest 对pdb.set_trace()有内建的捕捉与交互增强(详见文档中指引的 breakpoints 章节),这也是"移除多余包装、回归 Python 原生能力"的一个典型演进。
八、"compat" 兼容属性:从Node上访问类对象的弃用
通过Node实例访问Module、Function、Class、Instance、File、Item等对象,早已被文档标记为弃用,并从pytest 3.9 起开始发出警告。正确的做法是直接import pytest,然后通过pytest模块访问这些对象(如pytest.Function、pytest.Class)。这也是所有历史 API 的通用迁移方向:以顶层pytest命名空间为稳定出口。
九、迁移检查清单
基于全文,给出一份可直接对照执行的检查清单:
| 旧写法 | 状态 | 迁移目标 |
|---|---|---|
node.get_marker(name) | 已弃用 | node.get_closest_marker(name)(覆盖语义)或node.iter_markers(name)(叠加语义) |
pytest.mark.parametrize(...)内联pytest.mark.xfail(...)包裹参数 | 已计划移除(4.0) | pytest.param(..., marks=...) |
pytest_funcarg__NAME前缀 | 仍支持,不推荐 | @pytest.fixture |
@pytest.yield_fixture | 已弃用 | @pytest.fixture+ 直接yield |
pytest.setup | 已移除(2.3 前改名) | autouse fixture |
setup.cfg中[pytest]节 | 不推荐 | [tool:pytest](pytest.ini/tox.ini仍为[pytest]) |
pytest.set_trace() | 已不需要 | import pdb; pdb.set_trace() |
通过Node访问Module/Function/Class/... | 自 3.9 起告警 | import pytest后从pytest模块访问 |
| 字符串 skipif/xfail 条件 | 仍完全支持 | 布尔条件 +reason(按需迁移) |
pytest.config全局对象 | 5.0 移除 | request.config/pytestconfigfixture |
十、延伸阅读
- Marker 新 API 的完整实现:src/_pytest/nodes.py(
add_marker/iter_markers/get_closest_marker) - Marker 数据结构(
Mark、MarkDecorator、ParameterSet):src/_pytest/mark/structures.py - 缓存插件实现与选项注册:src/_pytest/cacheprovider.py
- 字符串条件求值逻辑:src/_pytest/skipping.py
- 现代 autouse fixture 与
pytest.param的官方说明:doc/en/explanation/fixtures.rst、doc/en/example/parametrize.rst - 缓存用法:doc/en/how-to/cache.rst
历史笔记的价值不在于"过时",而在于它精确记录了每个现代 API 之所以长成今天模样的原因。读懂这些变更,你不仅能在遗留代码面前游刃有余,也能更深刻地理解 pytest 的设计取舍。
【免费下载链接】pytestThe pytest framework makes it easy to write small tests, yet scales to support complex functional testing项目地址: https://gitcode.com/GitHub_Trending/py/pytest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考