pytest 9.1 破坏性变更解析Metafunc.parametrize的indirect/ids/scope参数改为 keyword-only【免费下载链接】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 9.1 引入的一项破坏性变更展开Metafunc.parametrize()——以及基于它实现的pytest.mark.parametrize装饰器——的indirect、ids、scope三个参数改为仅限关键字传入keyword-only。文章将结合 python.py 的源码实现与 metafunc.py 中的回归测试说明这次变更的动机、错误场景的前后对比、正确的迁移写法以及如何利用类型标注提前规避此类误用。读完本文你将理解为什么一个看似简单的 API 调整能显著改善参数化测试的可调试性并掌握 9.1 下参数化调用点的全部正确姿势。一、变更概要一句话读懂 breaking change在 pytest 9.1 中Metafunc.parametrize()的方法签名从部分参数支持位置传入收紧为argnames、argvalues仍然可以按位置传入前两个位置参数indirect、ids、scope三个参数必须使用关键字形式传入否则直接抛出TypeError。由于pytest.mark.parametrize装饰器的底层实现就是把标记的参数原样转发给metafunc.parametrize()见下文源码分析因此该变更同时作用于装饰器用法。这是本 changelog 条目 8593.breaking.rst 的核心内容。二、变更动机位置参数是参数化误用的重灾区2.1 经典翻车现场changelog 中给出了一个极具代表性的错误示例parametrize(arg1, arg2, [(1, 2)])作者的意图显然是声明两个参数名arg1、arg2并提供一个参数值列表[(1, 2)]。然而在旧版本中第三个位置参数会被静默地解释为indirect参数于是[(1, 2)]被当成需要 indirect 化的 fixture 名称列表来处理最终产生一条令人困惑的错误indirect fixture (1, 2) doesnt exist这类错误的杀伤力在于错误信息与真实问题脱节用户写错了参数位置得到的却是关于fixture 不存在的提示排查方向被严重带偏错误在运行期才暴露集合collection阶段之前没有任何静态提示indirect恰好接收任意可迭代对象布尔值或参数名序列这让列表被当作 indirect的误用很难被提前拦截。2.2 变更后的行为同一段错误代码在 9.1 下会立刻得到一条语义清晰的内置异常TypeError: parametrize() takes 2 positional arguments but 3 were givenTypeError直指参数数量/位置不匹配这一根因用户无需再去猜测哪个 fixture 不存在。changelog 原文对此的总结是这类错误现在以可理解的TypeError失败。2.3 声明与运行时行为终于对齐changelog 还特别指出类型标注从一开始就把这些参数声明为 keyword-onlyThe type annotations have always declared these arguments as keyword-only。也就是说此前文档与类型层面的契约早已要求使用关键字传参只是运行时实现一直宽松地接受了位置参数本次变更是让运行时行为与既有声明收敛一致——对依赖 mypy / pyright / Pylance 等类型检查器的团队来说这一直是官方建议的用法升级后代码行为与静态检查结论不再存在偏差。三、源码级实现keyword-only 是如何落地的3.1 方法签名中的*Metafunc.parametrize的定义位于 python.pydef parametrize( self, argnames: str | Sequence[str], argvalues: Iterable[ParameterSet | Sequence[object] | object], *, indirect: bool | Sequence[str] False, ids: Iterable[object | None] | Callable[[Any], object | None] | None None, scope: ScopeName | None None, _param_mark: Mark | None None, ) - None:注意签名中argvalues之后的裸*分隔符它是 Python 语法层面强制其后所有参数仅限关键字传入的机制。indirect、ids、scope以及内部使用的_param_mark全部位于*之后因此任何额外的位置参数都会在进入函数体之前被解释器拒绝。对应 docstring 中也明确记录了变更版本python.py.. versionchanged:: 9.1 indirect, ids and scope are now keyword-only.3.2 装饰器如何联动pytest_generate_tests转发机制pytest.mark.parametrize本身不直接调用Metafunc.parametrize真正的工作发生在 pytest 的pytest_generate_tests钩子实现中python.pydef pytest_generate_tests(metafunc: Metafunc) - None: for marker in metafunc.definition.iter_markers(nameparametrize): metafunc.parametrize(*marker.args, **marker.kwargs, _param_markmarker)iter_markers(nameparametrize)收集测试函数上的所有pytest.mark.parametrize标记然后把标记的args/kwargs原样展开转发给metafunc.parametrize()。这意味着pytest.mark.parametrize(a, [1], False)中的第三个位置参数False会被展开为parametrize(a, [1], False)在 9.1 下立即触发TypeError这正是回归测试 metafunc.py 所验证的行为反之只要装饰器使用indirect、ids、scope关键字写法转发后即天然满足新签名要求无需任何额外处理。3.3 参数语义回顾这三个参数分别控制什么为了让为什么必须显式写出参数名更有感知这里依据 python.py 的 docstring 梳理三者的职责参数类型默认值作用indirectbool或argnames的子集序列False为True时argnames中的所有参数值都通过对应 fixture 的request.param注入传参数名列表时可只对部分参数生效。典型用途是把昂贵资源初始化推迟到 fixture setup 阶段而不是 collection 阶段ids序列 / 生成器 / 可调用对象 /NoneNone自定义每个参数集的测试 ID。序列按argvalues下标一一映射None表示使用自动生成的 ID传入 callable 时它对每个参数值调用一次返回值拼入整体 ID不传则根据argvalues自动生成scope作用域字符串如function、class、module、package、session或NoneNone参数实例的分组作用域同时会覆盖fixture 函数上定义的作用域可用于基于测试上下文或配置动态设定作用域3.4_param_mark与作用域推断的细节签名中还有第四个 keyword-only 参数_param_mark以下划线开头的内部参数由pytest_generate_tests注入当前Mark对象用于参数 ID 生成与错误信息定位普通用户不应直接传入。此外当scopeNone时Metafunc.parametrize会调用_infer_parametrize_scope(argnames, self._arg2fixturedefs, indirect)见 python.py基于参数名与 fixture 定义自动推断作用域——这也解释了为什么显式传入的scope会拥有更高优先级。四、正确写法装饰器与编程式调用4.1 装饰器用法最常见import pytest pytest.mark.parametrize( x, y, [(a, b), (c, d)], indirect[x], # 仅 x 通过 fixture 注入 ids[case-1, case-2], # 自定义测试 ID scopemodule, # 参数实例按模块分组 ) def test_pair(x, y): assert isinstance(x, str) assert isinstance(y, str)当indirect[x]时对应的 fixture 需要接收request.parampytest.fixture(scopefunction) def x(request): return request.param * 3这种参数名参数值三个关键字参数的结构是 9.1 之后唯一合法的装饰器形态。若沿用旧习惯写成pytest.mark.parametrize(x, y, [...], indirect, ...)将直接得到TypeError。4.2 编程式调用pytest_generate_tests钩子在自定义集合逻辑中直接调用Metafunc.parametrize时同理def pytest_generate_tests(metafunc): if env in metafunc.fixturenames: metafunc.parametrize(env, [dev, prod], ids[dev, prod], scopesession)钩子实现位于模块/类级的pytest_generate_tests中pytest 在 collection 阶段调用它并传入Metafunc实例相关调用链见 python.py 中_genfunctions对pytest_generate_tests.call_extra的触发。这些调用点同样必须在 9.1 下遵循 keyword-only 约束。五、回归测试错误行为被钉死为 TypeError仓库中的测试用例完整覆盖了本次变更的两个入口可作为行为契约的权威参照。5.1 装饰器入口多余位置参数触发TypeErrormetafunc.py 中的test_parametrize_positional_argspytester.makepyfile( import pytest pytest.mark.parametrize(a, [1], False) def test_foo(a): pass ) result pytester.runpytest() result.stdout.fnmatch_lines([*TypeError*positional argument*]) result.assert_outcomes(errors1)它验证了pytest.mark.parametrize(a, [1], False)中第三位置参数False不再被静默吞入indirect而是产生包含positional argument字样的TypeError测试项以错误errors而非通过/失败收场。5.2 编程式入口Metafunc.parametrize同样拦截metafunc.py 中的test_parametrize_positional_indirect_error直接针对Metafunc实例def func(x, y): raise NotImplementedError() metafunc self.Metafunc(func) with pytest.raises(TypeError, matchpositional arguments): metafunc.parametrize(x, y, [(a, b)], [x]) # type: ignore[call-arg]测试注释明确点题indirectand later arguments are keyword-only, so that extra positional arguments (e.g. several argname strings, #8593) fail with an understandable TypeError rather than being silently interpreted.—— 即本变更正是为了修掉 #8593 所描述的多传的位置参数被静默解释问题且[x]这类的合法indirect取值现在也必须写成indirect[x]才能生效。六、迁移指南升级到 pytest 9.1 的检查清单6.1 扫描所有参数化调用点重点排查以下两类写法装饰器三参数及以上的位置写法最常见# 旧写法9.1 起报 TypeError pytest.mark.parametrize(x, y, [(a, b)], [x]) pytest.mark.parametrize(x, y, [(a, b)], [x], [1, 2])统一改为pytest.mark.parametrize(x, y, [(a, b)], indirect[x], ids[1, 2])pytest_generate_tests钩子中的metafunc.parametrize调用将indirect、ids、scope全部改为关键字传参如果你的自定义钩子里曾把第四个及以后的位置参数传给parametrize这些调用点必须在升级前修复。6.2 利用类型标注提前发现问题由于parametrize签名在类型层面一直是 keyword-only*分隔符后的参数依赖类型检查器的团队可以在升级前通过以下方式定位存量问题mypy src tests # 或 pyright对metafunc.parametrize(x, y, [(a, b)], [x])这类调用静态检查会直接报告 Unexpected positional argument。这也是 changelog 强调类型标注早已声明为 keyword-only的实用价值类型检查器可以成为这次 breaking change 的提前预警系统。6.3 批量修复思路对于大型代码库可以先通过 grep 粗筛可疑调用# 找出所有位置参数超过两个的 parametrize 调用装饰器与编程式均适用 grep -rn parametrize(.*,.*,.*, --include*.py src tests再结合类型检查与单元测试仓库自身的 metafunc.py 就是修复后的行为样本逐点确认。七、与 9.1 其他参数化变更的协同本次 keyword-only 收紧并非 9.1 中唯一的参数化相关变化。在 python.py 的argvaluesdocstring 中可以看到另一条versionchanged:: 9.1记录向argvalues传入非Collection的可迭代对象如生成器、迭代器已被标记为弃用。这意味着 9.1 的参数化 API 整体在走向更严格、更明确的契约位置参数被收窄为argnames、argvalues两个argvalues要求是可重复消费的集合而非一次性迭代器所有语义性选项indirect/ids/scope强制具名。对使用者而言升级到 9.1 时的心智模型可以简化为参数化 两个位置参数是什么参数、给什么值 三个关键字选项怎么传、怎么命名、什么作用域规则清晰且错误信息友好。八、FAQ 速查Q1升级后pytest.mark.parametrize不加任何选项还能照常用吗能。前两个位置参数argnames、argvalues不受影响最常见的两参数用法无需任何改动。Q2indirectTrue这种单参数写法需要改吗需要改为关键字形式indirectTrue。布尔值True/False作为第三位置参数传入同样会触发TypeError必须显式写出参数名。Q3ids传 lambda 或生成器可以吗可以ids接受序列、生成器如itertools.count()或可调用对象其中序列与生成器中的元素应为str/int/float/bool/NoneNone表示退回自动生成 ID。详见 python.py 的说明。Q4scope不传会发生什么不传时_infer_parametrize_scope会依据参数名对应的 fixture 定义自动推断作用域显式传入则优先采用并覆盖 fixture 自身定义的作用域。Q5这个变更会影响第三方参数化插件吗凡是内部调用metafunc.parametrize且以位置方式传递indirect/ids/scope的插件都会受到影响。这类插件应尽快升级并遵循 keyword-only 约定_param_mark等下划线参数为内部实现细节不应对其做任何假设。总结pytest 9.1 将Metafunc.parametrize的indirect、ids、scope收紧为 keyword-only是一次以可理解的TypeError取代晦涩的间接错误的 API 硬化。它消除了parametrize(arg1, arg2, [(1, 2)])这类误用被静默吞入indirect的陷阱让运行时行为与类型声明、官方文档保持一致。升级时只需扫描参数化调用点、改为关键字传参并借助类型检查器提前预警即可平滑过渡——仓库中 python.py 的实现与 metafunc.py 的回归测试是理解与验证这一契约的最佳参照。【免费下载链接】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),仅供参考