你的 mutation testing 报告里全是 survivor,其中至少有一个你完全看不懂。

工具说它把第 47 行的 > 换成了 >=,或者把整个条件块替换成了 true,又或者变异了一个你根本不知道正在被测试的字符串字面量。你 diff 看了三遍。你还是不明白这个 mutant 破坏了什么行为,也不知道该写什么测试来捕获它。于是你跳过了。mutant 活了下来。你的分数依旧很低。

这是 mutation testing 推行不下去最常见的原因。不是运行时间问题,不是 equivalent mutants。而是工程师盯着一个 survivor,无法把它映射到缺失的测试,然后断定 mutation testing 只是噪音。

它不是。你只是需要一个不同的切入点。

问题所在:你从 mutation 出发,而不是从代码出发

大多数开发者处理 surviving mutant 的方式是反过来的。他们先读 mutation diff,试图理解引入了什么样的 synthetic bug,然后再绞尽脑汁想一个能捕获这个特定 bug 的测试。

对明显的情况有效。对任何细微之处都失效。

这个 mutation 可能在嵌套了三层调用的 helper 函数里。它可能影响了一个你不知道存在的 side effect。它可能在生成代码或框架回调里。diff 告诉你 什么 变了,但没告诉你 为什么 现有测试不在乎。如果你从解码 mutation 开始,你就是在对 synthetic code 做逆向工程。即使对经验丰富的工程师来说,这也很难。

更好的方法是彻底忽略 mutation,把 survivor 当作关于你代码的信号,而不是关于 synthetic bug 的信号。

一个 Surviving Mutant 只是一行你的测试没有验证的代码

每一个 surviving mutant 都指向一行在测试中执行过、但其输出或 side effects 从未被断言的代码。

mutation 可能是什么都行。它存活下来只说明一件事:如果那行代码产出了错误的结果,你的测试仍然会通过。你不需要理解具体的 mutation 来修复这个问题。你需要理解那行代码应该做什么,然后写一个测试来检查它是否做到了。

这种重新框定把问题从逆向工程 synthetic diffs 变成了普通的测试设计。

方法:从代码行倒推,而不是从 mutation 正推

这里有一个四步流程,适用于任何 surviving mutant,无论 diff 看起来多么令人困惑。

第一步:找到 mutation 触及的确切代码行

你的 mutation testing 工具的 HTML report 会在源代码中内联显示被变异的代码行。打开那个文件,找到原始代码行,不是 diff。

举个例子,假设 Stryker 报告这个函数里有一个 survivor:

// pricing.js
function calculateDiscount(price, customer) {
  if (customer.loyaltyYears > 5) {
    return price * 0.85;
  }
  if (customer.isStudent) {
    return price * 0.90;
  }
  return price;
}

module.exports = { calculateDiscount };

mutation 把第一个条件里的 > 改成了 >=。这个细节可能会让你困惑。暂时忘掉它。代码行是 if (customer.loyaltyYears > 5)

第二步:问这行代码应该强制什么规则

不要去想 mutation。去想业务规则。

这行代码应该检查一个客户是否忠诚了五年以上。如果是,他们获得 85 折优惠。边界很重要。恰好五年的客户不应该获得这个折扣。六年的客户应该。

现在看看现有的测试:

// pricing.test.js
const { calculateDiscount } = require('./pricing');

test('returns full price for new customers', () => {
  expect(calculateDiscount(100, { loyaltyYears: 0 })).toBe(100);
});

test('gives loyalty discount to long-term customers', () => {
  expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});

test('gives student discount to students', () => {
  expect(calculateDiscount(100, { isStudent: true })).toBe(90);
});

这些测试覆盖了第一个 if 语句的两个分支。但它们没有测试边界。loyaltyYears: 5 从未出现。这就是 >= mutant 存活的原因。工具发现了一个你不知道存在的缺口。

第三步:写一个如果这行代码错了就会失败的测试

你不需要写一个能杀死这个特定 mutation 的测试。你需要写一个如果业务规则被违反就会失败的测试。

// pricing.test.js
test('does not give loyalty discount at exactly 5 years', () => {
  expect(calculateDiscount(100, { loyaltyYears: 5 })).toBe(100);
});

test('gives loyalty discount at 6 years', () => {
  expect(calculateDiscount(100, { loyaltyYears: 6 })).toBe(85);
});

现在边界是显式的。如果有人把 > 改成了 >=,第一个测试就会失败,因为恰好五年的客户会错误地获得折扣。mutant 死了。你从头到尾都不需要理解 synthetic diff 里的 >= 意味着什么。

第四步:重新运行 mutation test 并确认

