你的测试全过了。覆盖率报告显示87%。但你的变异得分是40%,一半的变异体还活着。

这个40%不代表你的代码有毛病。它代表你的测试有毛病。覆盖率衡量的是测试运行期间哪些行被执行了。变异测试衡量的是,如果那些行开始做错事,你的测试会不会发现。40%的变异得分意味着,60%本可以被引入代码的 bug 会大摇大摆地通过 CI。

存活变异体到底是什么

存活变异体是一个小而人造的 bug,你的测试没能抓住它。

变异测试工具的工作原理是:获取你的源代码,逐一对其应用一组预定义的转换。它们可能把 > 翻成 >=,把 + 改成 -,或者把一个布尔条件替换成 true。每一次转换后的代码版本就是一个变异体。工具会针对每个变异体运行你的测试套件。如果有任何测试失败,这个变异体就被“杀死”。如果所有测试都通过,这个变异体就“存活”下来。

一个存活变异体意味着两件事之一。要么你的测试实际上并没有验证被变异体破坏掉的行为,要么这个变异体是“等价”的(转换产生了语义完全相同的代码,这是变异测试中一个已知的难题)。

大多数存活者都不是等价变异体。大多数是行走的活死人。

一个具体例子:密码校验器

下面是一个检查密码是否符合策略要求的函数:

// password.js
function isValidPassword(password) {
  if (password.length < 8) {
    return false;
  }
  if (!/[A-Z]/.test(password)) {
    return false;
  }
  if (!/[0-9]/.test(password)) {
    return false;
  }
  return true;
}

module.exports = { isValidPassword };

下面是一套能给你100%行覆盖率的测试:

// password.test.js
const { isValidPassword } = require('./password');

test('accepts a valid password', () => {
  expect(isValidPassword('Hello1')).toBe(true);
});

test('rejects a short password', () => {
  expect(isValidPassword('Hi1')).toBe(false);
});

test('rejects a password without uppercase', () => {
  expect(isValidPassword('hello1')).toBe(false);
});

test('rejects a password without a digit', () => {
  expect(isValidPassword('Hellooo')).toBe(false);
});

等等。isValidPassword('Hello1') 返回 true,但 'Hello1' 只有六个字符。第一个检查本应该拒绝它。测试是错的,但它通过了,因为测试本身断言的就是错误的行为。

像 Stryker 这样的变异测试工具会抓住这一点。它的其中一个变异会把长度检查中的 < 翻成 <=。这个变异体会存活下来,因为现有测试实际上并没有验证8个字符的边界。另一个变异可能会删除整个第一个 if 块。这个变异体也会存活,因为测试中没有包含一个八字符长但既没有大写字母也没有数字的密码。长度上限从未与其他规则组合测试过。

下面是一套真正能杀死这些变异体的测试:

// password.test.js
const { isValidPassword } = require('./password');

test('rejects password shorter than 8 chars', () => {
  expect(isValidPassword('Hello1')).toBe(false);
});

test('accepts password exactly 8 chars with uppercase and digit', () => {
  expect(isValidPassword('Hello1!@')).toBe(true);
});

test('rejects password without uppercase', () => {
  expect(isValidPassword('hello1!@')).toBe(false);
});

test('rejects password without digit', () => {
  expect(isValidPassword('Helloooo')).toBe(false);
});

test('rejects password missing both uppercase and digit', () => {
  expect(isValidPassword('helloooo')).toBe(false);
});

现在8个字符的边界被显式测试了。<= 变异体失败,因为 'Hello1!@'(8个字符)必须被接受。删除变异体失败,因为如果没有长度检查,'helloooo' 会漏过去。

变异测试在底层究竟是怎么工作的

变异测试的计算成本很高,因为它要为每个变异体跑一遍完整的测试套件。

如果你的代码库有10,000行,变异工具生成了3,000个变异体,那就是3,000次测试套件运行。早期的学术实现因此基本上无法在真实代码库上使用。现代工具变聪明了。

Stryker,JavaScript 和 TypeScript 中使用最广泛的变异测试框架,使用了几种优化手段:

  1. 变异体作用域:Stryker 只运行那些可能到达被变异代码行的测试子集,依据来自初始 dry run 的覆盖率数据。

  2. 并行执行:变异体在多个工作进程中并行评估。

  3. 增量模式:Stryker 会缓存结果,只对自上次运行以来发生变更的代码重新评估变异体。

  4. 检查器:对于编译型语言,Stryker 可以在 AST 层面验证变异体,而无需重新编译整个项目。

