第 3 章 · 03 断言的艺术
本章目标:掌握 pytest 断言重写的工作原理,学会用
pytest.raises、pytest.warns、pytest.approx写出失败时能"自己解释自己"的高质量断言。
3.1 断言重写:为什么普通 assert 这么好用
unittest 需要几十个 assertXxx 方法(assertEqual、assertIn、assertIsNone……),pytest 只用一个原生 assert。秘密在于断言重写(assertion rewriting):pytest 导入测试模块时,会拦截编译过程,把每个 assert 语句改写成等价但携带中间值信息的代码。于是失败输出能展示子表达式的求值结果:
# test_assert_demo.py
def f():
return 3
def test_function():
assert f() == 4> assert f() == 4
E assert 3 == 4
E + where 3 = f()where 3 = f() 就是重写的功劳——它把调用表达式的实际返回值直接打印出来。属性访问、比较运算、二元/一元运算符都有同样的内省能力。
你还可以给断言附加说明文字,它会在失败时与内省信息一起打印:
def test_even(n=3):
assert n % 2 == 0, "value was odd, should be even"重写的边界
断言重写只发生在 pytest 管理导入的模块上。如果你在测试里断言了一个第三方库内部的函数行为,失败信息里只有最终结果,没有该库内部表达式的展开——这不是 bug,是重写范围决定的。
3.2 浮点数与集合:pytest.approx 实用模式
浮点比较永远不要直接用 ==,官方推荐 pytest.approx:
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 相等的语义。
对集合做断言时,先想清楚你要的是"顺序无关的相等"还是"包含关系":
def test_permissions():
granted = {"read", "write"}
# 顺序无关相等:用 set 比较,而不是 sorted 后 ==
assert granted == {"write", "read"}
# 包含关系:子集判断比逐个 in 更能表达意图
assert {"read", "write"} <= {"read", "write", "admin"}3.3 异常断言:pytest.raises 的正确姿势
验证"这段代码应该抛异常",用上下文管理器形式:
import pytest
def test_zero_division():
with pytest.raises(ZeroDivisionError):
1 / 0需要检查异常信息时,用 as excinfo 拿到 ExceptionInfo 对象(有 .type、.value、.traceback 三个核心属性):
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:
def test_match_parameter():
with pytest.raises(ValueError, match=r"got -\d+"):
parse_age("-7")两个高频陷阱必须牢记:
- 类型匹配遵循继承关系。
NotImplementedError是RuntimeError的子类,所以pytest.raises(RuntimeError)会放过NotImplementedError。要精确匹配就用excinfo.type is RuntimeError再补一刀; - 只包住会抛异常的那一行。如果
with块里混入了准备代码,准备代码意外抛错也会让测试"假通过"。
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 完全对称:
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 多断言的组织策略
一个测试里放多个断言本身没问题,但要注意两点:
- 断言应该服务于同一个行为验证。第一个断言失败后,后面的不会执行——所以互不相关的检查应拆成多个测试;
- 如果确实希望"一次看到所有失败项"(如校验批量数据),可以收集错误后一次性抛出:
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.warns与raises结构对称;多断言要么聚焦同一行为,要么聚合后一次报告。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. pytest 失败输出中的 "+ where 3 = f()" 是怎么来的?
2. with pytest.raises(RuntimeError) 能捕获 NotImplementedError 吗?
3. 关于 pytest.raises 的 match 参数,正确的是?
4. 断言 0.1 + 0.2 与 0.3 近似相等,最推荐的写法是?
🛠️ 动手实践
- 编写一个
divide(a, b)函数,除零时抛出带提示信息的ZeroDivisionError,分别用as excinfo和match=两种方式为它写异常测试。 - 用
pytest.approx验证[0.1*3, 0.2*3]与[0.3, 0.6]的列表近似相等,再故意改大rel观察何时失败。 - 给第 2 章的某个函数加一个"参数非法时发
UserWarning"的逻辑,并用pytest.warns验证消息内容。
断言写利索了,下一章学习如何组织成百上千个测试并给它们打标签。