偶发失败的测试是断言了竞态 (Flaky Test Asserted A Race)
1. 一句话
测试时好时坏时,第一件事不是"加 sleep / 加超时",而是分清它属于哪一种:
- A 类:等待预算太紧 —— 被测逻辑是对的,只是机器忙的时候没等到。放宽预算即可。
- B 类:断言了一个竞态的结果 —— 两种结果都是正确的并发交错,只有一种被断言了。这时改测试(让它只断言两种情况都成立的不变量),不要改代码去迎合断言。
判断方法:单独反复跑(for i in $(seq 1 20); do ...; done)。
- 单独跑 20 次全过、只在整套并行跑时挂 → 偏 A 类。
- 单独跑也会挂(哪怕 15 次挂 2 次)→ 偏 B 类,别当成抖动。
2. 实例(2026-09,rolefit-rs)
现象:circuit_breaker_trips_and_auto_pauses 在 workspace 全量并行跑时约 13% 失败(15 次挂 2 次),单独跑 12 次全过。
排查过程:
- 先把轮询预算从 3 秒提到 30 秒 → 仍然失败,而且"等满了整 30 秒"。
→ 说明不是"没等到",是状态永远不会变成断言的那个值。这一步排除了 A 类。 读代码,把两种可能的终态画出来:
- 熔断发生时还有记录没开工 → 那些 worker 卡在 resume 门上 → 运行保持
paused✅ - 熔断发生时所有记录都已越过 resume 门 → 各自跑完 → 结算把
paused覆盖成partial❌
- 熔断发生时还有记录没开工 → 那些 worker 卡在 resume 门上 → 运行保持
- 看测试夹具:3 条记录、并发 1、熔断阈值也是 3。三条都并发越过门之后才开始失败,
第 3 次失败触发熔断时已经没有"还没开工"的记录 —— 于是走第二条分支。
到底走哪条,取决于第 3 次失败落在哪条记录的哪一步。测试断言的是掷硬币的结果。 - 对照源实现(这个项目在把 Python 逐行为等价地迁到 Rust):源里
process_one同样是
"先 await 恢复门、已开工的照跑完",也就是说两种结果在源系统里都会出现。
→ 代码没毛病,是测试写得比现实更确定。
改法:把记录数提到 5 条(> 熔断阈值 3)。熔断后第 4、5 条必然卡在 resume 门上,
运行必然停在 paused —— 变成确定性用例,而且断言更强了:新增"熔断后最多只处理完 3 条",
直接把"熔断真的拦住了后面的活"这件事测出来。连跑 20 次全绿。
3. 顺手该做的另一半
同一个测试文件里那些"等异步完成"的兜底超时(wait_done 轮询 200 次 ×10ms、timeout(1s, join_handle))属于 A 类,一并放宽。要点是:
兜底超时的数字只在真出问题时才会等满,平时几十毫秒就返回。
所以放宽不会让测试变慢,只会让它不再因为机器挤而假失败。
反过来说:如果某个超时是断言本身(比如"这个限流器在 100ms 内不该放行"),
那就不能放宽 —— 放宽等于把断言删了。改之前先分清哪个是哪个。
4. 下次怎么识别
- 报告里出现"偶发""重跑就过" → 立刻
for i in $(seq 1 20)复现,拿到真实失败率。 - 失败时先看是不是等满了预算:等满 = 状态没变成期望值(B 类);没等满就失败 = 别的错。
- 写并发用例时先问一句:这个断言在每一种合法交错下都成立吗? 不成立就改成不变量断言
(如"最终要么 paused、要么 terminal,且 config 一定被标记为熔断"),别锁死其中一种。
5. 相关概念
- Promise (JavaScript 异步对象) —— 同样的"等待与竞态"心智模型,仅语言不同。
- 迁移类工程里,这个判断还要多一步:先去源实现里确认期望行为。源实现里两种结果都会出现,
就说明该改的是测试而不是这个新实现。
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Programming