即便有了这些优化,在大型代码库上跑完整变异测试仍然可能需要10-30分钟。这就是为什么大多数团队只在 CI 的 pull request 或 nightly build 中运行变异测试,而不是每次保存都跑。

没人告诉你的权衡

变异测试不是免费的,也不总是对的工具。

等价变异体问题是最大的理论限制。有些变异并不改变可观察行为。考虑一下:

const timeout = 1000 * 60;

把它改成 1000 * 61 的变异在语义上是不同的。但把它改成 60 * 1000 的变异是等价的。没有测试能杀死它,因为值完全一样。在一般情况下,区分等价变异体和真正的存活变异体是不可判定的。现代工具用启发式方法跳过明显的等价情况,但你仍然会看到一些。

性能是真实存在的。 在一个中等规模的 TypeScript 项目上,Stryker 可能生成2,000个变异体,花15分钟来评估它们。如果你在 pull request 中启用它,那就是每次运行15分钟的 CI 时间。团队通常会先设置一个阈值(比如,如果变异得分低于60%就让构建失败),然后每晚跑完整分析。

虚假自信是双刃剑。 100%的变异得分不代表你的代码没有 bug。它只意味着,与该工具的变异算子匹配的 bug 没有一个能漏过去。变异测试无法发明它不知道如何创建的 bug。它不会抓住需求中的逻辑错误、无法模拟的竞态条件,或者跨服务边界的集成故障。

如何真正开始使用变异测试

如果你写 JavaScript 或 TypeScript,Stryker 是入门的地方。

安装它:

npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner

创建 stryker.config.mjs

// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  packageManager: 'npm',
  reporters: ['html', 'clear-text', 'progress'],
  testRunner: 'jest',
  coverageAnalysis: 'perTest',
  mutate: ['src/**/*.js'],
  threshold: {
    break: 60,
  },
};

export default config;

运行它:

npx stryker run

从查看 HTML 报告开始,而不是盯着分数。报告会把每个存活变异体内联显示在你的源代码中。通读前十个存活者。对每一个,问自己:如果这个位置出现真正的 bug,会导致生产问题吗?如果是,写一个能抓住它的测试。如果否,考虑一下这段代码是否过度设计了。

不要追求100%。在一个成熟的代码库上,70-80%就是一个不错的分数。低于50%,你的测试很可能只是在执行代码而没有做任何有意义的断言。高于90%,你可能正在遭遇边际递减和不断增长的等价变异体税。

怎么对待你的40%

40%的变异得分是一份礼物。它精确地告诉了你,你的测试在哪里只是装饰。

挑出存活变异体最多的三个文件。阅读每个存活者,问问缺少了什么断言。修复往往很简单:你在测试中调了一个函数但从未检查返回值。或者你把数据传过一个解析器但从未验证解析后的输出。或者你用不同输入把 happy path 测了三遍,但从未测过错误分支。

变异体不是噪音。它们是一个按可能性排序的列表,列出了未测试 bug 最可能藏身的地方。从顶部开始。


FAQ

代码覆盖率和变异测试有什么区别? 代码覆盖率衡量哪些行被执行了。变异测试衡量的是,如果这些行包含 bug,你的测试是否会失败。100%覆盖率加40%变异得分意味着你跑过了每一行,但即使大部分行是错的,你的测试也不会发现。

变异测试能在我现有的代码中找到 bug 吗? 不能。变异测试评估的是你的测试,而不是源代码。它告诉你测试在哪些地方不足。它不告诉你代码是否正确,只告诉你测试是否能抓住某些类别的错误。

哪些语言有好的变异测试工具? JavaScript/TypeScript(Stryker)、Java(PIT)、C#(Stryker.NET)、Python(mutmut)和 Rust(cargo-mutants)都有成熟的工具。生态系统在性能和支持的变异算子方面各有不同。

变异测试应该取代代码覆盖率吗? 不应该。覆盖率便宜且快速。在开发过程中用它获取快速反馈。把变异测试当作一个定期的质量关卡,用来发现覆盖率看不到的盲区。