一个订单状态机上线两周后出错:本该"已支付且未发货"才允许取消,代码里写成了 ||。测试全部通过,覆盖率 85 %。翻测试用例发现,那段判断确实被执行到了——覆盖率算它被覆盖,只是没有一个用例构造出"已支付但已发货"的组合,于是 && 与 || 的差别从未被验证。
这就是覆盖率的天花板:它衡量"这行代码有没有被跑到",不衡量"跑到了能不能发现问题"。变异测试补的正是这一层——它不问"测到没有",而问"如果我故意把代码改错,你的测试能不能抓住"。
1. 覆盖率与变异率:两个不同的问句
| 指标 | 问的问题 | 高分意味着 | 高分不意味着 |
|---|---|---|---|
| 行覆盖率 | 这行被执行过吗 | 没有"死代码" | 断言有效 |
| 分支覆盖率 | 每个分支走过吗 | 条件组合被碰过 | 组合的语义被验证 |
| 变异率(mutation score) | 改错一处,测试会失败吗 | 测试有检测力 | 覆盖全部场景 |
| 断言密度 | 每个用例断言几件事 | 测试不空跑 | 断言是否恰当 |
// 同一段代码,两个指标给出的结论完全不同
if (paid && !shipped)
覆盖率: 该行被执行过 → 85% 达标
变异率: 改成 || 后测试仍绿 → 漏测被抓住
// 覆盖率看"到没到",变异测试看"拦不拦得住"
第三行是这层"元测试"的核心:它测的是测试,不是代码。一个变异率高的模块,说明它的测试真的能拦住错误;而覆盖率高的模块可能只是"跑过了但没断言"。
2. 机制:改一处、跑一遍、看是否失败
| 步骤 | 动作 | 结果分类 |
|---|---|---|
| 1 | 对源码做一处微小改动(+→-、&&→||、删一行) |
得到 1 个 mutant |
| 2 | 跑该模块的全部测试 | — |
| 3 | 有测试失败 | killed(测试有效) |
| 4 | 全部通过 | survived(测试漏了) |
| 5 | 编译不过 / 超时 | 忽略或单独统计 |
第四行就是价值所在:每个 survived 的 mutant 都是一条"测试存在盲区"的具体证据,而且它带着具体的代码位置——比"覆盖率不够"精确得多。
// 一个真实的 survived(复用自开头的场景)
原始: if (paid && !shipped) return cancelable;
变异: if (paid || !shipped) return cancelable; ← 改成 ||
结果: 全部测试通过 → survived
// 结论:测试里没有"paid=true, shipped=true"这个组合
// 补一个用例即可;覆盖率不会告诉你这件事
3. 等价变异体:不是所有 survived 都是问题
有些变异不改变程序语义(例如把 i++ 改成 i += 1,或删除一段无副作用的日志),任何测试都杀不死它。它们叫等价变异体。
| 类型 | 例子 | 是否真问题 |
|---|---|---|
| 语义等价 | a > b → b < a |
否 |
| 无副作用删除 | 删掉 log.debug(...) |
否 |
| 不可达分支 | 改死代码 | 否 |
| 边界未测 | >= → > |
是 |
| 条件组合未测 | && → || |
是 |
// 识别等价变异体的三个启发式(能自动化到七八成)
1 变异后字节码完全一致(编译器已把它优化掉)
2 被变异的是日志、断言消息、常量名,不是控制流
3 该行在整轮执行里从未被任何测试触达
// 剩下的必须人工看:判定"语义等价"本身是不可判定问题
等价变异体在实践中占 10–30 %,且无法自动判定(判定它等价本身就是不可判定问题)。所以变异率不会是 100 %,现实目标是"稳定在 70–85 %,且 survived 清单里没有语义上的漏测"。
4. 成本控制:只变异你改的那几行
全量变异极慢:一个中型模块几千行、每行若干个 mutant、每个 mutant 跑一遍测试——几小时到几天。所以生产上只用增量。
| 策略 | 范围 | 耗时 | 适用 |
|---|---|---|---|
| 增量变异(PR 内) | 只变异 diff 里的逻辑行 | 分钟级 | 每次 PR |
| 模块级 | 单个模块全量 | 十分钟–小时 | 每日 |
| 全量 | 全仓库 | 小时–天 | 每周 / 发布前 |
| 抽样 | 随机 10 % mutant | 可控 | 持续观测趋势 |
第一行是性价比最高的:只变异本次改动的行——它的耗时约等于"测试套件跑一遍"乘以 diff 里的 mutant 数,而全量变异的时间与整个代码库成正比,这也是它不能塞进每次 PR 的原因。,把变异测试变成 PR 门禁的一部分。它天然回答了"这次改动有没有被新测试保护"。
// 一个可用的 PR 门禁(只变异 diff,阈值 70%)
$ git diff origin/main --name-only | grep '\.java$' > changed.txt
$ mvn -q org.pitest:pitest-maven:mutationCoverage \
-DtargetClasses=$(paste -sd, changed.txt) \
-Dfeatures=+EXPORT -DoutputFormats=XML
$ grep -o 'detected="[0-9]*" generated="[0-9]*"' target/pit-reports/mutations.xml \
| awk -F'"' '{d+=$2; g+=$4} END{printf "score=%.1f%%\n", 100*d/g}'
// score < 70% → 让这个 PR 失败,并指出 survived 的位置
5. 与覆盖率的关系:先覆盖,再变异
两者不是替代关系,是顺序关系。
| 覆盖率状态 | 变异测试的作用 |
|---|---|
| < 50 % | 先补覆盖,变异测试意义不大(大量 survived 只是因为没跑到) |
| 50–80 % | 变异测试能定位"跑到了没断言"的具体位置 |
| > 80 % | 变异测试是主要的质量信号 |
| 高覆盖 + 高变异率 | 可以信赖这套测试 |
// 两个指标的推进顺序,反了就是浪费
阶段一 覆盖率 50% → 80% 常规单测补齐
阶段二 变异率 70% → 85% 按变异报告定位漏测
阶段三 保持 增量门禁 + 周度趋势
// 在 50% 覆盖率上跑变异测试,产出全是"没有测试"的噪音
第一行要说明白:在覆盖率很低的模块上跑变异测试,会得到一堆噪音——因为所有 mutant 都 survived,而原因只是"没有测试"。所以顺序是固定的:先把覆盖率拉到可用水平,再用变异率做精修。
6. 排错:五种"以为测够了"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 覆盖率 85 % 但漏边界 | 只测 happy path | 补组合用例(变异测试会指出位置) |
| 断言只写"不抛异常" | 无有效断言 | 断言返回值/状态变化 |
| 测试全绿但回归频发 | 测试在 mock 里自证 | 加真容器集成测试 |
| 变异率忽高忽低 | 测试含随机/时间依赖 | 固定种子与时钟 |
| PR 门禁太慢 | 变异范围没限制 | 只变异 diff |
第四行很常见:测试里有 Instant.now() 或随机数据,同一个 mutant 这次被杀、下次存活。变异测试要求测试本身是确定性的,否则分数没有意义。
7. 边界:什么时候不值得做
| 场景 | 为什么不必 | 说明 |
|---|---|---|
| 纯 CRUD / 胶水代码 | mutant 大量等价 | 收益低 |
| 覆盖率 < 50 % | 先补覆盖 | 见第 5 节 |
| 生成代码 | 无法对应业务语义 | 排除目录 |
| 原型 / 一次性脚本 | 无长期价值 | 不投入 |
| 强依赖外部服务的模块 | 变异跑不动 | 先解决可测性 |
判断口径一句话:变异测试适合"逻辑密集 + 生命周期长 + 出错代价高"的模块——支付、权限、状态机、计价。对这三类之外的地方投入,回报会低于同等时间的其他质量手段。
8. 自测
- 覆盖率与变异率分别回答什么问题?为什么前者高不能推出后者高?
- 一个 mutant 的四种结果分别意味着什么?"survived"为什么是有价值的信号?
- 等价变异体为什么占 10–30 %?现实目标为什么不是 100 %?
- 为什么"只变异 diff"是性价比最高的策略?它天然回答什么问题?
- 覆盖率 < 50 % 时先做什么?为什么顺序不能反?
- 什么类型的模块适合上变异测试?判据是什么?
9. 一句话总结
覆盖率衡量"被执行",变异率衡量"能发现错误"——前者是必要条件,后者才是检测力。做法是"改一处代码,看测试是否会失败",每个 survived 都指向一个具体的漏测位置(比"覆盖率不够"精确得多)。成本靠只变异 diff 控制,顺序是"先补覆盖、再上变异",而等价变异体决定了现实目标是 70–85 % 而非 100 %。判据:逻辑密集、生命周期长、出错代价高的模块才值得投入。