Skip to content

第 3 章 · 03 断言的艺术

本章目标:掌握 pytest 断言重写的工作原理,学会用 pytest.raisespytest.warnspytest.approx 写出失败时能"自己解释自己"的高质量断言。

3.1 断言重写:为什么普通 assert 这么好用

unittest 需要几十个 assertXxx 方法(assertEqualassertInassertIsNone……),pytest 只用一个原生 assert。秘密在于断言重写(assertion rewriting):pytest 导入测试模块时,会拦截编译过程,把每个 assert 语句改写成等价但携带中间值信息的代码。于是失败输出能展示子表达式的求值结果:

python
# test_assert_demo.py
def f():
    return 3


def test_function():
    assert f() == 4
text
>       assert f() == 4
E       assert 3 == 4
E        +  where 3 = f()

where 3 = f() 就是重写的功劳——它把调用表达式的实际返回值直接打印出来。属性访问、比较运算、二元/一元运算符都有同样的内省能力。

你还可以给断言附加说明文字,它会在失败时与内省信息一起打印:

python
def test_even(n=3):
    assert n % 2 == 0, "value was odd, should be even"

重写的边界

断言重写只发生在 pytest 管理导入的模块上。如果你在测试里断言了一个第三方库内部的函数行为,失败信息里只有最终结果,没有该库内部表达式的展开——这不是 bug,是重写范围决定的。

3.2 浮点数与集合:pytest.approx 实用模式

浮点比较永远不要直接用 ==,官方推荐 pytest.approx

python
import pytest


def test_floats():
    # 经典陷阱:0.1 + 0.2 == 0.30000000000000004
    assert (0.1 + 0.2) == pytest.approx(0.3)


def test_relative_and_abs_tolerance():
    # 默认相对容差 1e-6,可显式指定
    assert 100.0 == pytest.approx(100.001, rel=1e-4)
    assert 0.0 == pytest.approx(1e-9, abs=1e-6)

approx 同样支持列表、字典和 NumPy 数组的逐元素近似比较,甚至能正确处理 NaN 相等的语义。

对集合做断言时,先想清楚你要的是"顺序无关的相等"还是"包含关系":

python
def test_permissions():
    granted = {"read", "write"}

    # 顺序无关相等:用 set 比较,而不是 sorted 后 ==
    assert granted == {"write", "read"}

    # 包含关系:子集判断比逐个 in 更能表达意图
    assert {"read", "write"} <= {"read", "write", "admin"}

3.3 异常断言:pytest.raises 的正确姿势

验证"这段代码应该抛异常",用上下文管理器形式:

python
import pytest


def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        1 / 0

需要检查异常信息时,用 as excinfo 拿到 ExceptionInfo 对象(有 .type.value.traceback 三个核心属性):

python
def parse_age(text):
    age = int(text)
    if age < 0:
        raise ValueError(f"age must be non-negative, got {age}")
    return age


def test_negative_age_message():
    with pytest.raises(ValueError) as excinfo:
        parse_age("-5")
    # 断言异常消息内容
    assert "non-negative" in str(excinfo.value)

更简洁的做法是 match 参数——它接收一个正则表达式,对异常的字符串表示执行 re.search

python
def test_match_parameter():
    with pytest.raises(ValueError, match=r"got -\d+"):
        parse_age("-7")

两个高频陷阱必须牢记:

  1. 类型匹配遵循继承关系NotImplementedErrorRuntimeError 的子类,所以 pytest.raises(RuntimeError) 会放过 NotImplementedError。要精确匹配就用 excinfo.type is RuntimeError 再补一刀;
  2. 只包住会抛异常的那一行。如果 with 块里混入了准备代码,准备代码意外抛错也会让测试"假通过"。
python
def test_exact_type():
    def foo():
        raise NotImplementedError

    with pytest.raises(RuntimeError) as excinfo:
        foo()
    # raises 因子类关系而通过,这里显式收紧为精确类型
    assert excinfo.type is RuntimeError

反模式:try/except 写法

在测试里手写 try: ... except ExpectedError: pass else: pytest.fail(...) 既啰嗦又容易漏掉 else 分支。统一使用 pytest.raises

3.4 警告断言:pytest.warns

验证代码发出预期的警告,结构与 raises 完全对称:

python
import warnings

import pytest


def deprecate_api():
    warnings.warn("old_api is deprecated", DeprecationWarning)


def test_deprecation_warning():
    with pytest.warns(DeprecationWarning, match="deprecated"):
        deprecate_api()

pytest.warns 同样提供 excinfo 风格的记录对象,可以检查一共发出了几条警告、每条的类别和消息;若块内没有发出匹配的警告,测试会失败。与之互补的是 @pytest.mark.filterwarnings 标记,用于把无关警告静音或升级为错误(例如 -W error::DeprecationWarning 让过期 API 直接挂测试)。

3.5 多断言的组织策略

一个测试里放多个断言本身没问题,但要注意两点:

  • 断言应该服务于同一个行为验证。第一个断言失败后,后面的不会执行——所以互不相关的检查应拆成多个测试;
  • 如果确实希望"一次看到所有失败项"(如校验批量数据),可以收集错误后一次性抛出:
python
def test_user_record_shape():
    user = {"name": "Alice", "age": 30, "roles": ["admin"]}

    errors = []
    if "name" not in user:
        errors.append("missing name")
    if not isinstance(user["age"], int):
        errors.append("age must be int")
    if "admin" not in user["roles"]:
        errors.append("must have admin role")

    # 把所有问题一次暴露,而不是修一个跑一次
    assert not errors, f"schema problems: {errors}"

3.6 本章小结

  • 断言重写在模块导入时改写 assert,失败时打印子表达式中间值(where ... = ...);
  • 浮点用 pytest.approx(支持 rel/abs 容差、列表、dict、NumPy);集合按语义选 set 相等或子集判断;
  • pytest.raises + match= 是异常断言标准解法;注意子类匹配问题和 excinfo.type is 精确收紧;
  • pytest.warnsraises 结构对称;多断言要么聚焦同一行为,要么聚合后一次报告。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. pytest 失败输出中的 "+ where 3 = f()" 是怎么来的?

2. with pytest.raises(RuntimeError) 能捕获 NotImplementedError 吗?

3. 关于 pytest.raises 的 match 参数,正确的是?

4. 断言 0.1 + 0.2 与 0.3 近似相等,最推荐的写法是?

🛠️ 动手实践

  1. 编写一个 divide(a, b) 函数,除零时抛出带提示信息的 ZeroDivisionError,分别用 as excinfomatch= 两种方式为它写异常测试。
  2. pytest.approx 验证 [0.1*3, 0.2*3][0.3, 0.6] 的列表近似相等,再故意改大 rel 观察何时失败。
  3. 给第 2 章的某个函数加一个"参数非法时发 UserWarning"的逻辑,并用 pytest.warns 验证消息内容。

断言写利索了,下一章学习如何组织成百上千个测试并给它们打标签。