S16-J06 · 不稳定测试:从现象到根因的定位方法
同一个 commit,CI 跑三次,两绿一红。重跑又绿,于是"它自己好了"——下次再红就没人看。flaky 的损失不是那一次失败,而是红灯失去信息量:红灯能靠重跑消掉,真 bug 就会混在噪声里一起被消掉。
定义只有一条:同一份代码、同样输入,多次运行结果不同。它一定来自某个未受控的变量,而不是"写得不用心"。
1. 六类根因与典型症状
| 根因 | 典型症状 | 复现难度 | 修法 |
|---|---|---|---|
| 时间依赖 | 跨零点、月末、DST 切换红;sleep 后偶发红 |
中 | 注入 Clock,冻结时间 |
| 顺序依赖 | 单独绿、全量红;换顺序即变 | 低 | 消除共享可变状态 |
| 并发竞争 | 偶发超时、共享集合断言、日志错乱 | 高 | 加压重复跑,改同步点 |
| 外部依赖 | 网络抖动、容器慢、端口被占 | 中 | 隔离 + 显式重试并报告 |
| 哈希顺序 | 断言依赖 HashMap/Set 遍历序 |
低 | 显式排序、固定种子 |
| 资源泄漏 | 先绿后红;连接 / 线程数递增 | 中 | 关闭资源、隔离上下文 |
排序按排查成本而非常见度:顺序依赖与哈希顺序几乎零成本定位,并发与外部依赖最贵——先排除便宜的。
2. 时间依赖:修法是注入,不是加长等待
| 反模式 | 为什么坏 | 替代 |
|---|---|---|
Thread.sleep(1000) 等异步 |
慢机器不够、快机器白等 | Awaitility 轮询 |
断言 LocalDate.now() 的结果 |
跨零点必红 | 注入 Clock.fixed(...) |
| 用当前时间造"过期"数据 | 月末 / 闰日边界 | 显式传固定时间 |
| 时区取系统默认 | CI 与本地不一致 | 显式 ZoneId.of("UTC") |
sleep 等容器就绪 |
启动时间不可预测 | 健康检查 + 轮询 |
// 反模式:等待固定时长
Thread.sleep(2000);
assertThat(repo.count()).isEqualTo(3);
// 正模式:轮询直到条件成立,超时即失败
await().atMost(Duration.ofSeconds(5)).pollInterval(Duration.ofMillis(100))
.untilAsserted(() -> assertThat(repo.count()).isEqualTo(3));
sleep 的问题不只是慢,而是它把"没等到"变成"看起来等到了":机器一慢,测试就在最不该红的时候红,而且毫无线索。
时间冻结三种实现:注入 Clock(干净)、mockStatic(Clock.class)(有污染风险)、@EnableTimeShifting 扩展(按版本核对 API)。能注入就注入,冻结只是顺带收益。
3. 顺序依赖:单独绿、全量红
识别方法很简单:
mvn -q test -Dtest=OrderServiceTest # 单类跑,绿
mvn -q test # 全量跑,红
mvn -q test -Dtest=OrderServiceTest,OtherTest # 两两组合缩小范围
若"单独绿、全量红",几乎必然是测试间共享了可变状态。五个常见载体:
| 载体 | 表现 | 处理 |
|---|---|---|
static 字段 / 单例缓存 |
一个测试改配置,另一个读到脏值 | 重置或改实例字段 |
| Spring 上下文缓存 | @MockBean 污染共享上下文 |
隔离上下文配置 |
| 数据库残留 | "总数 = 3"被上一个测试破坏 | 清理或唯一租户前缀 |
| 临时文件固定路径 | 读到上一个测试的文件 | @TempDir |
| 系统属性 | setProperty 影响全 JVM |
try/finally 恢复 |
诊断手法是二分定位:两两组合跑,或按字母序 / 逆序跑(部分构建工具支持 runOrder)。JUnit 5 的方法顺序确定但不承诺(按版本与配置核对),所以"本地顺序不一样"本身就是信号。
4. 并发竞争:偶发超时与共享集合
并发型 flaky 只在压力下出现:CI 核数多、并行度高,反而更容易红。
| 现象 | 可能的竞争点 |
|---|---|
| 断言偶发失败,值是中间态 | 断言早于异步任务完成 |
ConcurrentModificationException |
遍历时另一线程在写 |
| 超时忽长忽短 | 线程池饱和、锁竞争 |
| 计数偶尔多 1 或少 1 | 非原子自增、丢失更新 |
| 日志顺序与预期不符 | 多线程写同一流 |
复现手段:加压 + 重复。
# 重复跑同一个测试类,观察失败率
for i in $(seq 1 50); do mvn -q test -Dtest=OrderServiceTest || echo "FAIL at $i"; done
// 用失败率而非单次结果判断
@RepeatedTest(50)
void concurrentCounter() { /* ... */ }
50 次里红 3 次就已经能定位——失败率是这类问题的唯一可靠指标,单次通过说明不了什么。
修法是消除不确定性而不是加重试:把"等异步完成"改成显式同步点(CountDownLatch、带超时的 join()),共享集合换并发结构,断言放在汇合之后。
5. 外部依赖:隔离与"会报告的重试"
| 外部因素 | 症状 | 隔离方式 |
|---|---|---|
| 网络抖动 | 偶发连接超时 | 换本地 stub / 容器 |
| 容器启动慢 | 前 30 秒必红 | 健康检查 + 就绪等待 |
| 端口冲突 | BindException |
随机端口(0 由系统分配) |
| 共享测试库被并发改 | 数据幻读 | 每测试独立 schema |
| 第三方限流 | 偶发 429 | 录制回放或降并发 |
端口冲突有确定性修法:不要让测试猜端口。Testcontainers 的 getMappedPort()、WireMock 的动态端口都是这个思路。
重试要分两种:
| 重试类型 | 位置 | 可否接受 |
|---|---|---|
| 基础设施重试(连接、启动) | 框架 / 客户端层 | 可接受,但要记日志 |
| 断言失败重试 | 测试层 | 不可接受,等于把 bug 重试掉 |
// 可接受:等待外部条件成立
await().atMost(Duration.ofSeconds(10)).until(this::serviceReady);
// 不可接受:把断言包进重试
retry(3, () -> assertThat(result).isEqualTo(expected));
原则:重试要报告。重试后通过必须打 FLAKY 标记并计入指标,否则只是把噪声藏起来。
6. 随机与哈希顺序
HashMap 遍历序在不同 JVM、容量、hash seed 下都可能变化,HashSet 同理。断言"集合转字符串等于某值"是最典型的写法性 flaky。
| 写法 | 风险 | 改法 |
|---|---|---|
assertEquals(list, map.keySet()) |
顺序不定 | sorted() 或无序断言 |
toString() 比较集合 |
顺序 + 实现细节 | 逐元素断言 |
new Random() 无种子 |
不可复现 | 固定种子 |
randomUUID() 进断言 |
不可预测 | 断言格式或注入供应商 |
| 并行流收集顺序 | 不保证 | 收集后显式排序 |
// 脆弱
assertThat(repo.findAllTags()).isEqualTo(List.of("a", "b", "c"));
// 稳健
assertThat(repo.findAllTags()).containsExactlyInAnyOrder("a", "b", "c");
要断言顺序就让顺序成为契约的一部分并在实现里显式排序(ORDER BY、Comparator)。没排序却断言顺序,等于在测 JVM 实现细节。
7. 资源泄漏:前面绿、后面红
| 泄漏 | 症状 | 修法 |
|---|---|---|
| 连接未关 | 跑到第 N 个测试池耗尽 | try-with-resources;调小池上限早暴露 |
线程池未 shutdown |
JVM 不退出、CI 卡收尾 | @AfterAll 关闭 |
| 临时文件未清 | 磁盘涨、路径冲突 | @TempDir |
| 监听器未注销 | 后续测试收到别人的事件 | 每测试新建容器 |
ThreadLocal 未清 |
线程复用脏读 | finally 里 remove() |
判据:失败位置随执行顺序漂移,越靠后的测试越容易红。这类问题常被误判成"某个测试写得不好",实际是前面某个没清干净。
8. 治理流程:从隔离到回归
| 阶段 | 动作 | 产出 |
|---|---|---|
| 1 识别 | 打 flaky 标签,收集失败率 |
清单与优先级 |
| 2 隔离 | 移出阻塞流水线 | 主干恢复可信 |
| 3 复现 | 加压重复跑、固定种子 | 可复现步骤 |
| 4 定位 | 时间→顺序→哈希→并发→外部→资源 | 根因结论 |
| 5 修复 | 注入 Clock、消除共享状态、显式同步 | 代码改动 |
| 6 回归 | 重复 50 次全绿才移回 | 证据 |
| 7 度量 | flaky 率进质量指标 | 可见趋势 |
隔离不是终点。打标签后不回来,等于慢性删除测试——三个月后没人记得它为什么被移出。
口径建议:flaky 次数 / 总运行次数,按测试类聚合。别用"重试后通过率"当质量指标,那个数字只会鼓励重试。
9. 重试插件的边界
| 场景 | 重试是否正当 | 说明 |
|---|---|---|
| 容器 / 网络就绪 | 是 | 环境不确定性,且应记日志 |
| 已知第三方抖动 | 有条件 | 需 issue 跟踪与到期时间 |
| 断言失败 | 否 | 逻辑不确定 = 有 bug |
| 并发竞争 | 否 | 只是提高"碰巧通过"概率 |
| 时间边界 | 否 | 换个时间还红,更难查 |
判断标准:重试是否改变根因是否成立。容器没起来,重试后起来了,根因消失;断言偶发失败,根因仍在,只是这次没触发。
用重试必须配上:保留第一次失败的堆栈(而非最后一次)、重试次数上限、重试统计上报。否则"曾经红过"这件事彻底消失。
10. 排错表
| 现象 | 最可能根因 | 定位动作 |
|---|---|---|
| 单独绿、全量红 | 顺序依赖 / 共享状态 | 两两组合跑 |
| 跨零点或月末红 | 时间依赖 | 冻结时钟后重跑 |
| 偶发超时,重跑即绿 | 并发 / 外部依赖 | 重复 50 次算失败率 |
BindException |
端口冲突 | 改用动态端口 |
| 断言值顺序不同 | 哈希顺序 | 显式排序 |
| 越靠后越容易红 | 资源泄漏 | 查连接/线程数,收紧池上限 |
| 只在 CI 红 | 并行度、核数、时区 | 本地加压并行、固定时区与 locale |
"只在 CI 红"值得单独说:本地单线程、CI 并行分片暴露的问题集合不同。本地复现不到不等于不存在,应复制 CI 的并行度与时区,而不是关掉测试。
11. 边界:什么不算 flaky
| 情况 | 定性 | 处理 |
|---|---|---|
| 依赖服务真的挂了 | 环境故障 | 修环境 |
| 外部账号被限流 | 设计缺陷 | 换本地替身 |
| 代码有真实竞态 | 产品 bug | 修代码,测试是对的 |
| 断言过严 | 脆弱测试 | 收窄范围 |
用 sleep 掩盖的慢 |
掩盖 | 换轮询 |
| 删测试让流水线变绿 | 逃避 | 隔离 → 修复 |
最后两行是同一陷阱两面:删掉 flaky、或用 sleep 拖长等待,都让指标变好而质量变差。
12. 自测
- 为什么"重跑就绿"比"稳定红"更危险?
- 单独跑绿、全量跑红,最先该查哪一类共享状态?
- 并发型 flaky 为什么用单次运行结果无法判断?
Thread.sleep(2000)换成Awaitility之后,本质改变了什么?assertEquals(list, set)这类断言在什么环境下必然暴露?- 重试插件在什么条件下算权宜,什么条件下算掩盖?