变异测试:谁来测试你的测试
上一章拆穿了覆盖率:它量的是「代码被执行」,不是「行为被验证」。这就留下一个尖锐的问题 —— 那我到底怎么知道一套测试是不是真有用?测试是用来验证代码的,可谁来验证测试?这个「测试你的测试」的问题,有一个极其聪明的答案,叫变异测试(mutation testing):把你的代码故意改坏,制造一堆有 bug 的版本(变异体),然后用你现有的测试去跑它们。如果测试能让改坏的版本变红,说明它抓得住这种 bug;如果改坏了测试还全绿,说明你的测试对这种 bug视而不见。它量的,恰恰是覆盖率量不到的那个东西:牙齿。
核心点子:把代码改坏,看测试红不红
变异测试的想法,回到了第 3 章「先看到红」的精神。当时我们说:一个从没红过的测试,你不知道它会不会红。变异测试把这件事系统化、自动化了:
如果我把你代码里的 > 改成 >=(制造一个 bug),而你的测试依然全绿 —— 那说明:要么你没测那个边界,要么你的断言太弱。总之,这种 bug 真的溜进代码里时,你的测试拦不住。
变异测试就是把这个实验批量做:自动生成成百上千个「微小改坏」的版本,逐个用你的测试去验,统计有多少被抓到。
它怎么工作:变异算子
变异测试工具会对你的代码做一批标准的「小手术」,每种手术叫一个变异算子(mutator):
- 关系运算:
>↔>=、<↔<=、==↔!= - 算术:
+↔-、*↔/ - 逻辑:
&&↔|| - 边界:
return true↔return false、把某行删掉、把某个返回值改成默认值
每做一个手术,就是一个变异体(一个和原代码只差一点点的、有 bug 的版本)。然后:
- 杀死(killed):某个测试因为这个变异体而失败了 —— 好!说明你的测试能抓到这种改动。
- 存活(survived):所有测试照样全绿 —— 坏!这个 bug 版本骗过了你的整套测试。每一个存活的变异体,都是你测试网上的一个洞。
变异分数 = 杀死数 / 变异体总数。这个分数,才是「你的测试到底有多大本事抓 bug」的真实度量 —— 它量的是验证能力,不是执行路径。
眼见为实:弱测试 2/5,强测试 5/5
下面这个 Demo,被测函数是「买超过 10 件减 5 元」。它生成 5 个变异体(*改+、>改>=、>改<、-5改+5、删掉打折)。你可以切换两套测试:一套「弱测试」(只测了 qty=1),一套「强测试」(测了 1、10、11 三个点)。看变异分数怎么跳。
结果很说明问题:弱测试只杀掉 2/5(40%) —— 那些不涉及「打折逻辑」的变异体它抓到了,但因为它从没测过 qty > 10 的情况,三个和打折有关的变异体全部存活。切到强测试,5/5(100%) —— 因为它测了边界(qty=10、11),每个变异体都被逼红。
变异测试不只给你一个分数,它给你的是具体的、可操作的诊断:每个存活的变异体,都精确地告诉你「如果代码这样改坏,你发现不了」。你照着它去补测试,就是在给网上的洞打补丁。这比"覆盖率还差 10%"有用一万倍 —— 后者不告诉你缺的是什么,前者直接把缺口指给你看。
为什么它是「覆盖率的诚实版」
把两者摆一起对比,就懂了变异测试的价值:
| 覆盖率 | 变异测试 | |
|---|---|---|
| 量的是 | 代码被执行了多少 | 代码变坏时被抓到了多少 |
| 没断言的测试 | 照样贡献覆盖率 | 杀不掉任何变异体,分数为 0 |
| 能骗过它吗 | 能(刷执行) | 难 —— 要骗它必须真的能抓 bug |
| 成本 | 低(跑一遍) | 高(每个变异体都要跑一遍全套测试) |
关键那一行:一个没有断言的假测试,能刷出 100% 覆盖率,却杀不掉任何一个变异体(改坏了代码它也不会红,因为它根本不 assert)。变异测试一眼看穿这种假测试 —— 这正是它「诚实」的地方。
成本与用法
变异测试很贵:N 个变异体,每个都要把整套测试跑一遍,时间接近 N 倍。所以它不适合每次提交都跑。实际用法:
- 工具:Pitest(JVM/Kotlin/Java 的事实标准)、Stryker(JS/TS)。
- 只对关键的、复杂的核心逻辑跑(支付、权限、计价),不必全项目跑。
- 放在夜间构建(第 22 章),或者手动针对某个模块跑,把它当作「审计测试质量」的工具,而非日常门禁。
AI 时代:审计 AI 测试的唯一硬指标
这一章是全书主线的一个关键扣。上一章说,覆盖率是审查 AI 测试时最不能信的指标(AI 能轻松刷高覆盖 + 一堆没牙齿的测试)。变异测试正是那个骗不过的指标:
AI 生成一套测试,覆盖率 95%,全绿,看起来无懈可击。但它是真的验证了行为,还是一堆 assertNotNull 凑数?肉眼很难判断,变异测试一跑就见分晓。如果这套测试的变异分数很低(改坏代码它也不红),那它就是漂亮的空壳 —— 无论覆盖率多高。
所以在 AI 大量生产代码和测试的年代,变异分数成了一个格外珍贵的东西:一个不由「谁觉得测得好」、而由「改坏了抓不抓得到」说了算的、无法被表面功夫糊弄的质量度量。它是第 1 章那个「外部真相来源」在"测试质量"这一层的化身。你不信任 AI 的代码,也不该信任它的测试 —— 而变异测试,是你验证后者的那把锥子。
真跑一次:Pitest 的报告长什么样
在 JVM 项目里,加个 Pitest 插件,跑 ./gradlew pitest,它会对你的代码批量做手术,输出一份报告:
>> Generated 47 mutations, Killed 39 (83%) >> Line Coverage: 96% Mutation Coverage: 83% ← 注意这两个数字的落差 price.kt:3 > changed conditional boundary → SURVIVED ← qty > 10 改成 >= 没被抓 price.kt:3 negated conditional → KILLED price.kt:3 removed call to discount → SURVIVED ← 删掉打折逻辑没被抓 price.kt:4 Replaced integer subtraction with addition → KILLED
看那两行:行覆盖 96%,变异覆盖只有 83%。这 13 个百分点的落差,就是「代码被执行了、但没被真正验证」的部分 —— 正是上一章说的覆盖率盲区,被变异测试量化了出来。而每一个 SURVIVED 都带着行号和「怎么改坏的」,直接告诉你去哪儿补什么测试。这份报告,比一个 96% 的覆盖率数字有用一个数量级。
一个绕不开的坑:等价变异体
变异测试不是完美的,它有个理论上的麻烦叫等价变异体(equivalent mutant):有时候你把代码改「坏」了,但改出来的版本行为和原来完全一样 —— 那它当然没有任何测试能杀掉它(因为它根本没引入可观测的 bug)。
// 原始:i < list.size
for (i in 0 until list.size) { ... }
// 变异:i != list.size —— 在这个循环里,行为和 < 完全等价,杀不掉,但也不是 bug
因为等价变异体的存在,变异分数几乎永远到不了 100%,而且判断「一个存活的变异体是真漏洞还是等价体」需要人看,成本不低。所以正确的态度是:把变异分数当趋势和诊断,而不是当 KPI。重点看那些明显是真漏洞的存活者(比如"删掉整条打折逻辑没被抓"),照着补测试;那些一看就是等价体的,忽略即可。追求 100% 变异分数,会掉进和"追求 100% 覆盖率"一样的古德哈特陷阱。
怎么在真实项目里用它
变异测试的成本(N 个变异体 × 每个跑一遍全套)决定了它的用法必须克制:
- 范围要窄:只对核心的、复杂的、出错代价高的模块跑(计价、权限、风控、状态机)。别全项目跑,你会等到天荒地老。
- 时机要靠后:放夜间构建(第 22 章),或者你重点重构某个模块后手动针对它跑一次。它是「审计工具」,不是「每次提交的门禁」。
- 增量地看:Pitest 支持只对本次 diff 变动的代码跑变异 —— 这让它快到可以进 PR 流程,只审「你这次改的代码,测试跟上了没」。
1. 回到 Demo,注意弱测试放跑的三个变异体,全都和「打折」这条它没测的分支有关。存活的变异体精确地指出了测试的盲区。
2. 在你项目的一个核心模块上跑一次 Pitest / Stryker。变异分数大概率比你的覆盖率低不少 —— 那个差距,就是覆盖率一直在替你隐瞒的真相。
3. 让 AI 写一套测试,然后对它跑变异测试。看它写的测试变异分数如何。这是检验「AI 测试有没有牙齿」最硬的一招。
覆盖率和变异测试,拆穿的是「测试有没有牙齿」。下一章拆另一种谎言 —— 一种时绿时红的测试。它 97% 的时候是绿的,于是你信了它;可它的绿和红,和你的代码对不对毫无关系。flaky 测试,可能比没有测试更糟。