2735 字
约 9 分钟
22
S16-A25 · 断言的强度:测试为什么没拦住 bug
2024-12-31
2026-11-09

一个模块有四百多条测试,覆盖率八十六,CI 全绿。上线后出现一个缺陷:某个金额计算在第二位小数上做了四舍五入,而业务要求银行家舍入。翻查测试,有十七条测试覆盖了这个方法——但没有一条断言过小数位的具体值,它们都在断言"返回值不为空"或"不抛异常"。

测试数量与测试有效性是两件事。决定测试能拦住什么的,不是它跑了多少行,而是它的断言有多强。一条只断言非空的测试,与没有测试几乎没有区别:它同样无法区分正确实现和错误实现。


1. 断言的五个强度等级

等级 形态 能拦住什么 常被误认为
L0 无断言 只调用不检查 只有崩溃 —
L1 存在性 assertNotNull / 不为空 NPE 类缺陷 有效测试
L2 类型与形状 类型正确、集合大小正确 类型错误、数量错误 覆盖了逻辑
L3 具体值 断言精确结果 计算错误、边界错误 高覆盖
L4 不变量 断言业务规则恒成立 语义错误 —

L1 与 L2 是"看起来在测"的主要形式。它们不是无用的——L1 能拦住空指针,L2 能拦住结构变化——但它们几乎拦不住逻辑错误,而逻辑错误恰恰是线上缺陷的主体。

同一条测试里断言可以分层共存,这通常是好做法:一条 L3 断言证明这次算对了,一条 L4 断言证明规则没被破坏。前者在重构后会失效,后者不会——两者配合,测试既能定位具体错误,又能在结构变化后存活。

L4 是最强的一层,也是最难写的一层:它不是断言"这一次的输出是 110",而是断言"任意输入下,总额等于明细之和且不为负"。这类断言在重构后仍然有效。

2. 为什么弱断言会大量出现

原因 机制 表现
不知道正确值 业务规则没写下来 只能断言"有个值"
照抄实现 期望值从实现输出里取 断言等于当前行为
断言难写 对象有多层嵌套 只比字段数量
怕测试脆 担心细节变动导致失败 主动弱化断言
生成工具默认 自动生成只做存在性检查 批量弱断言

第四项值得展开:"怕脆"导致的弱化是一个合理的错误。断言精确值确实会让测试在合法变更时失败,但解决方式不是放弃断言,而是选对断言的层次——断言业务不变量比断言具体数值更稳,断言关键字段比断言整个对象更稳。

// 脆:断言整个对象,任何字段变动都会失败
assertEquals(expected, actual);

// 稳且强:断言业务关心的那部分
assertEquals(110.00, actual.total());          // 精确金额,保留两位
assertEquals(2, actual.items().size());        // 数量
assertTrue(actual.total().compareTo(BigDecimal.ZERO) > 0);   // 不变量:不为负

3. 期望值从哪里来

这是断言有效性的根源问题:如果期望值来自实现,测试就只能证明"没变",不能证明"对"。

来源 可信度 说明
业务规则文档 高 唯一的外部基准
手工计算的样例 高 需要留计算过程
线上已知正确的数据 中高 需要确认当时确实正确
实现当前输出 极低 把 bug 固化为契约
另一个实现的输出 低 两边同错则无效

一个可执行的做法:把期望值连同它的推导过程一起写在测试里。

// 期望值附带推导:后来的人能判断它是否仍然正确
@Test
void bankerRounding() {
    // 业务规则:四舍六入五成双(银行家舍入),保留 2 位
    // 推导:2.345 → 2.34(5 后无数字且前一位为偶,舍)
    //       2.355 → 2.36(5 后无数字且前一位为奇,入)
    assertEquals(new BigDecimal("2.34"), svc.round(new BigDecimal("2.345")));
    assertEquals(new BigDecimal("2.36"), svc.round(new BigDecimal("2.355")));
}

注释里的"推导"不是废话——它让下一个人能独立判断这条断言的期望值是否仍然符合业务规则,而不是只能选择相信或删除。

第一项最可靠,也最少被使用——因为业务规则往往没被写下来。这带来一个反向的结论:写测试时发现"不知道怎么算才对",说明业务规则缺失,这本身就是一个重要产出。

第五项的陷阱值得单独说:用两个实现互相验证(比如新旧两版算法对拍),在两边共享同一段逻辑或同一个错误理解时,结果会一致地错。

4. 用变异检验断言强度

覆盖率衡量"执行",变异衡量"验证"。两者结合才能看出测试的真实强度。

变异类型 说明 存活说明什么
条件取反 > 改 >= 边界断言缺失
算术替换 + 改 - 数值断言缺失或过弱
返回值替换 返回 null 或常量 断言未检查结果
语句删除 删掉一行赋值 该行没有被任何断言覆盖

