上个月帮一个团队看一段测试代码,二十来个用例全绿,但同一个函数在生产日志里隔几天就抛一次类型错误。翻到测试文件末尾,问题一目了然:核心分支上的 Python 断言写成了assert (isinstance(x, dict), "x 必须是字典")。括号里是个二元组,非空元组恒为真,这条断言从写下那天起就没生效过。这类问题不是水平问题,而是断言语义里藏着几个反直觉的角落:它什么时候会被执行、拿什么比较、失败了报什么,三者任一没搞对,测试就会变成心理安慰。
这篇就围绕 Python 断言把这几件事拆开:assert语句本身的执行条件与失效链路,unittest那一大串assertXxx函数彼此差在哪,pytest的断言内省凭什么让一条裸assert顶得上手写十行校验,以及实操中真正会翻车的那几类现场和各自的排查路径。刚写测试的同学能拿到一份可直接抄的对照表,写了几年但一直靠直觉选断言函数的人,大概能在浮点比较、异常断言、软断言这几节里补上缺口。
1. assert 语句被误用的那一面:它到底管什么
1.1 从一个永远为真的断言说起
assert的语法形式是assert 表达式[, 消息],语义上等价于if not 表达式: raise AssertionError(消息)。这个翻译看着平淡,但它带来一个容易被忽略的后果:只要表达式本身不是布尔值,Python 就会用真值规则去判断。顺手加个逗号、多包一层括号,表达式就变成了元组,而任何非空元组都是真值。于是assert (cond, "msg")、assert (x > 0)这类写法全部退化成空语句,解释器不报错、测试不失败、IDE 也不一定提示,静默失效,这是断言类问题里最难发现的一种。
我见过更隐蔽的版本:有人在断言消息里做了格式化,写成assert x == y, "mismatch: %s vs %s" % (x, y),前一行刚写了assert(x == y),第二行复制粘贴时忘了删掉左侧的括号。单个表达式加括号本身没问题,assert (x == y)是合法的,问题只出在"括号里有两项、中间用逗号隔开"这种组合上。所以我给团队定的规矩很粗暴:断言语句里不出现逗号之外的任何多余标点,需要解释的写进消息参数,不要塞进条件表达式。
判断一条assert是否真的在起作用,最省事的办法是临时把它改成assert False,跑一遍用例,看有没有变成红色。没变色,说明这条断言从来没被执行到,或者早就被别的东西吃掉了。这个方法听着土,但在接手陌生代码库时,比逐行读代码快得多。
1.2 优化模式下 assert 静默消失的完整链路
assert不是普通语句,它是为调试服务的。解释器在编译阶段就会检查__debug__这个内置常量,它的值由启动参数决定:默认是True,加上-O或-OO就变成False。一旦为False,编译产物里根本不会包含断言对应的字节码,不是"执行时跳过",而是"压根没生成"。这意味着断言里的任何副作用——函数调用、变量赋值、日志输出——在优化模式下统统不存在。
可以用两行代码验证这件事:
python -c "assert False, 'boom'; print('没炸')" # AssertionError: boom python -O -c "assert False, 'boom'; print('没炸')" # 没炸-OO在-O的基础上还会顺手剥掉文档字符串,一般项目里用得少,-O则是很多部署脚本里的常客,出于"少点检查快一点"的朴素想法被加进启动命令。真正的坑在于它和缓存机制叠加:同一个模块,__pycache__里可能同时存在带断言和不带断言的两种字节码文件,谁先跑出来就被谁复用。开发机上一切正常,打包镜像里用了-O,测试套件里的断言全部变成装饰品,报告依然一片绿。
基于同一条逻辑,pytest在优化模式下运行时会明确提示断言相关的警告,因为它的断言重写机制依赖源码里的assert语句存在。真要在生产环境开优化,一个可用的小技巧是把必须保留的检查写成if not cond: raise AssertionError(...)或者自定义异常,这类代码不参与编译期裁剪。我个人的取舍是:生产环境不靠断言兜底,断言只在测试和开发环境里承担"不可能发生的情况"这一职责,一旦越界就该换成显式的raise。
1.3 三条分界线:调试断言、参数校验、契约检查
断言最常被误用的地方,是拿它做输入校验。用户提交的表单、外部接口返回的数据、命令行传进来的参数,这些都属于"运行时可能合法地不满足条件"的情况,应该抛ValueError、TypeError或者自定义业务异常,而不是AssertionError。原因有三条,每一条单独拿出来都够呛:优化模式下断言会消失;AssertionError在调用方的异常处理体系里通常没有位置,捕获它显得莫名其妙;断言失败意味着"程序逻辑自相矛盾",而输入不合法只是"调用方用错了",两者语义完全不同,混在一起会让错误分类彻底失效。
我习惯用一张表来划这条线,团队 review 时直接对照:
| 场景 | 该用什么 | 理由 |
|---|---|---|
| 内部函数的前置条件(调用方一定是自己人) | assert | 属于开发期契约,出错即 bug |
| 对外 API 的参数校验 | raise ValueError/TypeError | 输入不合法是正常路径,不能被优化掉 |
| 文件、网络、数据库返回值检查 | raise自定义异常 | 外部环境不可控,需要可捕获、可重试 |
| 测试用例中的结果比对 | assert或assertXxx | 失败即缺陷,需要清晰报错 |
| 不可达分支标记 | assert False, "..."或raise NotImplementedError | 显式失败优于静默走错路 |
还有一类介于中间的情况值得单独提:契约式设计里的前后置条件。这类断言写起来很爽,但团队规模一上来,维护成本会陡增——每个函数都堆几条断言,失败信息淹没在噪音里,最后没人看。我的做法是只给跨模块边界和状态机迁移这两类位置加断言,函数内部的中间变量靠类型标注和单元测试覆盖,不额外加运行时检查。
2. unittest 断言函数族:名字相近,语义差得远
2.1 assertEqual 与 assertIs:一个比"值",一个比"身份"
这两个最容易被随手互换。assertEqual(a, b)比较的是a == b,走的是对象的__eq__;assertIs(a, b)比较的是a is b,即两个名字是否指向同一块内存对象。差别在多数时候看不出来,但在几个地方会咬人。
第一个是布尔与整数:1 == True为真,0 == False也为真,所以assertEqual(1, True)是通过的,assertIs(1, True)才会失败。第二个是小整数缓存:CPython 会把 -5 到 256 之间的整数缓存复用,assertIs(100, 100)在多数实现下能过,一旦数字变成 1000,同样的写法就挂了,这种"在小数据上跑通、换成真实数据就崩"的测试最难排查。第三个是字符串驻留,短标识符形态的字符串会被复用,拼接出来的字符串不会,两者混用同样会出现随机失败。
经验上,assertEqual覆盖九成场景,assertIs只在三个地方值得用:和None比较(官方推荐的写法就是assertIsNone,比assertEqual(x, None)更准确,也避免自定义__eq__把None判等)、和True/False严格比较、以及验证"返回的是同一个对象"这种身份语义(比如缓存命中、单例)。
顺带说assertTrue和assertFalse。它们只做真值判断,assertTrue(1)、assertTrue("x")、assertTrue([0])全都能过。更麻烦的是失败信息:assertTrue(a == b)失败时只告诉你"期望 True 得到 False",两个值分别是什么、差在哪,全靠你自己加消息。这就是为什么assertEqual(a, b)永远优于assertTrue(a == b),后者的可调试性几乎为零。
2.2 数值、容器与字符串断言的选择表
数值比较里最需要单独记的是assertAlmostEqual。它的签名是assertAlmostEqual(first, second, places=7, msg=None, delta=None),内部逻辑分两条:不给delta时,执行round(abs(second - first), places) == 0;给了delta时,执行abs(second - first) <= delta。places和delta同时传会直接抛TypeError,这点要在写代码时留意,别一边设了精度位数一边又给了容差。
places有个反直觉的地方:它用的是四舍五入,不是严格的阈值判断。差值刚好落在第 7 位边界上时,进位规则会让结果在 0 和 1e-7 之间跳,测试偶尔红一下。做数值计算、信号处理这类本来就有舍入误差的场景,我倾向直接给delta,语义清晰,出问题也好解释清楚"我们就允许 1e-9 的偏差"。
容器断言的差别主要在顺序敏感性和元素唯一性上:
| 断言函数 | 比较方式 | 顺序敏感 | 重复元素 |
|---|---|---|---|
assertEqual | 直接用== | 是(列表、元组) | 敏感 |
assertListEqual | 类型必须都是 list,逐个比对 | 是 | 敏感 |
assertTupleEqual | 类型必须都是 tuple | 是 | 敏感 |
assertCountEqual | 元素计数相同即可 | 否 | 敏感(数量要一致) |
assertSetEqual | 集合相等 | 否 | 不敏感(集合天然去重) |
assertDictEqual | 键值逐项比对 | 否(键顺序无关) | 不适用 |
assertCountEqual是个好东西,验证"数据库查询结果集合一致"时比assertEqual(sorted(a), sorted(b))干净得多,也不要求元素可排序。它在实现上会先尝试用计数(哈希)比较,遇到不可哈希的元素再退化成两两配对比较,元素量大时会慢,但正确性没问题。
字符串断言里,assertMultiLineEqual值得记一笔,它在失败时会给出逐行的差异标记,比裸的assertEqual好读太多。验证生成代码、配置文件、模板输出的场景,直接用它,能省下大量"到底哪个字符不一样"的肉眼比对时间。正则匹配则用assertRegex(text, pattern),注意它是re.search语义,不是从头匹配,要锚定就自己加^和$。
2.3 异常与警告断言:assertRaises 的两种写法与三个漏网场景
assertRaises支持两种用法。上下文管理器写法:
with self.assertRaises(ValueError) as ctx: int("abc") self.assertEqual(str(ctx.exception), "invalid literal for int() with base 10: 'abc'")可调用对象写法:
self.assertRaises(ValueError, int, "abc")两种都能用,区别在于前者能拿到异常对象,从而进一步断言异常消息、异常属性、__cause__链。要验证消息内容,我更推荐assertRaisesRegex(ValueError, r"invalid literal.*"),一行搞定,比手动取ctx.exception再assertIn简洁。
漏网场景有三个。第一,只写with self.assertRaises(Exception),把范围放得太宽,任何异常都能让它通过,包括TypeError、AttributeError这种"代码写错了"的异常,测试于是变成了"只要不崩就算过"。第二,用了上下文管理器却忘了检查ctx.exception,等于只验证了"抛了某种异常",没验证"抛的是不是那一条分支"。第三,被测代码内部把异常吞了又抛新异常,assertRaises(ValueError)能过,但根本不是你想测的那条路径,这时要配合assertRaises加上对__context__的检查,或者干脆在关键函数上加日志断点。
警告断言用assertWarns和assertWarnsRegex,用法与assertRaises对称。要注意的是 Python 的警告默认有去重机制,同一个位置只报一次,反复调用被测函数时第二次可能就拿不到警告了。测试里稳妥的做法是配合warnings.catch_warnings()加simplefilter("always"),或者直接用assertWarns上下文,它内部会处理这部分。
3. pytest 的断言内省:一条裸 assert 顶十行手写校验
3.1 断言重写是怎么回事
pytest最让人上瘾的一点是:不用记任何assertXxx,直接写assert a == b,失败时它会告诉你左右两边分别是什么、序列在第几项开始不一样、字典哪个键的值对不上。这个能力不是解释器给的,而是pytest自己在导入测试模块时做的手脚——它挂了一个导入钩子,在模块被编译成字节码之前,把 AST 里的assert语句重写掉,插入自省代码,把参与比较的每个子表达式的值先存到临时变量里,断言失败时再把这些值展开打印。
这个机制有三个必须知道的边界。第一,重写只发生在pytest自己导入的模块上,也就是测试文件和conftest.py;如果你把断言写在业务代码的某个工具模块里,那个模块已经被正常导入过,重写不会发生,失败信息就退化成原始的一行AssertionError。第二,想让某个包里的断言也享受重写,得在conftest.py里显式调用pytest.register_assert_rewrite("your_package.helpers"),而且这句调用必须发生在该模块被导入之前。第三,加--assert=plain参数会关掉整个机制,正常情况下没人这么干,但在排查"为什么失败信息这么简陋"时,先确认一下启动参数里有没有它。
前面说的优化模式问题在这里同样成立:-O下源码里的assert被编译期剥离,重写也就无从谈起,pytest会给出相应提示。所以再强调一次,测试环境的启动命令里不要带-O。
3.2 pytest.raises、approx、warns 的实战细节
pytest.raises的用法和assertRaises类似,但有两个参数值得说透。match参数接收的是正则表达式,作用位置是str(exception),匹配方式是re.search,也就是只要异常消息里包含这个模式就算通过,不需要写完整的消息。因为它是正则,消息里出现.、(、[这些字符时必须转义,否则要么匹配失败,要么匹配到意料之外的内容。我踩过这个坑:验证ValueError("missing key: a.b")时写了match="a.b",结果.被当成通配符,aXb也能过,后来改成match=r"a\.b"才严谨。
pytest.approx解决浮点比较,它默认用相对容差 1e-6,对接近零的值再叠加一个 1e-12 的绝对容差。写法很灵活:
assert 0.1 + 0.2 == pytest.approx(0.3) assert 1.0000001 == pytest.approx(1.0, rel=1e-6) assert [0.1, 0.2] == pytest.approx([0.1, 0.2 + 1e-12]) assert {"a": 0.1 + 0.2} == pytest.approx({"a": 0.3})最后一行的能力很实用,字典、列表、元组都能逐元素套用近似比较,不用自己写循环。要注意approx默认的相对容差在大数值上会放大:比较 1e9 这个量级的数时,1e-6 的相对容差意味着允许 1000 的绝对误差,如果你的业务要求是绝对误差 0.01,那就必须显式传abs=0.01并把rel设为 0,否则测试会放过真实缺陷。
pytest.warns和pytest.deprecated_call用于警告断言。pytest.warns(SomeWarning)作为上下文管理器使用时,会校验至少有一个匹配的警告;它内部同样处理了警告去重的问题,比手写warnings.catch_warnings省心。如果是验证"某段代码触发了弃用警告"这种迁移场景,pytest.deprecated_call()更直接。
3.3 失败信息怎么读:从 traceback 到中间值
pytest失败输出里的E行是核心信息,它会展示断言展开后的表达式、每个子表达式的实际值、以及差异定位。举个例子,两个列表比较失败,输出会指出第一处不同的索引,并把两边的值并排列出来;两个字典比较失败,会只列出差异的键;集合比较会给出"只在左边""只在右边"和"交集"三部分。
读这类输出有个习惯值得培养:先看E行里的比较符号,再看两侧的值,最后看 traceback 的最后一层栈帧是不是你预期的位置。如果栈帧指向了被测代码内部而不是测试文件,说明断言的求值过程抛了别的异常(比如对None取属性),这时候问题不在断言本身,而在断言写得太"深入",把本应先做存在性检查的逻辑全都压缩进一条表达式里了。
这也是我不推荐写超长断言表达式的原因。assert obj["data"]["items"][0]["price"] > 0这种写法信息量确实足,但一旦items为空,报的是IndexError而不是断言失败,排查方向就跑偏了。拆成两步,先断言结构存在,再断言数值范围,失败信息准确得多。
4. 四类典型翻车现场的排查链路
4.1 浮点断言:0.1 + 0.2 的两种失败姿势
第一类姿势是等值比较。assertEqual(0.1 + 0.2, 0.3)必挂,因为浮点二进制表示无法精确表达 0.1,累加后的结果是 0.30000000000000004。这类问题几乎人人都知道,但换到业务代码里就容易被忽略:金额计算经过几轮乘除、百分比换算之后,拿结果和期望值做assertEqual,测试在开发机上过了,换台机器、换个数学库版本就挂了。
第二类姿势是assertAlmostEqual的places边界。前面提过它用四舍五入,当差值刚好卡在临界点时,结果不稳定。更麻烦的是对Decimal和float混用:assertAlmostEqual(Decimal("0.1"), 0.1)这种比较在内部做减法时可能抛TypeError,而不是给出一个明确的失败。
排查链路我一般这么走:先打印repr(a)、repr(b)、repr(a - b),用repr而不是str,因为str会截断精度,看起来一模一样;再看差值量级,决定是改用delta还是先统一数据类型;最后检查被测代码里有没有除法、开方、乘方这类会放大误差的运算。如果整个项目里浮点比较到处开花,值得做一个统一的辅助函数,把容差策略收敛到一个地方,改的时候只改一处。
4.2 断言里塞副作用,测试顺序一变就崩
assert的条件表达式在求值时是会执行的,所以assert self.cache.pop(key) == expected这种写法看起来简洁,实际有两个问题:一旦断言失败,pop已经把元素删了,后续用例的前置状态被污染;如果在优化模式下运行,pop根本不执行,行为直接翻转。
这类问题的表现很有意思:单跑某个用例通过,全量跑就失败;换一下用例顺序,失败的又变成了别的。排查时我的第一反应是找断言语句里有没有方法调用,尤其是带副作用的调用——pop、append、open、commit、发请求、写文件。找到之后把副作用提到断言之前,断言只做纯比较。
同样的道理,断言消息里也别放函数调用。assert x, dump_entire_state()在通过时也会执行一遍dump_entire_state(),如果它很慢或者有副作用,测试就白跑了性能还差。真想延迟求值,可以写成assert x, "...",把详细状态放到失败处理的钩子里。
4.3 第一条断言失败掩盖了后面九条
一个用例里连写十条断言,第一条挂了,后面九条根本没机会执行,你修完第一个问题重新跑,第二个问题才冒出来。来回几轮,时间全耗在"修一个跑一次"上。这在数据校验类的测试里特别常见,比如验证一条接口返回的十几个字段。
unittest给的解法是subTest:
for case in cases: with self.subTest(case=case["name"]): self.assertEqual(case["actual"], case["expected"])subTest的失败不会中断整个循环,所有子用例跑完统一汇报,失败信息里带上case的标识,一眼能看出是哪条数据出的问题。
pytest里没有内置的软断言,需要的话可以用第三方插件的check风格 API,或者自己封装一个收集器:提供一个that(cond, msg)方法,条件不满足就记录消息不抛异常,用例结束时统一断言收集列表为空。自己写的好处是失败信息的粒度完全可控,坏处是要记得在用例末尾调用一次汇总方法,忘了就等于什么都没断言。我倾向于只在"一次性校验大量同构数据"的场景用软断言,普通用例还是保持"一条断言一个失败点"的硬断言,简单直接。
4.4 只在 CI 上失败:环境差异与缓存
有一种失败最折磨人:本地跑一百遍全绿,推到流水线必挂。断言相关的成因主要有三个。
其一是哈希随机化。字典和集合的迭代顺序在不同进程里可能不同,如果被测代码依赖了顺序,而测试断言又恰好对顺序敏感,就会出现随机失败。PYTHONHASHSEED的值变化是主要诱因,本地一直用同一个 shell 可能一直没触发。排查方法是把结果显式排序再断言,或者用assertCountEqual这类顺序无关的比较。
其二是字节码缓存。镜像构建时跑过一次-O,__pycache__里留下了不带断言的.pyc,后续即使启动命令改回来了,缓存也可能被复用。清缓存的标准操作是find . -name "__pycache__" -exec rm -rf {} +,把这个步骤写进 CI 脚本里,比事后排查便宜得多。
其三是依赖版本。断言函数的行为在不同版本之间有过调整,浮点容差的默认值、异常消息的措辞、警告的分类都可能变。验证这类问题最快的办法是在 CI 里打印sys.version和关键依赖的版本,和本地对比,一致了再往下查。
5. 把断言写到能当文档用
5.1 断言消息三要素:期望、实际、上下文
断言失败信息写得好不好,直接决定了排查时间是五分钟还是半小时。我要求消息里至少包含三样东西:期望什么、实际得到什么、在什么条件下得到的。assert result == 200, "状态码不对"是反面教材,assert result == 200, f"下单接口期望 200,实际 {result},请求参数 {payload}"就合格了。
pytest的重写机制会自动补上"期望/实际"这一层,所以在pytest里手写消息时,重点应该放在"上下文"上——哪个用例、哪条数据、哪次循环。而unittest的assertXxx失败信息相对简单,手写消息的负担更重一些。
有个细节值得提:f-string 形式的断言消息在断言通过时也会被求值。如果消息里拼接了大对象或者调用了repr很慢的对象,测试耗时会明显上升。真遇到这种性能问题,可以退回到%延迟格式化或者干脆精简消息内容,把详细状态放到日志里。
5.2 软断言与 subTest:一次跑出全部失败
前面提过subTest和自建软断言收集器,这里补一下选择依据。数据驱动的批量校验用subTest,因为它在报告里会给每条子用例单独的状态,失败数量一目了然;跨多个维度的综合校验(比如同时检查状态码、响应时间、字段完整性)用软断言收集器更合适,因为维度之间没有天然的分组关系。
自己实现一个最小可用的收集器并不复杂:
class SoftAssert: def __init__(self): self.failures = [] def that(self, cond, msg=""): if not cond: self.failures.append(msg) def report(self): if self.failures: raise AssertionError( f"{len(self.failures)} 处校验未通过:\n" + "\n".join(self.failures) )配合pytest的 fixture 使用,每个用例注入一个新实例,结束时调用report()。这套东西的价值在于:一次运行拿到全部失败点,而不是一轮一轮地修。风险也明确——汇总抛出的是单条AssertionError,报告里看不到具体是哪个断言对应的栈,所以消息本身必须写清楚,别只写"字段不符"。
5.3 自定义断言函数的封装边界
项目里断言写多了,自然会想抽公共函数,比如assert_valid_order(order)。封装的收益是可读性和复用,代价是失败信息的可追溯性变差:调用处只看到AssertionError,看不出是订单的哪个字段不合格。所以自定义断言函数里,每一条内部断言都要带足够具体的消息,并且把关键字段值一起打出来。
封装边界的判断标准我总结成一句话:跨模块重复三次以上的校验才值得抽函数,单次使用的复杂校验就地写清楚。抽得太早,函数签名会随着业务演进而频繁变化,最后变成一个什么都能塞的万能校验器,比不抽还难维护。
另外,自定义断言函数不要返回布尔值,也不要设计成"校验并修复"的形态。断言只负责判断,修复是另一条代码路径。混在一起写,测试失败时你连"被测代码到底做了什么"都说不清楚。
最后分享一个我在实际项目里养成的习惯:每次写完一个测试用例,故意把被测函数的返回值改坏一次,看断言会不会红、失败信息够不够定位问题。这个动作花不了半分钟,但能筛掉相当一部分"看着写了其实没生效"的断言——尤其是那些被元组坑、被优化模式坑、被宽泛异常捕获坑掉的语句。断言这种东西,跑绿不算数,能红才算数。