100% 覆盖率为何仍有 Bug:.dotfiles 的 Mutation Testing 变异测试入门

原创2026-10-28 23:19:101,875 阅读

100% 覆盖率为何仍有 Bug:.dotfiles 的 Mutation Testing 变异测试入门

一句话看懂问题:覆盖率和"捉虫能力"是两回事

很多开发者都有过这种错觉:测试覆盖率 100%、测试全部绿色,代码就是安全的。但现实是,覆盖率(Code Coverage)只告诉你"哪些代码被执行过",变异测试(Mutation Testing)才告诉你"你的测试能不能发现真正的 Bug"。

.dotfiles 这个开源项目(以 CLAUDE.md 开发指南闻名的个人 dotfiles 仓库)内置了一个 mutation-testing 技能,专门解决"100% coverage but bugs still slip through"这一经典痛点。仓库里还配了一个完整的练习场 checkout-core:一个纯计价逻辑的购物结算模块,行覆盖率 100%,却因为弱断言放走了 20 个"变异体"。下面我们就用它来做一次 Mutation Testing 入门。

一个真实事故:100% 覆盖率的订单算错了钱

在 evals/skills/fixtures/mutation-testing-workspace 里,checkout-core 提供小计、折扣、运费和订单汇总四类纯函数,没有 UI、没有 IO,是学习变异测试的理想小样本。

它的事故场景很生活化:一位顾客购物车恰好凑满 £100.00,却被按原价收费——批量折扣只在上超过 £100 时生效,没有覆盖"恰好等于"这一侧。而当时:

问题出在测试的"弱"。典型的弱断言长这样(摘自 src/checkout.test.ts):

it("charges for small domestic orders", () => {
  expect(shippingPence(order())).toBeTypeOf("number"); // 只断言"是数字"
});

it("combines the parts", () => {
  expect(totalPence(order())).toBeGreaterThan(0);      // 只断言"大于 0"
});

这类断言几乎不可能失败:>= 被改成 >、* 被改成 /、&& 被改成 ||……测试照样全绿。弱断言 + 覆盖率 = 虚假的安全感,这正是变异测试要戳破的泡沫。

什么是 Mutation Testing:给代码"动手术"

变异测试的思路可以理解为"故意制造 bug,看测试会不会抓出来":

  1. 生成变异体(Mutants):工具自动在生产代码里引入小改动,比如把 subtotal >= BULK_THRESHOLD_PENCE 改成 subtotal > BULK_THRESHOLD_PENCE;
  2. 跑测试:对每个变异体执行你的测试套件;
  3. 判定结果:测试失败,说明变异体被"杀死"(Killed),测试有效;测试通过,说明变异体"存活"(Survived),这里藏着一个你的测试发现不了的 bug。

一句话记住:一个存活变异体 = 一个你的测试抓不到的潜在 Bug。

checkout-core 基线运行 44 个变异体:24 个被杀死、20 个存活,其中 1 个是"等价变异体"。这些数字来自仓库的评估脚本 evals/skills/mutation-testing-assertions.js,它固定了基线(44 个变异体、20 个存活),并规定修复后存活数必须降到"1 个等价 + 最多 1 个可判断"才算通过。

读懂变异测试报告:4 种结果,4 种处理方式

跑完 Stryker 后,报告里的每个变异体都会归入 4 类。这是 claude/.claude/skills/mutation-testing/SKILL.md 中给出的分类表:

类别 含义 你应该做什么
Killed 变异后测试失败 不用管,测试有效
Survived 变异后测试仍通过 补强测试(除非是等价变异体)
No Coverage 没有测试执行到这段代码 补行为测试
Equivalent 变异后行为完全一样 不用管,但它必须被"点名"并说明理由

⚠️ 新手最容易踩的坑:把所有存活变异体都当缺陷。像 if (items.length === 0) return 0; 被改成 if (false) 后,空数组的 reduce 本来就返回 0——行为没有任何差异,这就是 Equivalent(等价变异体),没有任何测试能杀死它。正确的做法是记录一行理由,而不是徒劳地写测试。

动手:用 Stryker 给 checkout-core 做变异测试

JS/TS 项目的标准起点是 Stryker 里已经声明了 @stryker-mutator/core 和 @stryker-mutator/vitest-runner,但故意没有配置文件和脚本——这正是仓库设计的教学缺口,让你走完"从零到跑通"的全流程。

1. 最小起步配置

技能文档推荐 stryker.config.mjs 起步配置:

export default {
  testRunner: "vitest",          // 优先用项目自己的测试运行器
  coverageAnalysis: "perTest",
  reporters: ["html", "clear-text", "progress"],
  mutate: [
    "src/**/*.{ts,tsx,js,jsx}",
    "!src/**/*.test.{ts,tsx,js,jsx}",   // 只变异生产代码,排除测试
    "!src/**/*.d.ts"
  ]
};

两条黄金规则:

  • 只变异生产代码:测试、fixture、快照、生成文件、dist 等一律排除;
  • 首次配置不要写 break 阈值:没有基线之前,任何阈值要么是"第一天就红",要么是摆设。先跑出分数,再定阈值。

2. 三个常用命令:全量、增量、按分支 diff

技能建议一次性加齐三条脚本(SKILL.md):

{
  "mutation": "stryker run",
  "mutation:incremental": "stryker run --incremental",
  "mutation:diff": "node scripts/stryker-diff.mjs <review-base>"
}
  • pnpm mutation:全项目跑,适合首次建立基线、改测试基础设施或上 CI 门禁;
  • pnpm mutation:incremental:带缓存的增量跑,日常反馈最快;
  • pnpm mutation:diff [base]:只对当前分支相对基线(默认 main)改动过的生产文件做变异,是 PR 门禁的主力。

