Python assert 行为不一致原因分析

最近做一些测试, 由于习惯性pytest跑代码, 得到了非预期的结果. 表现如下

> cat a.py
assert 'ab' is 'a'+'b'
> python a.py
# PASS
> pytest a.py
E   AssertionError: assert 'ab' is ('a' + 'b')

一通问题定位下来, 发现是pytest对于assert语句做了改写导致.

pytest为什么要改写assert语句? 为了断言失败的时候输出更多有用的信息. 比如说序列断言相等失败时, diff定位到具体导致不同的元素.

pytest如何改写assert语句的? 利用模块加载时候的钩子函数, 改写了实际加载的代码中的assert语句逻辑. assert语句本身比较简单, (left, op, right) 的三元结构, 当然需要根据具体语句做不同的特化处理. 也支持通过conftest自行写pytest_assertrepr_compare来改写断言失败的输出.

如何看pytest改写后的语句?

import ast
from _pytest.assertion.rewrite import AssertionRewriter

code = """assert 'ab' is 'a'+'b'"""
mod = ast.parse(code)
print(ast.unparse(mod))
AssertionRewriter(None, None, code).run(mod)
print(ast.unparse(mod))

输出

assert 'ab' is 'a' + 'b'
import builtins as @py_builtins
import _pytest.assertion.rewrite as @pytest_ar
@py_assert0 = 'ab'
@py_assert3 = 'a'
@py_assert5 = 'b'
@py_assert7 = @py_assert3 + @py_assert5
@py_assert2 = @py_assert0 is @py_assert7
if not @py_assert2:
    @py_format8 = @pytest_ar._call_reprcompare(('is',), (@py_assert2,), ('%(py1)s is (%(py4)s + %(py6)s)',), (@py_assert0, @py_assert7)) % {'py1': @pytest_ar._saferepr(@py_assert0), 'py4': @pytest_ar._saferepr(@py_assert3), 'py6': @pytest_ar._saferepr(@py_assert5)}
    @py_format10 = ('' + 'assert %(py9)s') % {'py9': @py_format8}
    raise AssertionError(@pytest_ar._format_explanation(@py_format10))
@py_assert0 = @py_assert2 = @py_assert3 = @py_assert5 = @py_assert7 = None

翻译一下

v0 = 'ab'
v3 = 'a'
v5 = 'b'
v7 = v3 + v5
assert v0 is v7

可见常量表达式改写成了变量运算结果, 自然断言失败

为什么`‘ab’ is ‘a’ + ‘b’? 因为做了常量折叠优化

import dis
dis.dis(lambda: 'ab' is 'a' + 'b')

  2           0 RESUME                   0
              2 LOAD_CONST               1 ('ab')
              4 LOAD_CONST               1 ('ab')
              6 IS_OP                    0
              8 RETURN_VALUE

如何确保pytest的assert和实际执行一致? --assert plain关掉全部assert改写, 或者指定模块文档加PYTEST_DONT_REWRITE标记忽略改写.

衍生为什么代码里面 assert 是个糟糕的主意? __debug__参数决定了是否执行assert语句. python -O 时, __debug__ = False, 会不执行assert语句. 相信不会有人真的python -O跑代码吧? 正式代码里面的校验老老实实写if not ...: raise XXX比较稳妥, 至少可以通过XXX抓出来抛对应抛异常的地方

Reference

HOME