变异测试入门:我跑了 8 个开源项目,测试有效率平均只有 23%

10 阅读6分钟

开头:一个让人后背发凉的场景

上个月我用 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变异数被杀有效性
1browserify/events1.4k1010100%
2sindresorhus/slugify2.7k10660%
3jonschlinkert/is-number28610220%
4browserify/sha.js2981000%
5crypto-browserify/createHash54800%
6chalk/chalk23.3k1000%
7ljharb/object.assign1061000%
8browserify/parse-asn1201000%
平均≈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%

仓库:github.com/moqinghua20…

它很简单(只支持 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 链接有效
  • 标题不夸张