代码智能体很会堆覆盖率、把测试跑成全绿,但断言经常没有杀伤力:边界不碰、错误路径不碰、期望值被写成「有值就行」。单看「参考实现是否通过」会系统性高估这批测试。下面用 Node.js 内置测试跑器和一套受控变异脚本,用杀死率而不是通过率给测试打分,并接到 CI 门禁与失败样本回流;流程不绑定某个模型。

全绿测试在骗谁

覆盖率回答的是「测试有没有执行到这行代码」,通过率回答的是「在当前这一份实现上,断言有没有炸」。两者都不回答更关键的问题:假如实现悄悄错了,测试会不会失败。

智能体生成测试时,常见捷径正好踩在这个盲区上。它会对着一份已经写好的参考实现编程,优先保证 node --test 全绿,于是断言被写成 typeof、notEqual(undefined)、ok(result)。参考实现一跑一个准,换一个差一号的实现,测试仍然全绿。这不是模型「不会写测试」,而是优化目标错了:它在最大化通过率,而不是最大化对故障的敏感度。

所以评测生成测试,不能停在「参考实现通过」。要把实现和断言都放到可复现的扰动下,看测试还能不能揭穿问题。扰动要受控、可枚举、可进 CI,而不是靠一次人工 code review 的手感。

评测口径:杀死率,不是通过率

变异测试的做法很旧,用在生成测试上仍然合适。先固定测试套件,再对生产代码做小而明确的改动(突变体)。测试失败,称这个突变体被杀死;测试仍通过,称它存活。杀死率是被杀死的突变体数量除以有效突变体数量。等价突变体(改了字面、行为不变)应单独列出,不要硬塞进分母里充分子。

针对智能体测试,只做生产代码变异还不够,建议固定三类扰动:

  1. 注入故障:改生产代码的比较符、返回值、异常路径,模拟真实回归。
  2. 翻转条件:把 min > max 改成 >= 或 <,把 < 改成 <=,专门打边界。
  3. 弱化断言:改测试代码,把严格相等改成「非空 / 类型正确」,看套件会不会变成空壳。

第一、二类衡量测试的检出力;第三类衡量断言本身有没有信息量。参考实现必须先全绿,否则后面的杀死率没有意义——那是测试写错了,不是突变体太强。

下面的例子刻意用 Node.js 内置模块:node:test、node:assert/strict、node:fs、node:child_process。不引入测试框架插件,是为了让流水线可复制,而不是绑死在某一版 Stryker 配置上。生产仓库若已有 Stryker,可以把同一套门槛换成 npx stryker run 的 mutation score,口径不变。

被测代码,以及一份「看起来很好」的测试

假设要测一个小函数:把数字限制在闭区间内,区间非法时抛错。把下面两个文件放在同一目录。

// clamp.js
export function clamp(n, min, max) {
  if (min > max) {
    throw new RangeError('min must be <= max');
  }
  if (n < min) return min;
  if (n > max) return max;
  return n;
}
// clamp.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clamp } from './clamp.js';

test('clamp returns a number', () => {
  const result = clamp(5, 0, 10);
  assert.equal(typeof result, 'number');
  assert.ok(result !== undefined);
});

package.json 只需声明 ESM,测试命令走内置跑器:

{
  "type": "module",
  "scripts": {
    "test": "node --test clamp.test.js",
    "mutate": "node scripts/evaluate.mjs"
  }
}

在参考实现上执行 npm test,结果会全绿,覆盖率也不难看:主路径执行到了。但它几乎没有预言能力——返回 0、10 甚至 NaN 以外的数字,typeof 仍然是 number。智能体交付物里,这类测试非常常见:它验证了「函数能调用」,没有验证「函数做对了」。

对照着写一份有杀伤力的测试,不必一次堆几十条,先覆盖值、边界和非法区间:

// clamp.strong.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clamp } from './clamp.js';

test('keeps interior value', () => {
  assert.equal(clamp(5, 0, 10), 5);
});

test('clamps to min and max', () => {
  assert.equal(clamp(-1, 0, 10), 0);
  assert.equal(clamp(11, 0, 10), 10);
  assert.equal(clamp(0, 0, 10), 0);
  assert.equal(clamp(10, 0, 10), 10);
});

