幕 IV · 谎言与陷阱CH 17进度 17/24

那个 97% 通过的测试:抖动从哪来

一个测试稳定地失败,是好事 —— 它在告诉你有 bug,你去修就是了。真正让人崩溃的,是那种时绿时红的测试:你代码一个字没改,重跑一次,它就从红变绿了。这种测试叫 flaky(抖动)。它 97% 的时候是绿的,于是你渐渐信任它、依赖它;可它的红,和你的代码对不对毫无关系。这一章讲一件反直觉的事:一个 flaky 测试,往往比没有这个测试更糟。因为它会一点点腐蚀掉整个团队对测试的信任 —— 而信任,是测试唯一的价值来源。

什么是 flaky信任的腐蚀四个来源重试是止痛片确定性

flaky:同样的代码,不一样的结果

flaky 测试的定义就一句话:在代码不变的情况下,它的结果不确定 —— 有时绿,有时红。它违反了 FIRST 里的 R(可重复,第 5 章)。

下面这个 Demo 模拟了一个 flaky 测试,让你亲手跑 100 次,看它的通过率。选一个抖动来源,点「跑 100 次」。

这个 Demo 有个小小的自嘲:它本身就是 flaky 的。每点一次「跑 100 次」,通过的数字都不一样 —— 可能 96,可能 98,偶尔 100。这恰恰是 flaky 测试最阴险的地方:因为它经常是绿的,你很容易某次刚好看到 100/100,就以为它没问题,把它合并了。然后明天,它在别人一个毫不相关的 PR 上红了。

为什么 flaky 比失败更坏

这是本章最重要的一点,值得说透:

◆ 判词 · flaky 测试腐蚀的是「红灯」这个信号本身

测试的全部价值,建立在一个约定上:红灯 = 有问题,必须停下来看。这个约定一旦成立,红灯就是一个强信号,团队会认真对待它。

flaky 测试专门破坏这个约定。它红了,但十有八九不是真 bug(是它自己抽风)。于是团队学会了一件致命的事:看到红灯,先重跑一次再说。而一旦"重跑一次"成了条件反射,红灯就不再是信号了 —— 下次一个真 bug 让测试红了,大家也只会习惯性地重跑、忽略。「狼来了」喊多了,狼真来的时候没人信。

一个 flaky 测试,毒的不只是它自己,是整套测试的可信度。这就是为什么它比"根本没有这个测试"更糟。

抖动的四个来源

Demo 里那四个选项,就是 flaky 的四大来源。它们都指向同一个词:非确定性

来源怎么发生怎么治
时序 / 异步sleep 赌一个固定时长,机器一卡就不够(第 12 章)虚拟时钟、等条件不等时间
共享状态 / 执行顺序测试 B 靠测试 A 留下的状态;换个顺序就崩(违反 FIRST 的 I)每个测试自带 setup/teardown,不共享可变状态
真实时钟 / 时区断言里写死"今天";跨零点、跨时区那一刻就错注入固定的 Clock(第 5 章)
哈希序 / 浮点断言依赖 HashMap 遍历顺序、依赖浮点精确相等断言用集合而非顺序;浮点用容差比较

还有两个常见来源:真实外部依赖(连了会变的共享数据库/服务,第 6 章的密封性)和并发竞态(测试里有真的多线程竞争)。它们的共同解法都一样 —— 把不确定性从测试里赶出去,让它变回确定。

失败重试:止痛片,不是药

面对 flaky,最常见的"解决方案"是自动重试:失败了自动再跑两次,只要有一次绿就算过。Demo 里有个开关能试试这个。它确实能把可见的失败压下去 —— 但它是止痛片,不是药:

⚠ 陷阱 · 重试掩盖 flaky,也掩盖真 bug

重试的问题在于:它无法区分「flaky 抽风」和「真 bug」。一个真的、只是偶发的 bug(比如一个真实存在的竞态条件),会被重试机制一并"洗白"成绿色 —— 你彻底失去了发现它的机会。

而且重试让测试更慢(失败的要跑三遍),更重要的是,它纵容了 flaky 的存在:大家不再去修根因,反正重试能"解决"。于是 flaky 越积越多,套件越来越慢、越来越不可信。重试是把问题藏起来,不是解决它。

正确的姿势:定位、隔离、根治

成熟团队对 flaky 有一套纪律:

  1. 让它可见:CI 记录每个测试的历史通过率。一个测试开始抖,系统自动标记它(而不是靠人偶然发现)。
  2. 隔离(quarantine):把确认 flaky 的测试移出主门禁,单独放一个"隔离区"继续跑(还在跑,但不再阻塞合并)。这保护了红灯的可信度 —— 主流水线里,红就是真的红。
  3. 根治:限期修复隔离区里的测试,消除那个非确定性来源(而不是加重试)。修不好的,要么删,要么重写。