其中 mutation:diff 有两个值得学习的设计细节(来自 evals/skills/tests/mutation-testing-quality.yaml 的验收要求):

  • 脏工作区直接拒绝:git diff <base>...HEAD 不包含未提交内容,所以工作区不干净时必须停下并说明,而不是悄悄给你一个"假完整"的结果。验收测试 diff-script.test.ts 会专门制造一个未跟踪文件来验证这条拒绝逻辑;
  • 用 Node 辅助脚本而非一坨 shell:解析 NUL 分隔的 git diff --name-only -z 输出、过滤测试文件、调用仓库本地 Stryker 二进制——这些用 shell 拼接极其脆弱。

3. 杀死那 4 个"肇事"变异体

验收测试 boundaries.test.ts 直接模拟了 4 个会放走真实 bug 的变异,并逐一要求"变异后你的套件必须变红":

变异体 后果 怎么补测试
>= → >(批量折扣门槛) 恰好 £100 不打折 在 10_000 和 9_999 两侧各断言精确值
&& → ||(会员+码折扣) 非会员凭码也能打折 三种组合(有码非会员/会员无码/两者都有)分别断言
>= → >(免邮门槛) 恰好 £50 仍收运费 5_000 免邮、4_999 收 399 分
* → /(小计计算) 单价乘以数量变成除以 用非恒等值:250 × 3 = 750,而不是 100 × 1

规律非常清晰:边界值测两侧、布尔组合测全、拒绝恒等值(1、0、空集合)、断言精确结果而非"是数字/大于 0/不抛错"。这些也正是 SKILL.md 列出的"立即修复"清单:缺失的边界测试、弱断言、单侧分支覆盖。

什么时候跑变异测试?PR 就绪门禁,而不是每次提交

这是 .dotfiles 里这套方法最容易被误解的一点:变异测试不放在 RED-GREEN-REFACTOR 内循环里。

它的工作流(SKILL.md)是:

每个 TDD 增量:
  RED → GREEN → REFACTOR,只靠变异规则"想"测试点,不跑 Stryker

阶段收尾的 PR 就绪门禁:
  ① 全量基线跑 1 次(整项目仅这一次)
  ② 为每个值得杀死的存活变异体补强测试
  ③ 之后只做聚焦重跑:--mutate <file>:<start>-<end> / --incremental / diff 脚本
  ④ 最多再来 1 次全量做最终验证,交出一份"已分诊"的报告

原因很实际:变异测试的成本随代码库增长,每次提交都跑会让反馈环越来越慢。门禁内最多两次全量跑(基线 + 最终验证),其余检查全部聚焦到具体文件或行范围,比如:

pnpm exec stryker run --incremental --force --mutate src/checkout.ts:27-32

一个容易浪费时间的细节:基线跑完的存活列表已经打印在报告里,不要为了"再看一眼"重跑全量——把第一次输出存下来(tee 或 json reporter)慢慢分诊。

新手实践清单

把上面的内容压缩成一张可操作的清单:

  1. 装:@stryker-mutator/core + 对应测试运行器插件(vitest/jest/mocha),配置里 testRunner 指向项目真实运行器;
  2. 划范围:mutate 只覆盖生产代码;把 .stryker-tmp/ 和报告目录加进 .gitignore;
  3. 跑基线:第一次不写 break 阈值,记下分数;
  4. 分诊:每个存活变异体先问"它等价吗?"——没有任何输入能产生不同行为,就记为 Equivalent 并写一行理由;
  5. 杀:为剩下的存活体写最小行为测试(断言可观察结果,不迎合实现细节),再聚焦重跑到全灭;
  6. 固化:加 mutation / mutation:incremental / mutation:diff 三条脚本,把 diff 跑放进 PR 门禁。

💡 想感受"弱测试"被揭穿的完整过程,可以直接对照仓库里的两份评估文件:mutation-testing-quality.yaml 描述了两个教学案例("覆盖率 100% 却放走了线上 bug"与"从零搭起门禁"),mutation-testing-assertions.js 则用可执行断言规定了什么叫"通过"。

常见问题(FAQ)

Q:变异分数(Mutation Score)要追求 100% 吗? 不要把它当虚荣指标。优先处理改动区域和高风险代码(金额、权限、资格判定、数据丢失)里的存活变异体,整体分数作为趋势信号即可。

Q:Equivalent 变异体算失败吗? 不算,但必须显式点名并说明理由。只列清单不做分类,不是"已分诊"的报告。

Q:没有 Stryker 能跑吗? 可以手工做:挑一个边界条件手动改动、跑测试、记录、还原。手工变异更慢,但适合教学或工具覆盖不到的场景,流程与自动跑一致。

Q:和覆盖率报告冲突吗? 不冲突。覆盖率是"执行"的度量,变异测试是"检测"的度量。先有覆盖率,再用变异测试回答"这些被执行的行,断言真的在保护行为吗?"

小结

回到开头的问题:为什么 100% 覆盖率仍有 Bug? 因为断言弱——toBeTypeOf("number") 和 toBeGreaterThan(0) 测不出任何具体行为,覆盖率数字依然漂亮。而 .dotfiles 仓库给出的答案是:用 mutation-testing 技能 在 PR 就绪时跑一次 Stryker 门禁,把 20 个存活变异体逐一"杀死或分类",让测试套件真正具备"抓虫"能力。覆盖率证明代码被执行,变异测试证明测试守得住行为——两者缺一不可。

登录后查看全文
.dotfiles