撤掉那行改动,测试必须变红:给 agent 写的测试装一道 3 分钟的验证

2026-10-19 Skills测试AI agent工程实践

我见过最难受的一次,是 agent 一口气给一个模块补了 40 条测试,覆盖率从 31% 涨到 88%,全绿。两周后那个模块出了个线上问题,回滚之后这 40 条测试照样全绿。

不是它写错了语法,是它写的测试没有在测东西。

最常见的三种:断言写反(expect(result).toBeDefined() 但 result 恒不为 undefined);断言了一个和改动无关的量;测试里 mock 掉了被测函数本身,等于在测 mock。这三种覆盖率工具一个都发现不了,因为它们只看”这行代码被跑过没有”。

唯一靠谱的验证:让它先红

判断一条测试有没有在测东西,办法只有一个——把被测的改动撤掉,看它会不会失败。会失败,说明它盯着这个改动;不会,它就是装饰品。

这就是变异测试(mutation testing)的最小版本,不用装任何工具,一条 git 命令就能跑。

脚本

// scripts/red-test.mjs
// 用法:node scripts/red-test.mjs "npm test" src/foo.ts src/bar.ts
// 含义:把这几个文件的未提交改动撤掉,跑测试,测试必须失败。
import { execSync } from 'node:child_process';

const [cmd, ...files] = process.argv.slice(2);
if (!cmd || !files.length) {
  console.error('usage: node scripts/red-test.mjs "<test command>" <file...>');
  process.exit(1);
}

const run = (c) => execSync(c, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });

// 1. 工作区必须是干净的,否则 stash/pop 会把你的活儿弄乱
const dirty = run('git status --porcelain').trim();
if (dirty) {
  console.error('工作区不干净,先 commit 或 stash:\n' + dirty);
  process.exit(1);
}

// 2. 只撤掉源码文件的改动,测试文件本身留着
const srcFiles = files;
console.log(`撤掉改动:${srcFiles.join(' ')}`);
run(`git stash push -- ${srcFiles.join(' ')}`);

// 3. 跑测试,只看退出码
let passed = false;
let out = '';
try {
  out = run(cmd);
  passed = true;
} catch (e) {
  out = (e.stdout || '') + (e.stderr || '');
  passed = false;
} finally {
  run('git stash pop');
}

// 4. 撤掉改动后测试还通过 = 这条测试没在测这次改动
if (passed) {
  console.error('❌ 改动撤掉后测试仍然通过,这条测试没有在保护任何东西');
  process.exit(2);
}
console.log('✅ 改动撤掉后测试失败,说明它盯着这次改动');
console.log(out.split('\n').filter((l) => /fail|✕|✗|Error/i.test(l)).slice(0, 10).join('\n'));

跑法:把 agent 的改动 commit 到一个临时分支,然后 node scripts/red-test.mjs "npm test" src/xxx.ts。脚本自己会 stash 回去,跑完再 pop。

一次只撤一个文件

整包一起撤,只要有一条测试变红就算过了,剩下的照样没人管。正确做法是每个文件单独跑一次,把”撤掉 A 文件,哪条测试红了”记下来。一个文件对应不到任何一条测试,那这个文件的改动就是裸奔的。

agent 改了 8 个文件时,这件事大概 3 分钟。比起上线后回滚,便宜得离谱。

三种情况要人来判断

这个检查不是万能的,有三种结果需要你拍板:

  • 纯新增文件:撤掉之后文件不存在,测试可能直接编译失败而不是断言失败。这也算”红了”,但你要确认它红的理由对。
  • 重构类改动:重构按定义不改变行为,这时候测试不该变红。不变红是对的,别误杀。
  • 配置和资源文件:改个配置值测试可能本来就不覆盖,这类改动本来就应该靠别的方式验证。

怎么知道自己做对了

5 条,全过才算装完:

  1. 脚本对你最近一次 agent 改动跑过,并且至少有一条测试真的变红了。 一次都没红过,说明要么你没跑,要么你的测试全是装饰品。
  2. 每个被改的源码文件都单独验证过一次。 只跑整包的不算——那等于没做。
  3. 变红的测试名被记进了提交信息或 PR 描述。 写下来,将来回看时才知道当时保护的是什么。
  4. 这套检查挂进了 CI 或者 pre-push 钩子。 手动跑两次就放弃的做法,等于没有。
  5. 你能回答”删掉这个改动,哪条测试会失败”。 答不上来的测试,要么改到能答上来,要么删掉——留着只是让覆盖率好看。

几个坑

  • 工作区必须是干净的。 脚本第一步就在查这个,别跳过。stash/pop 在脏工作区上会把你的活儿弄乱,这是唯一可能让你丢代码的地方。
  • 别把测试文件一起撤了。 只传源码文件给脚本,测试文件要留着跑。传错了就是你把测试删了再跑测试,必然”通过”。
  • 绿了不一定是好事。 一个模块撤掉改动后所有测试都还绿,说明这个模块的测试整体失效了,不是某一条的问题。
  • 别指望 agent 自己发现。 它写完测试会告诉你”已添加 12 条测试,全部通过”。通过是它的验收标准,不是你的。你的标准是”撤掉改动它会红”。

最后一句

这条检查的价值不在于抓 bug,在于逼出一个具体答案:你这次改动,到底被什么保护着。

一个人开发没有 code review,这条 3 分钟的动作就是最便宜的那道 review。它买到的不是安全感,是一个能说出口的事实——“删掉这行,userService.test.ts 的第 47 行会红”。