系列专栏:测试工程师每日一博 · Day 10
日期:2026-07-16
关键词:flaky 测试、测试稳定性、flaky 检测、@Retry、quarantine、Awaitility、SLA
阅读对象:被"CI 莫名其妙挂了,重跑又过了"折磨过无数次的工程师
开篇:flaky 是测试套件的慢性病
Day 7 聊 CI 时,我用四个字给 flaky 定了个性------"fix or remove,没有第三条路"。但那只是政策,没说具体怎么做。Day 10 来兑现承诺。
先讲一个场景:周一早上 9 点,你打开电脑,CI 说"订单创建测试失败"。你点进去,发现 79 个测试里有 1 个红色,失败原因 Expected: <true> but was: <false>。你 rerun 一次,绿了。你过上一个快乐但充满罪恶感的周一。
这不是"Bug 修了",这是 flaky 测试 ------同一个 commit 在同一个环境下跑,有时过有时不过 。它和功能性 Bug 是两种东西:功能性 Bug 是代码错了,要改代码;flaky 是测试本身有非确定性,要么改测试(去掉非确定性),要么改成其它的验证方式。
flaky 是测试套件的慢性病 ------不会立刻致命,但长期不治会把整套测试打回原形。flaky 一旦超过 1%,团队的信任就会塌掉:CI 挂了?哦又是 flaky,直接 rerun 吧。三个月后,CI 失败没有人当回事,这就是测试工程的死亡。
今天这一篇就讲:怎么从 0 检测 flaky、怎么定位根因、@Retry 是解药还是毒药、怎么用工程化方式让 flaky 自动上报、怎么定 SLA 治理。所有方法论基于 Google 那篇 Flaky Tests at Google (Luo et al.,2019)和 Where Do Google's Flaky Tests Come From? (Memon et al.,2020)这两篇工业级研究,我用电商场景跑通一遍。
一、flaky 的五大根因:别只盯一个
很多人排查 flaky 只会"改成 Thread.sleep(2000) 试试",但根因其实有五大类。下表是去掉具体数字后的工业分布(来自 Google 论文 + 我多年观察整理):
| # | 根因类别 | 典型表现 | 占比 |
|---|---|---|---|
| 1 | 时间 / 日期依赖 | LocalDateTime.now() 进入测试 |
~25% |
| 2 | 并发 / 等待 | Thread.sleep(100) 等异步完成 |
~24% |
| 3 | 测试间相互污染 | static 字段没清理、数据库没人回滚 | ~16% |
| 4 | 外部环境依赖 | 网络、文件系统、随机数、时区、host | ~21% |
| 5 | 资源耗尽 / 平台差异 | 端口冲突、内存、CI 容器架构不同 | ~14% |
记住这张表。收到 flaky 报告的第一反应不是看错误堆栈,而是先问"这测试踩了上面哪一类坑"。错误堆栈只告诉你"哪里炸了",分类告诉你"为什么会时灵时不灵"。
下面挑前四类(占大头的前 85%)逐个深挖。
二、根因 1:时间依赖------"我用 now(),我认罪"
最常见的 flaky:测试里写了 LocalDateTime.now()。
反例:优惠券有效期校验
java
// src/main/java/com/example/coupon/Coupon.java
public class Coupon {
private final String code;
private final LocalDate validFrom;
private final LocalDate validTo;
public boolean isValid() {
LocalDate today = LocalDate.now(); // ← 罪魁祸首
return !today.isBefore(validFrom) && !today.isAfter(validTo);
}
}
// 测试
@Test
@DisplayName("测试优惠券在有效期内")
void shouldReturnTrue_whenWithinRange() {
Coupon coupon = new Coupon("SAVE10",
LocalDate.of(2026, 1, 1),
LocalDate.of(2026, 12, 31));
assertTrue(coupon.isValid()); // 2026 年内永远过;2027 年 1 月 1 日突然红
}
这个测试什么时候挂? 2026 年 12 月 31 日 23:59 还绿,2027 年 1 月 1 日 00:00 忽然 CI 全红。谁也不会提前想到------直到年关那天事故。
修复:注入 Clock,不要调 now()
java
// src/main/java/com/example/coupon/Coupon.java
import java.time.Clock;
import java.time.LocalDate;
public class Coupon {
private final String code;
private final LocalDate validFrom;
private final LocalDate validTo;
private final Clock clock; // ← 依赖注入
public Coupon(String code, LocalDate validFrom, LocalDate validTo, Clock clock) {
this.code = code;
this.validFrom = validFrom;
this.validTo = validTo;
this.clock = clock;
}
public boolean isValid() {
LocalDate today = LocalDate.now(clock); // ← 用注入的 Clock
return !today.isBefore(validFrom) && !today.isAfter(validTo);
}
}
// 测试:用固定 Clock
@Test
@DisplayName("有效期内的优惠券应有效")
void shouldReturnTrue_whenWithinRange() {
// 固定时间 2026-06-15
Clock fixed = Clock.fixed(
LocalDate.of(2026, 6, 15).atStartOfDay(java.time.ZoneOffset.UTC).toInstant(),
java.time.ZoneOffset.UTC);
Coupon coupon = new Coupon("SAVE10",
LocalDate.of(2026, 1, 1),
LocalDate.of(2026, 12, 31),
fixed);
assertTrue(coupon.isValid());
}
@Test
@DisplayName("过期优惠券应无效")
void shouldReturnFalse_whenExpired() {
Clock fixed = Clock.fixed(
LocalDate.of(2027, 1, 1).atStartOfDay(java.time.ZoneOffset.UTC).toInstant(),
java.time.ZoneOffset.UTC);
Coupon coupon = new Coupon("SAVE10",
LocalDate.of(2026, 1, 1),
LocalDate.of(2026, 12, 31),
fixed);
assertFalse(coupon.isValid());
}
关键纪律 :生产代码里永远不直接调 LocalDateTime.now() / Instant.now() ,而是注入 Clock,测试可固定、生产传真实 Clock。这是 flaky 治理的第一原则。
Spring 项目里更好做------Clock 注册成 @Bean,生产用 Clock.systemDefaultZone(),测试 @TestConfiguration 替换成 Clock.fixed(...)。
三、根因 2:并发等待------Thread.sleep 是 flaky 的干细胞
第二常见:测异步代码时,用 Thread.sleep 等结果。
反例:异步订单通知
java
// 消息发到 MQ,下游消费后落一条 audit 记录
@Test
@DisplayName("下单后应异步写出 audit 记录")
void shouldWriteAuditAfterOrderCreated() throws Exception {
Order order = orderService.create(buildOrderRequest());
// ❌ 等 2 秒,期望下游处理完
Thread.sleep(2000);
AuditLog log = auditRepository.findByOrderId(order.getId());
assertNotNull(log);
}
这个测试什么时候挂? 在你机器上 2 秒够,CI 上某次 MQ 消费者积压要 2.5 秒,挂了。Thread.sleep 永远没法稳定地"等够":等待短时易超时(慢机器),等待长时浪费时间(快机器还多等)。
修复:用 Awaitility,断言会"等"
xml
<!-- pom.xml -->
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.2</version>
<scope>test</scope>
</dependency>
java
import static org.awaitility.Awaitility.await;
import static java.time.Duration.ofSeconds;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@Test
@DisplayName("下单后 5 秒内应写出 audit 记录")
void shouldWriteAuditAfterOrderCreated() {
Order order = orderService.create(buildOrderRequest());
await()
.atMost(ofSeconds(5)) // 最多等 5 秒
.pollInterval(ofMillis(200)) // 每 200ms 检查一次
.untilAsserted(() -> {
// 用 assertion 而不是 if/check,失败会自动重试
AuditLog log = auditRepository.findByOrderId(order.getId());
assertNotNull(log, "audit 记录应在 5 秒内出现");
});
}
Awaitility 干了三件事:
- 轮询重试------每 200ms 跑一次断言,直到通过或超时;
- 断言失败时继续等------不是"等 2 秒再判一次",是"持续地在 5 秒窗口内尝试";
- 失败信息可读------超时时报告"5 秒内 audit 记录一直为 null",而不是干巴巴的 NPE。
纪律 :测试里能用 Thread.sleep(x) 的地方,99% 都可以换成 Awaitility。剩下 1% 是真实硬件依赖(读传感器那种),不在我们聊范围。
四、根因 3:测试间相互污染------"上一个测试给我留了垃圾"
第三类最隐蔽:测试单独跑过、一起跑挂。有一次我排查这个排查了两天,因为单测每个都过,但 mvn test 全跑就红两个。
反例:static 状态没清理
java
public class DiscountContext {
private static DiscountRule currentRule; // ← static 可变状态
public static void setRule(DiscountRule rule) {
currentRule = rule;
}
public static BigDecimal apply(BigDecimal price) {
return currentRule.apply(price);
}
}
// 测试 A
@Test
void shouldApply10PercentDiscount() {
DiscountContext.setRule(new PercentDiscountRule(BigDecimal.valueOf(0.1)));
assertEquals(new BigDecimal("90.00"), DiscountContext.apply(new BigDecimal("100.00")));
}
// 测试 B(跑在 A 之后)
@Test
void shouldApplyNoDiscountByDefault() {
// ❌ 这里期望"默认无折扣",但如果 A 先跑了,currentRule 还在
assertEquals(new BigDecimal("100.00"), DiscountContext.apply(new BigDecimal("100.00")));
}
问题 :currentRule 是 static,A 测试改了它,B 测试假设"默认值"------所以 B 单独跑过(刚启动 static 是 null 抛 NPE),A 跑过之后 B 也过(currentRule 被改成 10% 折扣......等等,这次反而挂了)。
实际中最常见的污染是这两种:
- 数据库没回滚------A 测试插了一条数据,B 测试假设表是空的;
- Spring 上下文 static 字段 ------
@SpringBootTest启的 bean 持有状态,A 改了之后 B 看到的是脏值。
修复:@BeforeEach 清理 + 事务回滚
java
// 方案 A:用 @BeforeEach 显式 reset
@BeforeEach
void resetState() {
DiscountContext.setRule(DiscountRules.noDiscount());
}
// 方案 B(集成测试):让 Spring 自动回滚事务
@SpringBootTest
@Transactional // ← 测试结束自动 rollback,数据库恢复干净
class OrderRepositoryIT {
@Autowired
private OrderRepository repo;
@Test
void shouldFindSavedOrder() {
repo.save(buildOrder());
// 测试结束,@Transactional 自动 rollback
}
}
纪律 1 :测试不该依赖"前一个测试留了什么"------每个测试都从已知初始态开始。这是测试可隔离性的底线。
纪律 2 :static 可变状态在测试代码里出现就要警觉 。如果是别人写的生产代码 static,测试里用 @BeforeEach 强制重置。
五、根因 4:外部环境依赖------网络、文件、随机数
第四类:测试依赖"测试代码外面的东西"。
反例 1:测试调真实第三方
java
// ❌ 测试里直接调真实的快递 API
@Test
void shouldReturnShippingFee() {
ExpressClient client = new ExpressClient("https://api.express.com"); // ← 真实 API
BigDecimal fee = client.calculateFee("杭州", "北京", 1.5);
assertEquals(new BigDecimal("12.00"), fee);
}
这种测试什么时候挂?(a)公司网络抽风时;(b)快递公司 API 变价时;©CI 在境外网络访问国内 API 超时时。任何外部依赖都可以让 flaky 凭空产生。
修复:用 WireMock 桩掉(参考 Day 5),或用 Pact 测真的契约(参考 Day 9)。基础设施代码绝对不能在测试里调真实第三方。
反例 2:依赖文件系统绝对路径
java
// ❌ 假设 /tmp/orders.json 一定可写
@Test
void shouldExportOrders() throws IOException {
File f = new File("/tmp/orders.json");
service.exportTo(f);
assertTrue(f.length() > 0);
}
CI 容器上 /tmp 可能不一样、可能不可写、可能上次跑的垃圾还在。修复 :用 JUnit 5 的 @TempDir:
java
@Test
void shouldExportOrders(@TempDir Path tempDir) throws IOException {
File f = tempDir.resolve("orders.json").toFile();
service.exportTo(f);
assertTrue(f.length() > 0);
}
// 测试结束 JUnit 自动删这个临时目录,绝对隔离
反例 3:Math.random() / UUID.randomUUID()
java
// ❌ 测试里用 random 数据,断言时只检查"非空"
@Test
void shouldGenerateUniqueOrderNumber() {
String orderNo = orderService.generateOrderNo();
assertNotNull(orderNo);
// 这测试永远过,但根本没验证"唯一"------真到生产偶发撞号,测试从来没报
}
更糟的:
java
// ❌❌ 测试本身依赖随机值
@Test
void shouldHandleRandomPrice() {
BigDecimal price = new BigDecimal(Math.random() * 100);
BigDecimal result = service.applyDiscount(price);
assertTrue(result.compareTo(price) <= 0); // 接近 trivial,几乎肯定过
}
修复 :测试要么用固定值 (边界值显式列),要么用确定性伪随机(seeded random)。
java
Random seeded = new Random(42); // 固定 seed
BigDecimal price = new BigDecimal(seeded.nextDouble() * 100);
// 每次跑结果一致,flaky 根除
六、根因 5:资源耗尽 / 平台差异------CI 上特有的怪事
第五类留给那些"我本机好好的"诡异 case。典型症状:
- 端口冲突 :Testcontainers 启容器时本机端口可能被占用 → 让框架使用随机端口(端口设为
0,由操作系统分配空闲端口); - CI 容器架构不同:Apple M 系列 ARM64 vs CI x86,镜像坑(参考 Day 4);
- 内存不足:并行跑 10 个 SpringBootTest,OOM 不是功能 bug;
- 文件名大小写敏感 :Mac 文件系统不区分大小写,Linux 区分,
Icon.pngvsicon.png在 CI 上挂;
这类问题排查技巧:写好 CI 失败时上传完整日志 + 容器状态 + 内存 dump 的 artifact(Day 7 的 nightly job 已示范),让"我本机好的"这种话不再有市场。
七、@Retry 是解药还是毒药?这是 flaky 治理最大的争论
JUnit 5 有个 @RetryingTest,Maven Surefire 有 rerunFailingTestsCount,作用相同------失败时自动重试 N 次。
java
@Test
@RetryingTest(3) // 失败时再跑 2 次,只要有一次过就算过
void flakyTest() {
// ...
}
直觉 :flaky 重试一下就好了。
现实 :@Retry 是把地毯下面的灰尘扫得更深------测试本身的非确定性没消除,只是被掩盖 。加 @Retry 后,这条测试有 30% 概率过,你重试 3 次,变成 1 - 0.7³ = 65.7% 概率过------但还有 34.3% 概率会最终挂。你以为是"治好了",其实是把"3 倍时间 + 还有概率挂"加到了 CI 里。
Google 那篇论文里的态度叫"no retry on failure "------失败就是失败,不重试。这种硬态度的好处是:flaky 立刻可见,不会躲在 retry 数字背后。
那 @Retry 真的完全不能用?不是。它适合两种情况:
- 已知非确定性但短期无法修 ------加
@Retry是临时缓解,不是修复,必须搭配 quarantine(下节讲); - 真实外部偶发不稳定 (比如真网络包丢失)------
@Retry帮助"测试本身的逻辑无关的偶发噪音"被过滤掉,且失败信息必须保留日志。
判断标准 :@Retry 用之前,问自己一句------"如果这条测试从来没挂过,@Retry 还有必要吗?"。答案应该是"没必要"。如果"必要",说明你把 retry 当成了功能,这就是误用。
八、quarantine:像处理传染病一样处理 flaky
quarantine(隔离区)是 flaky 治理的核心工程化动作。流程是:
flaky 检测 → 自动移动到隔离区 → 标记 @Tag("flaky") → CI 跳过 → 7 天修复期
↓
修好→回归主套件
未修好→删除测试
步骤 1:检测
检测 flaky 有两种方式:
(a) rerun 矩阵:同一次 commit 在 CI 上重跑 N 次,统计稳定性。简单但烧 CI 资源。
(b) 时间序列:把历史 CI 跑的结果接入数据系统,统计"这条测试最近 100 次跑里失败多少次"。Google 用的是这种。低成本但需要 CI 数据基础设施。
一个最朴素的实现------给 Surefire 加 flaky 检测(基于重跑):
xml
<!-- pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<!-- 失败时重跑 1 次,如果重跑过了,标记为 flaky -->
<rerunFailingTestsCount>1</rerunFailingTestsCount>
</configuration>
</plugin>
Surefire 报告里会出现 <flakyFailure> 标签,你可以解析这个标签做后续动作。
步骤 2:隔离
JUnit 5 的 @Tag 是天然的隔离机制。
java
@Test
@Tag("flaky") // ← 加标签
@DisplayName("暂存:[flaky] 偶发失败,正在排查")
void temporarilyFlaky() {
// 已知 flaky,等待修复
}
CI 里跳过 flaky 标签:
xml
<!-- pom.xml -->
<configuration>
<excludedGroups>flaky</excludedGroups>
</configuration>
或者 GitHub Actions 里:
yaml
- name: 跑测试(跳过 flaky)
run: mvn -B test -Dgroups='!flaky'
- name: 单独跑 flaky(只统计,不阻塞)
continue-on-error: true
run: mvn -B test -Dgroups='flaky'
关键设计 :flaky 不阻塞 PR merge ,但 CI 单独跑一次,把通过率记录下来。这样既不阻塞业务,又让 flaky 持续可见。
步骤 3:SLA 治理
光隔离还不够,得有"修复时限"。我推荐的 SLA:
| flaky 严重度 | 通过率 | 修复 SLA | 失败后果 |
|---|---|---|---|
| 🔴 高 | < 70% | 3 天 | 自动转 Issue,P0 标签,owner 必须响应 |
| 🟡 中 | 70%~90% | 7 天 | 自动转 Issue,P1 标签 |
| 🟢 低 | > 90% | 14 天 | 进入 watch list |
关键 :超 SLA 自动删除测试。这听起来无情,但很有必要------flaky 测试不删等于说"我们的测试套件不需要稳定",长期下来整个 CI 都没人信。删除测试不等于"代码没保护",如果代码重要,owner 会重新写一个稳定的测试;如果代码不重要,删了也无所谓。
步骤 4:修复回归
修好后,移除 @Tag("flaky"),测试回到主套件,owner 必须在 PR 里说明根因和修复方式------这样 flaky 知识沉淀下来,下次踩同样坑的人能搜到。
九、flaky 数据上报:让治理不再是感觉
最后一块拼图------怎么持续看到 flaky 的整体面。
GitHub Actions 有个简单做法:每个 PR 末尾解析 Surefire 报告,把 flaky 数量上报到 Slack / Dashboard:
yaml
# .github/workflows/pr.yml
- name: 解析 flaky 报告
if: always()
run: |
FLAKY_COUNT=$(find target/surefire-reports -name '*.xml' \
-exec grep -l '<flakyFailure>' {} \; | wc -l)
echo "本 PR 检测到 $FLAKY_COUNT 条 flaky 测试"
echo "FLAKY_COUNT=$FLAKY_COUNT" >> $GITHUB_ENV
- name: flaky 超阈值告警
if: env.FLAKY_COUNT > 5
uses: slackapi/slack-github-action@v1
with:
slack-message: "⚠️ 本仓库 flaky 测试已达 ${{ env.FLAKY_COUNT }} 条,注意治理"
进阶版是把 CI 结果接入 Datadog / Prometheus,做时序图。关键指标:
- flaky rate(flaky 测试数 / 总测试数)------趋势看治理效果;
- 单条 flaky 存活天数------谁超 SLA 了;
- flaky 测试 owner 分布------发现"某个团队持续产生 flaky",说明他们的测试工程文化需要支援。
数据可视化让 flaky 治理从"我听说有几个 flaky"变成"全仓库 47 条 flaky,3 条超 SLA,本月净减少 12 条"。没有数据的治理,永远停留在感觉。
十、和系列前文的回扣
把 Day 10 摆回系列地图:
- Day 1(单元测试金字塔) :flaky 大多发生在金字塔任何一个层,但单元层最不该 flaky(纯内存);
- Day 2(Mockito) :Mockito 严格模式 (
@MockitoSettings(strictness = STRICT_STUBS))能抓到"测试之间偷偷改 mock"导致的非确定性; - Day 3(覆盖率) :flaky 测试经常覆盖率很高但价值很低------它在测,但测的东西是随机的;
- Day 4(Testcontainers) :用真实容器替代 wire mock,本身就是消除一类 flaky------但要注意容器启动时的 flaky(端口、镜像拉取);
- Day 5(WireMock) :WireMock 的固定 delay 也是 flaky 高发地 ------Day 5 提到的
withFixedDelay,生产不抖测试时也用这个值,这种"假同步"就是 flaky; - Day 6(性能测试):性能测试本身没必要 flaky-free,SLO 应该用统计区间表示,单次失败不算 bug;
- Day 7(CI 分层):Day 7 给出了"flaky 检测 → 隔离 → fix or remove"政策框架,Day 10 把它具象化成可执行工程;
- Day 8(TDD) :TDD 出来的测试天然反 flaky------因为 Red-Green 循环要求"每次同样输入同样输出",flaky 测试在 TDD 循环里立刻被发现;
- Day 9(契约测试) :Pact 验证失败常被误判为 flaky(其实是契约真不兼容)------必须严格区分"flaky(测试本身非确定性)"和"契约失败(两边约定不一致)",后者不能 retry。
十一、思考题
- 你团队 CI 上"上次过这次不过"的报警,平均每个月出现几次?有没有把它定量上报,而不是凭感觉?
- 挑一条你们仓库的 flaky 测试,对照五大根因分类,它属于哪一类?对应的修复方式是什么?
- 你团队有没有
@Retry/rerunFailingTestsCount?如果有,他们是在"治理"还是"掩盖"? - 你敢不敢在团队里推"flaky 7 天不修就删"的政策?阻力会来自哪里?(提示:测试作者怕代码没保护;但没保护的代码其实本就没保护)
- flaky rate 应不应该作为 KPI?如果定为 KPI,会出什么怪事?(参考 Day 3 覆盖率 KPI 的反模式)
TL;DR
- flaky 五大根因:时间依赖、并发等待、测试间污染、外部环境依赖、资源耗尽------收到报告先归类,再看堆栈;
- 生产代码不调
now(),注入Clock;测试代码不用Thread.sleep,换 Awaitility ;静态状态在@BeforeEach显式重置; @Retry是缓解不是修复------除非短期 quarantine,否则不要长期依赖重试;- quarantine 流程:检测 →
@Tag("flaky")隔离 → CI 跳过但记录 → SLA 计时 → 超时删除; - flaky 治理三根支柱:检测(automated)、隔离(quarantine)、上报(dashboard)------三件事都做才叫工程化;
- flaky rate 不该当 KPI(参考 Day 3 覆盖率陷阱),但应作为趋势指标和工作清单;
- 删一条 flaky 测试不是损失,是"承认这条测试本来也没在保护代码"------代码真重要的话,会用稳定的方式重写。
明天 Day 11:《测试左移:需求评审阶段如何把缺陷消灭在编码前》 ------
flaky 是测试在执行层的问题,Day 11 要跳到上一层------**测试能不能更早介入?**需求评审阶段,QA 还没动手写测试,但已经能通过"逆向需求""场景拆解""缺陷预测",把 60% 的潜在 bug 挡在编码之前。如果这片对你有帮助,欢迎点赞收藏 ⭐。下篇见。