◆ 判词 · flaky 测试要么修好,要么删掉,绝不放任

放任一个 flaky 测试留在主流水线,是在慢性透支整套测试的信任。宁可暂时删掉它(承认"这块暂时没测"),也好过让一个乱红的测试训练全组忽略红灯。一个诚实的"没测",好过一个撒谎的"测了"。

AI 与 flaky:它两头都帮倒忙

▲ AI 既制造 flaky,又用错误的方式"修" flaky

制造:如第 12 章所说,AI 爱写 Thread.sleep 式的异步测试、爱写依赖执行顺序的共享状态测试 —— 这些天生 flaky。它写的时候在它的环境里"碰巧过了",到你的 CI 上就开始抖。

误修:你让 AI "修一下这个 flaky 测试",它最常见的做法是 —— 加重试、或者把 sleep 调更长。也就是上面说的止痛片。它在优化"让测试变绿"这个表面目标,而不是消除非确定性这个根因。真正的修复(注入时钟、隔离状态、改成等条件)需要理解 flaky 的来源,这恰恰是它容易跳过的。flaky 的根治,是需要你亲自判断"非确定性从哪来"的地方。

怎么抓一个 flaky:让它必然复现

flaky 最难的地方是「偶发」—— 你想修它,它偏偏不红给你看。所以第一步永远是提高它的复现率,把「偶尔红」逼成「稳定红」:

  • 反复跑:把这一个测试连跑几百次(JUnit5 的 @RepeatedTest(500)、命令行循环)。一个 3% 抖动率的测试,跑 500 次几乎必然红几次 —— 你就有了可观察的失败。
  • 打乱顺序:如果怀疑是执行顺序依赖,强制随机化测试顺序跑。顺序敏感的测试会立刻暴露。
  • 加压:并行跑、在负载高的机器上跑 —— 时序类 flaky 在资源紧张时最容易现形(这也是它专挑 CI 繁忙时段发作的原因)。

四个来源,各配一个真实修法

把前面那张表里的每一类,落成「坏代码 → 好代码」的具体样子:

// ① 时序:赌固定时长 → 等条件
// ✗  viewModel.load(); Thread.sleep(500); assertEquals(Done, state)
// ✓  runTest { viewModel.load(); advanceUntilIdle(); assertEquals(Done, state) }

// ② 共享状态:测试间残留 → 每个测试自带干净状态
// ✗  companion object { val cache = mutableMapOf<...>() }   // 所有测试共用,互相污染
// ✓  @BeforeEach fun setup() { cache = mutableMapOf() }       // 每个测试一份新的

// ③ 真实时钟:依赖"现在" → 注入固定时钟
// ✗  assertEquals("今天", format(LocalDate.now()))            // 跨零点就错
// ✓  assertEquals("今天", format(date, clock = fixedClock))   // 时间可控(第 5 章)

// ④ 哈希序:依赖遍历顺序 → 断言用集合,不用列表顺序
// ✗  assertEquals(listOf("a", "b"), map.keys.toList())        // HashMap 顺序不保证
// ✓  assertEquals(setOf("a", "b"), map.keys.toSet())          // 只比"有哪些",不比顺序
◆ 判词 · 每个修法,都是在「消灭一处非确定性」

四个修法看着不同,内核完全一致:把测试里那个"每次可能不一样"的东西,变成"每次都一样"。时长→条件、共享→独立、真时钟→固定时钟、无序→无序无关的断言。flaky 的治理,从来不是"让它更容易过"(那是重试的思路),而是"让它的结果不再取决于运气"。找到那个非确定性来源,是全部功夫所在 —— 也是 AI 最容易跳过的一步。

✚ 动手

1. 回到 Demo,连点几次「跑 100 次」,亲眼看那个通过数字每次都变。想象它在你 CI 上,某次刚好 100,你合并了 —— 然后呢?

2. 回想你团队最近一次"重跑就好了"。那个测试后来修了吗,还是至今还在乱红、大家已经学会无视它了?

3. 在你的测试里搜非确定性来源:sleepnow()Random()、共享的可变静态变量、真外部调用。每一个都是潜在的 flaky。

覆盖率的谎言、变异揭穿的空壳、flaky 的乱红 —— 这些是单个测试层面的陷阱。下一章把它们连同其他常见坑,汇成一份反模式图鉴,让你在 code review 里一眼认出它们。然后,就是全书的收口:当 AI 亲自来替你写测试,它会作弊,而且作弊得非常像。