2441 字
约 8 分钟
12
S16-A32 · 变异测试:为什么"覆盖率 85 %"仍然挡不住 bug
2024-12-31
2026-11-11

一个订单状态机上线两周后出错:本该"已支付且未发货"才允许取消,代码里写成了 ||。测试全部通过,覆盖率 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. 自测

  1. 覆盖率与变异率分别回答什么问题?为什么前者高不能推出后者高?
  2. 一个 mutant 的四种结果分别意味着什么?"survived"为什么是有价值的信号?
  3. 等价变异体为什么占 10–30 %?现实目标为什么不是 100 %?
  4. 为什么"只变异 diff"是性价比最高的策略?它天然回答什么问题?
  5. 覆盖率 < 50 % 时先做什么?为什么顺序不能反?
  6. 什么类型的模块适合上变异测试?判据是什么?

9. 一句话总结

覆盖率衡量"被执行",变异率衡量"能发现错误"——前者是必要条件,后者才是检测力。做法是"改一处代码,看测试是否会失败",每个 survived 都指向一个具体的漏测位置(比"覆盖率不够"精确得多)。成本靠只变异 diff 控制,顺序是"先补覆盖、再上变异",而等价变异体决定了现实目标是 70–85 % 而非 100 %。判据:逻辑密集、生命周期长、出错代价高的模块才值得投入。

S16-A32 · 变异测试:为什么"覆盖率 85 %"仍然挡不住 bug
https://www.clxhxhhr.top/posts/6743/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。