一个模块有四百多条测试,覆盖率八十六,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. 自测
- 断言的五个强度等级分别是什么?L1 与 L2 为什么"看起来在测"却几乎拦不住逻辑错误?
- "怕测试脆"导致的弱化为什么是合理的错误?正确解法是什么?
- 期望值来自实现,测试最多能证明什么?为什么它不能证明正确?
- 变异测试中,哪一类变异的存活最有诊断价值?它说明了什么?
- 评审断言时的第三个问题为什么能替代工具?它的心智步骤是什么?
- 数值比较不设精度为什么等于不做断言?举出两种具体形态。