bigywater.tech
Java · 427

Flaky Test 治理

不稳定测试不是“偶尔红一下”,而是对信号的持续破坏。这篇用三种真实的不稳定来源、重试对失败信号的掩盖,以及基于标签的隔离,量出它们各自的失败率与治理手段的真实代价。

0.45% vs 9.63%
同一个并发竞争测试在满载与空闲机器上的失败率——负载变化会改变而不是单调提高失败概率
166 / 167
重试 3 次把 167 次竞争失败中的 166 次变成了通过(最终仍报失败的只有 1 次)
72.67%
同一个时序断言(15 ms 计算要求 40 ms 内完成)在满载机器上的失败率(空闲时 0%)

三种不稳定的来源

实测 · 本机执行输出
  (校准:3000 万次计算最快 51.5 ms,取 8735828 次约 15 ms)
  类型 1 并发(两个线程同时第一次创建单例,检查再创建,没有同步):
    空闲:race 4000 次,失败 385 次(9.63%),用时 302 ms
    满载(30 个空转进程):race 4000 次,失败 18 次(0.45%),用时 480 ms
    -XX:ActiveProcessorCount=1:race 4000 次,失败 171 次(4.28%),用时 320 ms
  类型 2 时序(断言“一个约 15 ms 的计算在 40 ms 内完成”):
    空闲:timing 300 次,失败 0 次(0.00%),用时 4536 ms
    满载(30 个空转进程):timing 300 次,失败 218 次(72.67%),用时 16656 ms
  类型 3 顺序(共享静态状态,随机顺序;与负载无关):
    空闲:order 4000 次,失败 646 次(16.15%),用时 16 ms
    满载(30 个空转进程):order 4000 次,失败 646 次(16.15%),用时 33 ms

重试的掩盖效应

实测 · 本机执行输出
  retry 2000 次测试运行(每次最多 3 次尝试):第 1 次就通过 1833,第 2 次通过 154,第 3 次通过 12,3 次都失败 1;第一次尝试失败的有 167 次(被重试“治好”的有 166 次)

JUnit 的重试扩展

实测 · 本机执行输出
  [全部运行] 退出码 1
      EV alwaysFails 被调用
      EV alwaysFails 被调用
      EV alwaysFails 被调用
      EV flakyUntilThird 第 1 次调用
      EV flakyUntilThird 第 2 次调用
      EV flakyUntilThird 第 3 次调用
      │  └─ Suite ✔
      │     ├─ quarantinedOk() ✔
      │     ├─ alwaysFails() ✔
      │     │  ├─ 尝试 1/3 ■ 第 1 次尝试失败,重试:真的有 bug,总是失败
      │     │  ├─ 尝试 2/3 ■ 第 2 次尝试失败,重试:真的有 bug,总是失败
      │     │  └─ 尝试 3/3 ✘ 真的有 bug,总是失败
      │     ├─ stable() ✔
      │     ├─ quarantinedFlaky() ✘ 已隔离的不稳定测试
      │     ├─ realBug() ✘ 没有被隔离的真实失败
      │     └─ flakyUntilThird() ✔
      │        ├─ 尝试 1/3 ■ 第 1 次尝试失败,重试:前两次失败 ==> expected: <true> but was: <false>
      │        ├─ 尝试 2/3 ■ 第 2 次尝试失败,重试:前两次失败 ==> expected: <true> but was: <false>
      │        └─ 尝试 3/3 ✔
      → 10 tests found | 0 tests skipped | 4 tests aborted | 3 tests successful | 3 tests failed

隔离

实测 · 本机执行输出
  [门禁任务:--exclude-tag quarantine] 退出码 1
      EV alwaysFails 被调用
      EV alwaysFails 被调用
      EV alwaysFails 被调用
      EV flakyUntilThird 第 1 次调用
      EV flakyUntilThird 第 2 次调用
      EV flakyUntilThird 第 3 次调用
      │  └─ Suite ✔
      │     ├─ alwaysFails() ✔
      │     │  ├─ 尝试 1/3 ■ 第 1 次尝试失败,重试:真的有 bug,总是失败
      │     │  ├─ 尝试 2/3 ■ 第 2 次尝试失败,重试:真的有 bug,总是失败
      │     │  └─ 尝试 3/3 ✘ 真的有 bug,总是失败
      │     ├─ stable() ✔
      │     ├─ realBug() ✘ 没有被隔离的真实失败
      │     └─ flakyUntilThird() ✔
      │        ├─ 尝试 1/3 ■ 第 1 次尝试失败,重试:前两次失败 ==> expected: <true> but was: <false>
      │        ├─ 尝试 2/3 ■ 第 2 次尝试失败,重试:前两次失败 ==> expected: <true> but was: <false>
      │        └─ 尝试 3/3 ✔
      → 8 tests found | 0 tests skipped | 4 tests aborted | 2 tests successful | 2 tests failed
  [隔离任务:--include-tag quarantine] 退出码 1
      │  └─ Suite ✔
      │     ├─ quarantinedOk() ✔
      │     └─ quarantinedFlaky() ✘ 已隔离的不稳定测试
      → 2 tests found | 0 tests skipped | 0 tests aborted | 1 tests successful | 1 tests failed

分步演示

不稳定测试治理
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12;JUnit console-standalone 1.11.3;10 个逻辑 CPU,满载用 30 个 python 空转进程;失败率每次运行会有波动(同一台机器上另一次运行空闲 8.43%、满载 1.78%);重试扩展是自写的最小实现(@TestTemplate + aborted),不是 junit-pioneer 或 Surefire 的 rerunFailingTestsCount
    • 没有验证:Surefire / Gradle 的内置重试与 flaky 报告、CI 平台的历史失败率统计、根因分析工具(如 rr 录制重放)、测试分片与顺序随机化对不稳定的暴露、真实代码库里的 flaky 比例
    京ICP备2026067502号-1京公网安备11010502063487号