Skip to content
返回质量保障

变异测试

自动变异生产代码并检查测试套件是否抓住每个变异。

为什么

100% 行覆盖可以用执行每行但不断言任何有意义东西的测试达成——覆盖测的是哪些行跑了,而非测试是否会发现行写错了。变异测试通过改代码(+ 变 -、true 变 false、删条件)再跑测试来修这个问题。如果测试仍通过,变异"存活",意味着测试套件对该变化盲——断言缺失或太弱。它是测测试质量最严格的方式,暴露高覆盖弱断言代码的虚假信心。

何时使用

周期性(每周或每发布)对最关键模块跑变异测试——不是每次保存,太慢。用来审计一个 90%+ 覆盖但仍有回归的套件,找断言空洞处。

如何做

1. 选匹配技术栈的变异工具
   - JS/TS: Stryker JS
   - Java: PIT (Pitest)
   - Python: mutmut 或 cosmic-ray
   - Go: go-mutesting

2. 配置变异器和范围
   - 启用算术(+ → -)、布尔(true → false)、条件、边界变异器
   - 限定业务逻辑目录,非配置或入口点(否则太慢)
   - 每个变异体设超时(如 5s)使挂起的变异体不拖住运行

3. 跑变异分析
   - 工具编译每个变异体、跑测试套件、记录 killed/survived
   - 这是 N × 套件时间,所以从小模块和快套件开始

4. 读变异分数和存活变异体
   - 变异分数 = killed / total;关键模块目标 > 80%
   - 每个存活变异体读 diff 和本该抓住它的测试
   - 大多存活者是缺断言、差一检查或边界条件

5. 通过加强测试修存活者,非弱化变异器
   - 加本该杀死变异体的缺失断言
   - 重跑确认变异体现已 killed
   - 别因产生存活者就禁用变异器——那藏了弱点

示例

// src/lib/pricing.ts
export function finalPrice(base: number, member: boolean): number {
  if (member) return base * 0.9;
  return base;
}

// src/lib/__tests__/pricing.test.ts  (弱——80% 覆盖,0% 变异)
test("会员有折扣", () => {
  finalPrice(100, true);  // 无断言!行跑了,覆盖涨
});

// 运行: npx stryker run
// Stryker 变异 "base * 0.9" → "base * 0.8"、"if (member)" → "if (false)"
// 弱测试调 finalPrice(100, true) 但从不查返回值,
// 所以每个变异体都存活。变异分数: 0%。

// 修: 加断言
test("会员打 9 折", () => {
  expect(finalPrice(100, true)).toBe(90);  // 现在杀死 * 0.8 变异体
});
test("非会员付全价", () => {
  expect(finalPrice(100, false)).toBe(100); // 杀死 if(false) 变异体
});
// 重跑 Stryker → 变异分数: 100%。套件现在真验证行为。

常见问题

  • ⚠一次对整个代码库跑变异测试要数小时并产生数千存活者,压垮团队——从一个关键模块开始,修好再扩范围。
  • ⚠禁用产生存活者的变异器("反正我们不测边界条件")虚高分数而不改善套件——弱点仍在,只是没测。
  • ⚠把变异分数当合并前必须 100% 的门控适得其反——有些变异体等价(变异产生相同行为)无法杀死,现实分数上限约 80-90%。
  • ⚠每个 PR 跑变异测试拖慢 CI 并挫伤开发者——变异测试是审计工具,周期性跑,非每次提交门控。

最佳实践

  • ✓把变异测试限定到缺陷密度最高或最关键业务逻辑的模块——支付模块 90% 变异分数比全仓 60% 更有价值。
  • ✓用 Stryker 增量模式(或 PIT history)使仅变更代码中的变异体被重分析,运行时间从小时降到分钟。
  • ✓每个存活变异体有意识决定:加强测试(首选)、标等价(若真等价)、或接受为低风险——绝不静默忽略存活者。
  • ✓每周或每发布跑变异测试并追踪分数趋势——下降分数意味着新代码的断言比基线弱。