变异测试的成本主要在运行时间(每个变异都要跑一遍相关测试),所以实践中只做抽样:选核心模块、选计算密集的方法、只对最近改动的类跑。全量变异测试在日常流程里不现实,但作为一次性的体检非常值得。

第三类的存活最有诊断价值:如果实现改成"永远返回 0",测试仍然全绿,那这些测试就没有检查任何东西。

// 抽样做变异测试:只跑核心模块,控制耗时
mvn -q org.pitest:pitest-maven:mutationCoverage \
  -DtargetClasses='com.example.pricing.*' \
  -DtargetTests='com.example.pricing.*Test' \
  -DmutationThreshold=60 \
  -DoutputFormats=HTML,XML
// 存活变异列表就是"测试没拦住的地方",比覆盖率报告具体得多

5. 评审断言时的三个问题

问题 目的
这条断言失败时,能告诉人哪里错了吗 判断 L1/L2 还是 L3/L4
期望值是从哪来的? 判断是否照抄实现
把实现改成明显错误的版本,它会红吗 心智版变异测试

第三个问题在评审现场就能做,不需要工具:读一遍断言,想象把被测代码改成"返回 0"或"少一次循环",这条断言会不会失败。不会失败,它就只是执行。

// 评审时的心智检验,三步
1. 找到被测方法的核心计算
2. 想象把它改成"返回 0"或"少一轮循环"
3. 问:哪些断言会红?
   全都不红 → 这组测试没有检查结果
   只有 L1/L2 红 → 只检查了形状
   有 L3/L4 红 → 有效

6. 排错:五种"测试都过但线上出错"

现象 成因 处理方向
逻辑错误未被拦住 断言停在 L1/L2 补 L3 具体值与 L4 不变量
修 bug 后旧测试变红 期望值来自旧实现 期望值改从业务规则取
边界缺陷漏到线上 边界无断言 补空、负、极值、重复四类
重构后大量测试失败 断言了实现细节 断言公开行为与不变量
测试通过但结果精度不对 只比类型不比精度 断言精度与舍入规则

两种形态都可以直接看出问题:

// 弱:没有精度的浮点比较,实际上要的是"完全相等",几乎永远不成立或永远成立
assertEquals(110.0, actual, "金额");              // 缺 delta

// 强:显式给精度与舍入
assertEquals(110.00, actual.doubleValue(), 1e-9);

// 弱:BigDecimal 用 equals 比较,会把 scale 也纳入比较(2.3 != 2.30)
assertEquals(new BigDecimal("2.3"), actual);

// 强:用 compareTo 比数值,或用 isEqualByComparingTo
assertThat(actual).isEqualByComparingTo("2.30");

第三行是一个反直觉的坑:BigDecimal.equals 会比较 scale,于是 2.3 与 2.30 不相等。断言失败时看起来像"值算错了",实际是"scale 不同"——排查方向完全被误导。

第五项是最容易被忽略的一类:数值比较不设精度,等于不做断言。浮点比较用 assertEquals(double, double) 而不设 delta,或者用 BigDecimal 却不设 scale,都可能让错误的精度通过。

7. 边界:什么情况弱断言是合理的

场景 为什么 做法
冒烟测试 目的就是"能起来" L1 断言合理
胶水层(controller、mapper) 无逻辑 交给集成测试
结构性重构期间 先保证不崩 先 L1,稳定后补强
第三方库的行为探测 目的是观察而非验证 用日志代替断言

还有一条团队层面的做法值得记下:把"这条断言失败时的提示信息"当作断言质量的一部分。assertEquals(expected, actual) 不带消息时,失败输出只有两个值;带上"银行家舍入:2.345 应得 2.34"之后,失败信息本身就说明了业务规则。消息写得好,测试同时成了文档。

判断口径一句话:这条测试要保护的"正确"是什么。答得出来,断言就必须写到那个层次;答不出来,说明这条测试还没想清楚要保护什么。


8. 自测

  1. 断言的五个强度等级分别是什么?L1 与 L2 为什么"看起来在测"却几乎拦不住逻辑错误?
  2. "怕测试脆"导致的弱化为什么是合理的错误?正确解法是什么?
  3. 期望值来自实现,测试最多能证明什么?为什么它不能证明正确?
  4. 变异测试中,哪一类变异的存活最有诊断价值?它说明了什么?
  5. 评审断言时的第三个问题为什么能替代工具?它的心智步骤是什么?
  6. 数值比较不设精度为什么等于不做断言?举出两种具体形态。
S16-A25 · 断言的强度:测试为什么没拦住 bug
https://www.clxhxhhr.top/posts/6403/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。