变异测试
自动变异生产代码并检查测试套件是否抓住每个变异。
为什么
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)使仅变更代码中的变异体被重分析,运行时间从小时降到分钟。
- ✓每个存活变异体有意识决定:加强测试(首选)、标等价(若真等价)、或接受为低风险——绝不静默忽略存活者。
- ✓每周或每发布跑变异测试并追踪分数趋势——下降分数意味着新代码的断言比基线弱。