开头:一个让人后背发凉的场景
上个月我用 AI 写了一个功能,顺手让它把测试也写了。
3 个测试全绿,覆盖率 100%,CI 通过。我合并了。
两周后线上出了个 bug。我回去看测试,发现——
如果我把源码改坏,那 3 个测试还是全绿。
举个例子。源码是:
function isAdult(age) {
return age >= 18;
}
AI 写的测试是:
test('isAdult works', () => {
expect(isAdult(20)).toBe(true);
});
测试通过。覆盖率 100%。一切正常。
现在我把 >= 改成 >:
function isAdult(age) {
return age > 18;
}
测试还是绿的。
因为 20 岁在两种写法下都返回 true。而 18 岁的人,会拿到错误的答案,没有任何测试能发现。
这就是我说的「假绿」:看起来测了,实际上没测住。
一、为什么现有工具发现不了这个问题
我们平时判断「测试好不好」,看三个东西:
| 工具 | 它回答的问题 | 它不回答的 |
|---|---|---|
| Jest / Vitest | 测试跑过了吗 | 测试有效吗 |
| Coverage | 代码被执行了吗 | 执行了但断言对吗 |
| ESLint | 代码风格对吗 | 逻辑对吗 |
| TypeScript | 类型对吗 | 运行时行为对吗 |
覆盖率 100% 只能说明「每行代码都被执行过」,不能说明「每行代码的改动都能被发现」。
这两件事差别有多大?我拿真实项目测了一下。
二、变异测试是什么
原理很简单,四步:
① 找到源码里的"可变异点"(比较运算符、布尔值、逻辑运算符)
② 逐个改坏
③ 跑测试
测试变红 ⇒ "被杀" ⇒ 测试有效 ✅
测试还绿 ⇒ "存活" ⇒ 测试有洞 ❌
④ 恢复源码(每轮都恢复,不留残留)
最后算一个数:
测试有效性 = 被杀数 / 总变异数
这个数说明:你改坏 100 处代码,测试能抓到几处。
90%⇒ 测试是可信的50%⇒ 一半的改动测试看不见20%⇒ 测试基本是摆设
三、我跑了 8 个开源项目
★ 数据说明:下面是真实跑出来的结果。每行「变异数」= 随机抽的代码改动数,「被杀」= 测试抓到的数。 复现方法见文末附录。
| # | 项目 | Star | 变异数 | 被杀 | 有效性 |
|---|---|---|---|---|---|
| 1 | browserify/events | 1.4k | 10 | 10 | 100% |
| 2 | sindresorhus/slugify | 2.7k | 10 | 6 | 60% |
| 3 | jonschlinkert/is-number | 286 | 10 | 2 | 20% |
| 4 | browserify/sha.js | 298 | 10 | 0 | 0% |
| 5 | crypto-browserify/createHash | 54 | 8 | 0 | 0% |
| 6 | chalk/chalk | 23.3k | 10 | 0 | 0% |
| 7 | ljharb/object.assign | 106 | 10 | 0 | 0% |
| 8 | browserify/parse-asn1 | 20 | 10 | 0 | 0% |
| 平均 | ≈23% |
★ 最扎眼的两行:
chalk(2.3 万 star)有效性 0% —— 10 个变异全部存活browserify/events(1.4k star)有效性 100% —— 10 个全部被杀
同样是小工具库,差距从 0% 到 100%。
★ 诚实边界(必须说):
- 只跑了 8 个(不是 10 个),因为 7 个仓库因网络/基线问题跳过
- 每个仓库只随机抽 10 个变异(不是全量)
- 「0%」不代表「测试完全没用」,只代表「这 10 个改动测试都没抓到」
- 变异可能造出语法错误,被当成「被杀」(方向保守,会高估有效性)
四、最典型的两个例子
例子 1:chalk(2.3 万 star)—— 0%
chalk 是终端颜色库,几乎所有 Node CLI 都在用。它有 2.3 万 star,测试跑得很快,CI 全绿。
我抽了 10 个变异点:
[1/10] && → || ❌ 存活
[2/10] === → !== ❌ 存活
[3/10] >= → > ❌ 存活
...
10 个全部存活
为什么? 我看了它的测试:大量断言是「函数不抛错」「返回字符串」「包含某个 ANSI 码」。
这些断言对逻辑改动不敏感。 你把 && 改成 ||,只要不崩、返回的还是字符串,测试就过。
这不是说 chalk 的测试写得差 —— 它是颜色库,核心逻辑简单,边界情况本来就不多。但它说明一件事:
「测试全绿」和「测试有效」是两件事。
例子 2:browserify/events —— 100%
events 是 Node 的 EventEmitter 浏览器实现,1.4k star。
同样抽 10 个变异点,全部被杀:
[1/10] && → || ✅ 被杀
[2/10] === → !== ✅ 被杀
[3/10] >= → > ✅ 被杀
...
10 个全部被杀
为什么? 它的测试覆盖了大量边界:
- 监听器数量上限
- 移除监听器
- 错误事件
- 一次性监听器
每改一个逻辑条件,都有对应的测试变红。
★ 这两个例子的对比说明:
同样是小工具库
同样 CI 全绿
一个 0%,一个 100%
⇒ "全绿"完全不能说明"有效"
五、这意味着什么
5.1 覆盖率是及格线,不是保险
覆盖率 100% + 有效性 40%
⇒ 每行代码都跑了,但一半的改动测试看不见
⇒ 这是"假绿"
覆盖率 60% + 有效性 90%
⇒ 有些代码没测,但测了的部分是真的
⇒ 这比上面健康
5.2 AI 时代这个问题更严重
AI 写测试有个特征:只写 happy path。
// AI 常写的
test('discount works', () => {
expect(discount(200)).toBe(180);
});
// 它很少写
test('discount 边界:99 不打折', () => {
expect(discount(99)).toBe(99);
});
因为 AI 没被要求想边界,它只被要求「写个测试」。
5.3 怎么提高
| 方法 | 怎么做 |
|---|---|
| 补边界测试 | 每个 > 想一下「等于」的情况 |
| 跑变异测试 | 定期跑一次,看有效性 |
| 把有效性加进 CI | 低于 60% 就卡 PR |
六、诚实:变异测试也有局限
| 局限 | 说明 |
|---|---|
| 慢 | 每个变异都要跑一次测试 |
| 不测边界值 | 只改代码,不生成新输入 |
| 可能误判 | 变异造出语法错误时,会被当成"被杀" |
| 语言支持有限 | 主流工具只支持几种语言 |
★ 所以它不能替代其他测试,只是补上一个盲区。
七、我把工具开源了
npx ai-test-check ./src
═══ AI 测试有效性检测 ═══
✅ 基线测试通过(可以开始变异)
[1/5] calc.js >= → > ❌ 存活(测试没抓到)
[2/5] calc.js >= → > ❌ 存活(测试没抓到)
[3/5] calc.js && → || ✅ 被杀(测试抓到了)
[4/5] calc.js >= → > ❌ 存活(测试没抓到)
[5/5] calc.js === → !== ✅ 被杀(测试抓到了)
★ 测试有效性: 40%
它很简单(只支持 JS、只变异 6 类、不做 AST),但能让你在 30 秒内知道:你的测试到底测住了没有。
八、一个邀请
如果你跑完发现自己项目的有效性很低,欢迎告诉我。
我更好奇的是:你觉得这个数有用吗? 如果没用,为什么?
—— 因为我不想做一个「看起来很酷但没人需要」的东西。
附:怎么复现
# 1. 装
npm install -g ai-test-check
# 2. 在你的项目里跑
cd your-project
ai-test-check ./src --max=20
# 3. 看结果
# 如果有效性 <60%,说明测试有洞
★ 发布前检查清单(老板审阅用):
- 数据是真实跑出来的(不是编的)
- 项目名可查
- 结论保守("平均 23%" 不说 "都只有 23%")
- 局限写了
- 没有推销语气
- GitHub 链接有效
- 标题不夸张