只在当前文件上运行你的 mutation tool,或者如果你有耐心就运行完整套件。survivor 应该消失了。如果还在,说明你的测试实际上并没有执行到你以为它在执行的那行代码。检查 coverage data 确认一下。

当代码行本身令人困惑时

有时候被变异的代码行在一个 library wrapper、框架 hook 或你没写的生成代码里。在这种情况下,survivor 在告诉你另一件事:你的代码库里有没人足够了解、因而无法测试的代码。

这不是 mutation testing 的问题。这是 mutation testing 暴露出来的代码质量问题。

你的选择和没有 mutation testing 时一样:重构代码直到它有一个可测试的表面,或者接受这段代码未经测试并如此标记。有些工具允许你忽略特定代码行或文件。谨慎使用这个权力。每一个被忽略的 mutant 都是一个可能上线的 bug。

棘手情况:改变 Side Effects 的 Mutations

边界检查很简单。Side effects 更难。

考虑这个函数:

// logger.js
function logError(error, context) {
  const timestamp = new Date().toISOString();
  console.error(`[${timestamp}] ${context}: ${error.message}`);
  metrics.increment('error.count');
}

module.exports = { logError };

一个 mutation testing 工具可能会把整个 console.error 调用替换成空,或者把字符串模板替换成空字符串。如果你的测试不验证日志输出,这些 mutant 就会存活。

大多数团队不测试 logging。这通常没问题。但如果你的日志被 alerting 系统消费,或者 metrics.increment 驱动着一个会通知 on-call 的 dashboard,那么跳过这些测试就是有风险的。

方法是一样的。不要研究 mutation。问这行代码应该产生什么行为。如果答案是”一条带时间戳的结构化日志条目”,就写一个断言日志输出的测试:

// logger.test.js
const { logError } = require('./logger');

test('logs error with timestamp and context', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  logError(new Error('db timeout'), 'payment-service');
  expect(spy).toHaveBeenCalledWith(
    expect.stringMatching(/\d{4}-\d{2}-\d{2}T.*payment-service.*db timeout/)
  );
  spy.mockRestore();
});

删除 console.error 调用的 mutant 现在会失败,因为 spy 检测不到调用。破坏字符串模板的 mutant 会失败,因为 regex 不匹配。你不需要理解其中任何一个 mutation。

为什么这个方法比研究 Mutations 更具扩展性

可能的 mutations 有无限多种。你的代码应该具备的行为是有限的。

如果你试图写测试来杀死特定的 mutations,你就是在和 synthetic bugs 玩打地鼠。如果你写测试来验证代码的实际行为,mutations 会作为副作用死去。第二种方法是可持续的。第一种不是。

这也是你避免写出与 mutation tool 过度耦合的测试的方法。一个断言第 47 行用了 > 的测试是脆弱的。一个断言五年客户付全价的测试是正确的。

局限性:Equivalent Mutants 依然存在

这个方法对 equivalent mutants 没用,因为 equivalent mutants 不代表缺失的测试。它们代表的是产生相同行为的变换。

如果一个 mutation 在交换律运算里把 a + b 改成了 b + a,没有任何测试能杀死它。没有什么缺失的行为可以断言。这些是 false positives,每个 mutation testing 工具都有。学会识别它们,忽略它们,继续前进。不要让 2% 的 equivalent-mutant 噪音底让你觉得另外 98% 也是噪音。

从最差的三个文件开始

如果你的 mutation score 很低,有几十个 survivor,不要试图全部理解。挑出 survivor 最多的三个文件。对每个文件,挑出最可疑的三行代码。对每一行应用这个方法。

一小时内,你会写出九个让你的代码库更正确的测试。重新运行 mutation testing。你的分数会跳升。更重要的是,你会比从前更理解自己的代码。

Mutants 不是在要求你理解它们。它们在要求你理解你的代码。


常见问题

我需要理解 mutation operator 才能写测试吗? 不需要。mutation operator 是一种干扰。专注于原始代码行应该做什么。为那个行为写一个测试。mutant 会作为副作用死去。

如果被变异的代码行在一个我无法直接测试的私有函数里怎么办? 那是一个设计信号。如果一个函数有值得测试的行为,它就应该是可测试的。要么为了测试暴露它,要么通过调用它的 public API 来测试它。如果 public API 测试无法触及那个行为,那个行为可能是 dead code。

我应该杀死每一个 surviving mutant 吗? 不应该。有些 mutant 触及 logging、metrics 或其他可观测性代码,测试的成本超过价值。为你的代码库设置一个合理的阈值,把精力集中在业务逻辑里的 mutant 上。

如果我的测试杀死了 mutant 但感觉还是不对怎么办? 相信那种感觉。一个碰巧杀死了 mutant 但没有清晰断言业务规则的测试是技术债务。重写它,用领域语言表达预期行为,而不是测试语言。