test('rejects inverted range', () => {
  assert.throws(() => clamp(1, 10, 0), RangeError);
});

后面流水线会对同一组突变体分别跑这两份测试。你要看的不是谁覆盖率更高,而是谁能把故障打死。

受控变异流水线

把评测写成脚本,输入是「源文件 + 测试文件」,输出是结构化报告。突变体用显式替换表,不在生产代码上原地改写:先拷到临时目录,再跑 node --test。

// scripts/evaluate.mjs
import {
  mkdtempSync, cpSync, readFileSync, writeFileSync, rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const THRESHOLD = 0.8;

const productionMutants = [
  { id: 'range-gt-to-ge', find: 'if (min > max)', replace: 'if (min >= max)' },
  { id: 'range-gt-to-lt', find: 'if (min > max)', replace: 'if (min < max)' },
  { id: 'drop-throw', find: "throw new RangeError('min must be <= max');", replace: '' },
  { id: 'lt-to-le', find: 'if (n < min)', replace: 'if (n <= min)' },
  { id: 'lt-to-gt', find: 'if (n < min)', replace: 'if (n > min)' },
  { id: 'gt-to-ge', find: 'if (n > max)', replace: 'if (n >= max)' },
  { id: 'gt-to-lt', find: 'if (n > max)', replace: 'if (n < max)' },
  { id: 'return-min-as-max', find: 'if (n < min) return min;', replace: 'if (n < min) return max;' },
  { id: 'return-n-as-min', find: 'return n;', replace: 'return min;' },
];

const assertionWeakeners = [
  { id: 'equal-to-typeof-number', find: /assert\.equal\(([^,]+),\s*([^)]+)\)/g, replace: 'assert.equal(typeof $1, \'number\')' },
  { id: 'drop-throws', find: /assert\.throws\(([^;]+);/g, replace: '' },
];

function runTests(cwd, testFile) {
  const r = spawnSync(process.execPath, ['--test', testFile], {
    cwd,
    encoding: 'utf8',
  });
  return { ok: r.status === 0, stderr: r.stderr, stdout: r.stdout };
}

function withSandbox(fn) {
  const dir = mkdtempSync(join(tmpdir(), 'mut-'));
  try {
    cpSync(join(root, 'clamp.js'), join(dir, 'clamp.js'));
    return fn(dir);
  } finally {
    rmSync(dir, { recursive: true, force: true });
  }
}

function evaluate(testFile) {
  const baseline = withSandbox((dir) => {
    cpSync(join(root, testFile), join(dir, testFile));
    return runTests(dir, testFile);
  });
  if (!baseline.ok) {
    throw new Error(`${testFile} 在参考实现上未通过,先修测试再评变异`);
  }

  const killed = [];
  const survived = [];
  const invalid = [];

  for (const m of productionMutants) {
    const result = withSandbox((dir) => {
      const src = readFileSync(join(dir, 'clamp.js'), 'utf8');
      if (!src.includes(m.find)) return { skip: true };
      writeFileSync(join(dir, 'clamp.js'), src.replace(m.find, m.replace));
      cpSync(join(root, testFile), join(dir, testFile));
      return runTests(dir, testFile);
    });
    if (result.skip) invalid.push(m.id);
    else if (result.ok) survived.push(m.id);
    else killed.push(m.id);
  }

  const weakStillGreen = [];
  for (const w of assertionWeakeners) {
    const result = withSandbox((dir) => {
      cpSync(join(root, 'clamp.js'), join(dir, 'clamp.js'));
      let testSrc = readFileSync(join(root, testFile), 'utf8');
      const next = testSrc.replace(w.find, w.replace);
      if (next === testSrc) return { skip: true };
      writeFileSync(join(dir, testFile), next);
      return runTests(dir, testFile);
    });
    if (!result.skip && result.ok) weakStillGreen.push(w.id);
  }

  const valid = killed.length + survived.length;
  const score = valid === 0 ? 0 : killed.length / valid;
  return { testFile, killed, survived, invalid, weakStillGreen, score, valid };
}

const reports = ['clamp.test.js', 'clamp.strong.test.js'].map(evaluate);
writeFileSync(join(root, 'mutation-report.json'), JSON.stringify(reports, null, 2));

for (const r of reports) {
  console.log(
    `${r.testFile} 杀死率 ${(r.score * 100).toFixed(1)}% ` +
    `(${r.killed.length}/${r.valid}) 存活: ${r.survived.join(', ') || '无'} ` +
    `弱断言仍绿: ${r.weakStillGreen.join(', ') || '无'}`,
  );
}

if (reports.some((r) => r.testFile.includes('strong') && r.score < THRESHOLD)) {
  process.exit(1);
}

对弱测试,上面这组故障里大部分会存活:唯一的调用是 clamp(5, 0, 10),区间合法、值在内部,改比较符、丢掉 throw、甚至把 return n 改成 return min(得到 0)都过不了 typeof === 'number' 这一关。这不是突变体「太弱」,是断言没有锁定行为。强测试会打死翻转比较、改返回值、丢掉非法区间异常这类故障;lt-to-le / gt-to-ge 在闭区间语义下可能是等价突变,脚本把它们留在 survived 里,报告里应人工 squint 一眼,不要为了刷分去加「n === min 时必须走另一条分支」这种和规格无关的断言。

断言弱化是另一面镜子。如果把 assert.equal(clamp(5, 0, 10), 5) 换成「结果是 number」,参考实现仍然全绿,说明这条断言的信息量可以被抽空。生成测试评测时,应同时看:生产突变体杀死率低,或断言被弱化后套件毫无反应——两种都算质量不合格。

替换表要写死、要短。不要在评测脚本里做随机 AST 变形,否则同一天两次 CI 不可比。真要上规模,再用 Stryker 的确定性 mutator 集合,并把 mutate 范围限制在被测模块,避免把测试文件自己变异进去。

接到 CI,把存活样本回流

门禁只认报告,不认「模型说测试已经很好」。把 mutation-report.json 当制品;弱测试允许低分(它是对照基线),强测试或「本次智能体提交的测试文件」必须过门槛。门槛按模块定,不要从本例的玩具分母直接抄到业务仓库。

# .github/workflows/mutation.yml
name: mutation
on: [push, pull_request]
jobs:
  score:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - name: baseline
        run: node --test clamp.strong.test.js
      - name: mutants
        run: node scripts/evaluate.mjs
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: mutation-report
          path: mutation-report.json

回流比门禁更有用。把 survived 写成给下一轮生成用的材料,而不是再喂一遍「请提高覆盖率」。例如:return-n-as-min 存活,就明确要求补「区间内部的值必须原样返回」;drop-throw 存活,就要求非法区间必须抛 RangeError。提示里附上突变后的源码片段和「测试仍通过」这一事实,比空泛的「加强边界用例」有效。

有两点克制着写进流程。第一,评测用的突变体集合要版本化,和测试生成器解耦;换模型、换提示,只要杀死率上升,才说明测试变好,而不是突变体变少了。第二,不要用「再生成直到全绿」去拟合这一组突变体,否则测试会过拟合替换表,杀死率失去外推意义。突变体是抽检,不是新的规格说明书。

若仓库已经用 Jest 或 Vitest,不要为了评测再维护第二套用例。保持测试文件不动,只换执行器:评测脚本的 runTests 改成 npx vitest run --dir <sandbox> 即可。Stryker 适合回归阶段做全量 mutator,本文这种显式替换表适合给生成测试打分——范围小、失败可读、回流时能把突变 ID 对上提示词。

总结

全绿和覆盖率检查的是测试有没有跑通参考实现,变异杀死率检查的是测试能不能揭穿偏离。智能体生成的断言经常在前一项上表现很好,在后一项上接近于零。用 Node.js 内置测试跑器加一份显式突变表,就能得到可复现的分数:生产代码注入故障和翻转条件,衡量检出力;弱化断言,衡量预言强度。分数进 CI,存活突变体进下一轮生成材料,整条流水线不依赖某次生成结果。先让参考实现通过,再看突变体是否被杀死;两步缺一,都会把测试质量测偏。