3020 字
约 10 分钟
14
S16-J06 · 不稳定测试:从现象到根因的定位方法
2024-12-31
2026-11-09

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. 自测

  1. 为什么"重跑就绿"比"稳定红"更危险?
  2. 单独跑绿、全量跑红,最先该查哪一类共享状态?
  3. 并发型 flaky 为什么用单次运行结果无法判断?
  4. Thread.sleep(2000) 换成 Awaitility 之后,本质改变了什么?
  5. assertEquals(list, set) 这类断言在什么环境下必然暴露?
  6. 重试插件在什么条件下算权宜,什么条件下算掩盖?
S16-J06 · 不稳定测试:从现象到根因的定位方法
https://www.clxhxhhr.top/posts/6043/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。