系统级覆盖矩阵:如何验证每一条 assert 都被正确改写)
pytest 断言重写assertion rewriting系统级覆盖矩阵如何验证每一条 assert 都被正确改写【免费下载链接】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 仓库中的一次贡献记录changelog/14813.contrib.rst“Added a systematic coverage matrix for assertion rewriting”展开。pytest 的核心卖点之一是一条assert x 5失败时能展开成assert 3 5并附带中间值解释而这背后是编译期的 AST 断言重写器。本文以该贡献引入的系统性覆盖矩阵 testing/test_assertrewrite_coverage.py 为主体讲清它如何用四个正交的验证维度、五个测试助手把「重写后代码与原始代码语义一致」这一命题从「零散回归用例」升级为一套可枚举、可复用的验证框架。读完后你将掌握如何在不启动完整 pytest 进程的前提下直接调用rewrite_asserts()重写并执行任意测试源码以及设计「改写类」代码的测试体系时应覆盖哪些维度。背景断言重写器为什么需要专门的测试矩阵在 pytest 中断言重写由 src/_pytest/assertion/rewrite.py 实现。公开入口是rewrite_asserts()它接收一个ast.Module和源码字节串内部委托给AssertionRewriter一个ast.NodeVisitor逐条改写assert语句# src/_pytest/assertion/rewrite.py def rewrite_asserts( mod: ast.Module, source: bytes, module_path: str | None None, config: Config | None None, ) - None: Rewrite the assert statements in mod. AssertionRewriter(module_path, config, source).run(mod)见 rewrite.py L385-L392。AssertionRewriter的类文档L598-L649说明了其工作方式run()遍历模块中所有语句对每个ast.Assert调用visit_Assert()把断言条件改写为一系列中间语句临时变量命名为py_assert0、py_assert1…替换成一条在条件为假时抛出带详细解释的AssertionError的if语句。这套改写器的正确性有两个隐含承诺传统「写个 pytester 跑一遍看输出」的回归用例很难系统性地守住语义等价重写前后的断言在通过/失败判定上必须一致且操作数取值顺序必须与 Python 原生求值顺序一致尤其是 walrus 运算符:在表达式中途重新绑定名字的场景单次求值断言中的副作用表达式函数调用、属性访问、下标访问无论断言成功还是失败都只能执行一次——否则用户代码会在测试过程中被偷偷多跑几遍。这正是覆盖矩阵要回答的问题。原有的重写器测试集中在 testing/test_assertrewrite.py其中有一段注释直接交代了矩阵的由来L1640-L1648issue #10743 的测试套件中「其余部分已迁移到以进程内方式运行的覆盖矩阵」只有一条需要两个测试共存于同一模块的用例留在原文件因为「矩阵无法表达跨语句/跨测试的状态保持」。这划清了矩阵的职责边界单条断言内的改写正确性用矩阵覆盖跨测试的状态隔离仍由 pytester 端到端用例覆盖。矩阵的运行底座进程内编译-重写-执行矩阵没有使用pytester启动子进程而是直接对源码字符串做「解析 → 重写 → 编译 → 执行」。核心助手_exec_checktest_assertrewrite_coverage.py L29-L48def _exec_check( src: str, *, rewrite: bool True, ns: dict[str, object] | None None, ) - Callable[[], object]: src textwrap.dedent(src) tree ast.parse(src) if rewrite: rewrite_asserts(tree, src.encode()) if ns is None: ns {} exec(compile(tree, test-rewritten if rewrite else test-plain, exec), ns) return cast(Callable[[], object], ns[check])约定所有测试源码都定义一个名为check()的函数里面放待验证的断言。rewriteTrue时先调用rewrite_asserts(tree, src.encode())原地改写 AST再编译执行rewriteFalse则执行原始 AST。这种「双轨执行」是所有语义类验证的基础——同一个check()既以原生 Python 语义跑一遍又以重写语义跑一遍然后对比结果。进程内执行的代价是每个用例都是纯 Python 函数调用矩阵得以做到高密度、快速迭代。四个正交验证维度模块 docstringL1-L10明确了矩阵验证的四个维度内省深度Introspection depth失败信息中是否包含预期的中间值语义正确性Semantic correctness重写代码与原代码行为一致单次求值Single evaluation带副作用的表达式不被求值多次求值顺序Evaluation order操作数看到的值必须与 Python 原生求值顺序给出的值一致。值得注意的是这四个维度彼此不互相覆盖。例如「语义正确性」只对比是否抛出AssertionError但一个操作数被 walrus 提前/延后读取时两次运行可能都失败、只是「比较了错误的值」——此时需要第四个维度对比运行返回值才能发现。下面按维度讲解对应助手与测试类。维度一内省深度——失败信息里必须出现中间值助手assert_introspectsL71-L93编译并执行重写后的源码捕获AssertionError消息断言其包含must_contain中所有子串、不包含must_not_contain中任何子串def assert_introspects( src: str, *, must_contain: Sequence[str], must_not_contain: Sequence[str] (), ) - str: msg get_failure_message(src) for expected in must_contain: assert expected in msg, ... for unexpected in must_not_contain: assert unexpected not in msg, ... return msg内省矩阵本身按 Python 表达式类型组织成一族TestIntrospection*类每一类对应一种语法节点的重写路径。从测试内容可以反推出重写器需要为哪些 AST 节点维护独立 visitor测试类矩阵中的表达式维度验证的失败信息形态对应源码例子TestIntrospectionCompare、!、、、in、is及链式比较链式比较assert 1 x 5x10失败时显示assert 10 5即只显示失败的那一对操作数L333-L342TestIntrospectionBoolOpand/or及短路assert a and b显示(True and False)短路的a and explode不触达explodeL379-L409TestIntrospectionUnaryOpnot、~assert not x显示assert not TrueL415-L423TestIntrospectionBinOp、-等assert x y 10显示中间值(3 4)L440-L449TestIntrospectionCall/TestIntrospectionMethodCall调用结果以where 42 ...形式的「where 行」呈现L466-L500TestIntrospectionAttributeassert obj.x 5显示where 3 Obj().xL506-L518TestIntrospectionSubscript下标值直接代入比较assert 1 99L538-L554TestIntrospectionIfExp三元表达式两个分支都必须短路未选中的分支即使含1/0也不得执行L557-L581TestIntrospectionComprehension列表推导结果出现在比较中[0, 2, 4]L609-L616TestIntrospectionFStringf-string 渲染结果参与比较value42L629-L637TestIntrospectionWalrus(y : x * 2) 100显示assert 20 100同一名字被多个 walrus 重新绑定时每个操作数各自报告其看到的值L660-L710以最小的例子看整个流程如何落进用户可见的输出——矩阵的冒烟用例L200-L222def test_get_failure_message_returns_message(self) - None: msg get_failure_message( def check(): assert 1 2 ) assert assert 1 2 in msg def test_assert_introspects_succeeds(self) - None: assert_introspects( def check(): x 3 assert x 5 , must_contain[assert 3 5], )x 3; assert x 5失败时消息必须是assert 3 5而不是assert x 5——这就是「重写器把名字解析为中间值」的契约。维度二与三语义等价 单次求值语义等价助手assert_semantically_equivalentL148-L161借助_run_bothL135-L145把同一段源码分别在「未重写」和「已重写」两条轨道上执行返回(raised, result)二元组断言两者的raised一致def _run_both(src: str) - tuple[_Outcome, _Outcome]: outcomes: list[_Outcome] [] for rewrite in (False, True): func _exec_check(src, rewriterewrite) try: outcomes.append((False, func())) except AssertionError: outcomes.append((True, None)) return outcomes[0], outcomes[1]它被用于那些「重写后必须与原生语义完全同判」的表达式容器字面量[1, 2, 3] [1, 2, 4]、字典字面量、列表推导、f-string、if表达式等。这些节点的重写策略是把整块表达式提升到临时变量再组装如generic_visit对容器字面量与 f-string 的 hoisting矩阵用例就是针对这些策略的守卫。单次求值助手assert_single_evaluationL96-L121注入一个counter [0]命名空间让测试源码里的副作用表达式每次执行都自增计数然后断言计数恰为expected_call_count默认 1def test_call_in_compare_evaluated_once(self) - None: assert_single_evaluation( def check(): def side_effect(): counter[0] 1 return 42 assert side_effect() 100 )TestSingleEvaluationL718 起覆盖了 15 个场景比较中的调用、布尔and/or中的调用or短路时第二个调用不应执行期望计数相应调整、一元/二元运算中的调用、property属性访问、自定义__getitem__下标访问、walrus 中的调用、方法调用、嵌套调用outer(inner())期望计数 2、链式比较中多个比较操作数make_val(1) make_val(5) make_val(3)期望计数 3、if表达式条件、推导式的生成器表达式。TestEdgeCasesL1120 起进一步补充了组合场景下标键为函数调用d[get_key()]、嵌套下标d[a][b]、方法返回值再下标s.get_data()[x]、walrus 作为下标键、以及带自定义消息的断言assert d[key] 100, custom failure message验证自定义消息在新 visitor 下仍然生效L1224-L1231。单次求值是重写器最脆弱的性质之一失败路径为了拼装解释文本需要「再取一次值」来生成 where 行如果实现不慎副作用表达式就会在成功分支和解释分支各跑一遍。矩阵用计数法把这个性质变成了可量化断言。维度四求值顺序——walrus 运算符是最尖锐的探针assert_evaluation_orderL164-L189是比语义等价更严格的一条轴它不仅要求两次运行抛不抛异常一致还要求check()的返回值即操作数实际看到的值、追加的 trace、walrus 重新绑定后的最终名字绑定完全相同。模块 docstring 与测试类注释L890-L899解释了它防的是什么 bug重写器把子表达式拆成按源码顺序执行的语句但未被提升hoist的操作数只在组装外层表达式时才被读取——此时它后面的操作数的语句已经执行过了。如果其中恰好有 walrus 运算符重新绑定了该名字前面的操作数就会看到一个 Python 永远不会给出的值def test_compare_left_operand_precedes_walrus(self) - None: assert_evaluation_order( def check(): def identity(v): return v value Hello try: assert value ! identity(value : value.lower()) except AssertionError: return raised, value return passed, value )TestEvaluationOrderL890 起围绕这一主题布置了 14 个用例覆盖比较的左操作数先于 walrus 求值、调用中靠前/靠后的位置参数相对 walrus 的顺序、布尔链中多次 walrus 重绑定、二元运算左操作数、链式比较操作数、容器字面量与 f-string 的操作数这两者由generic_visithoist 成临时变量属于守卫型用例、属性操作数visit_Attributehoist、下标的容器相对键中 walrus 的顺序、方法接收者相对参数中 walrus 的顺序、关键字参数与**kwargs展开相对 walrus 的顺序、if表达式条件先于分支求值。一个值得注意的实现细节是观测方式本身助手注释L178-L181指出观测不能直接包裹脆弱的操作数比如给它套一层函数调用因为那会把它变成调用节点而被重写器 hoist反而把 bug 藏起来必须通过check()的返回值、trace 列表等外部可观察渠道读取。这体现了矩阵作者对「测试设施与被测系统相互干扰」的清醒认识。另外TestHelpersSmokeTest::test_assert_evaluation_order_detects_value_mismatchL301-L312连助手自身都做了元测试它构造了一段「两次运行都通过、但重写版本在locals()里多出了py_assert临时变量」的源码验证顺序助手真的能检测到这种「静默分歧」。如何运行这套矩阵矩阵是一个普通的 pytest 测试模块可以直接在当前仓库中查看并运行# 查看矩阵本体 less testing/test_assertrewrite_coverage.py # 运行整个覆盖矩阵进程内执行无需 pytester 插件依赖外部进程 python -m pytest testing/test_assertrewrite_coverage.py -v # 只看某个维度 python -m pytest testing/test_assertrewrite_coverage.py -k TestSingleEvaluation -v # 与原有重写器端到端测试对照 python -m pytest testing/test_assertrewrite.py -v两条测试线的分工可以总结为testing/test_assertrewrite_coverage.py单条断言内部的重写正确性进程内双轨执行高密度、按表达式类型成矩阵testing/test_assertrewrite.py需要真实 pytest 收集/执行环境的用例例如 L1640 的「walrus 目标名不得跨越无关断言保留绑定」——它需要同一模块里的第二个测试来证明重写器不保留跨语句状态「矩阵无法表达该场景」。小结这套矩阵对「改写类」代码测试的可借鉴之处结合 changelog/14813.contrib.rst 这条贡献记录与 testing/test_assertrewrite_coverage.py 的实现可以提炼出几个对编写「源码变换器/编译器前端」类测试直接可用的做法把正确性拆成正交维度内省输出质量、语义等价判定一致、单次求值副作用安全、求值顺序取值时点四个维度互不替代每个维度一个助手函数新用例只需一行调用加一段内嵌源码双轨执行是等价性验证的骨架同一段源码在未变换与已变换两条轨道上运行对比可观察结果是否抛错、返回值、副作用计数把「变换是透明的」变成可断言命题按被测组件的 visitor 结构组织用例矩阵中的TestIntrospection*类几乎与AssertionRewriter中的visit_*方法一一对应src/_pytest/assertion/rewrite.py L598 起新增表达式支持时知道该往哪个类里加用例对测试助手本身做冒烟测试TestHelpersSmokeTest验证了每个助手「该成功时成功、该失败时以预期方式失败」避免矩阵静默失真划清测试设施的边界进程内矩阵覆盖不了的跨测试状态问题明确留在端到端测试文件并在注释中说明原因testing/test_assertrewrite.py L1643-L1648而不是假装矩阵能覆盖。这套矩阵是理解 pytest 断言重写机制的一份「活文档」想弄清某个表达式比如 walrus、链式比较、if表达式经过重写后会产生什么样的失败输出与临时变量行为直接读对应的测试类比读源码更快反过来AssertionRewriter源码中每个 visitor 的约束也能在矩阵里找到对应的守卫用例。【免费下载链接】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),仅供参考