作者:谢星河-288|测试开发|15 年,关注工程规范与可维护性 栈:Java 17 + JUnit 5.10.2 + Mockito 5.11.0 + Spring Boot 3.1.5,CI 用 GitLab Runner(2 台 4C8G),并行度 4(
junit.jupiter.execution.parallel.config.fixed.parallelism=4)
一、现象与背景:单跑必绿、全量偶红,重跑顺序一变就搬家
先给一组数字。我维护一条结算链路的接口测试套件,1400 个用例,2025 年 Q1 三个月里:
- 全量回归每周 8–14 次「单跑通过、全量失败」的偶现,其中 7 类失败签名反复出现(金额格式化串位、时区偏移 8 小时、某静态 mock 返回上一个用例的 stub 值、临时目录文件残留导致
FileAlreadyExistsException等); - 最贵的一次:一条「汇率换算」用例在周一红、周二绿、周三又在另一个用例上红——失败点会「搬家」,团队当时归因是「用例顺序依赖」,排查了 3 天没定位;
- 累计人工排查成本 200+ 分钟/月,且 2 次真回归被「重跑变绿」掩盖,直到线上报警才回头补用例。
关键判断:这些失败与用例执行顺序强相关,但根因不是用例之间的数据依赖(数据依赖我们上一轮已经用 schema 隔离 + 事务回滚治掉了),而是测试代码本身在 JVM 进程内制造了「副作用泄漏」——前一个用例改了全局状态,后一个用例在不知情的前提下读到了脏值。
二、根因分析:为什么一个 JVM 进程里会「串味」
JUnit 5 默认把整个测试模块跑在同一个 JVM、同一批 ClassLoader 里,所有用例共享四块「全局内存」:
- 系统属性(
System.getProperties())——任何线程、任何时刻改,全进程可见; - Locale / TimeZone 默认值(
Locale.setDefault、TimeZone.setDefault)——被SimpleDateFormat、NumberFormat、DateTimeFormatter隐式消费; - 静态字段与静态 mock(
Mockito.mockStatic、单例的 static 成员)——生命周期 = 类加载期到 JVM 退出; - ThreadLocal 与线程池——并行执行时,工作线程被复用于不同用例。
叠加两个放大器,泄漏就从「逻辑 bug」变成「概率性 flaky」:
- JVM 启动顺序非确定:surefire 在 CI 上的类加载顺序受文件系统、并行调度影响,同一套代码,周一和周三的「谁先谁后」可以不同——这就是失败点「搬家」的机制;
- JIT + GC 停顿:4C8G 的 runner 上 1400 个用例的老年代在跑到 800 用例左右会触发 Full GC(实测 STW 峰值 210ms),此时正在
Thread.sleep或轮询等待的用例,窗口被整体平移,原本 200ms 能等到、变成 250ms 才等到——sleep 型断言对 GC 停顿天然敏感。
一句话:测试代码里任何一处「写全局、读全局」,都在把「用例独立」这个前提悄悄打破;JIT 与调度只是让打破的时机变得不可预测。
三、六类泄漏清单 + 可复现的扫描与修复
3.1 泄漏清单(按我们踩中频率排序)
| # | 泄漏源 | 典型症状 | 修复 |
|---|--------|---------|------|
| 1 | Locale.setDefault / TimeZone.setDefault 无恢复 | 金额小数点变逗号、日期偏移 ±8h | 用 Extension 快照/恢复 |
| 2 | System.setProperty 无恢复 | 后一个用例读到前一个的配置 | 同上,快照 Map |
| 3 | Mockito.mockStatic 未 close | 静态方法返回上用例 stub 值 | try-with-resources + 全局扫描 |
| 4 | ThreadLocal.set 未 remove | 并行下跨用例串值 | 统一 wrapper + @AfterEach remove |
| 5 | 临时目录/文件残留 | FileAlreadyExistsException | @TempDir 或唯一 UUID 前缀 |
| 6 | 静态单例内部状态(缓存、计数器) | 计数类断言差 1 | 提供 resetForTest() 并在 @BeforeAll 调用 |
3.2 用 ArchUnit 把「禁止写全局」变成编译期规则
@AnalyzeClasses(packages = "com.example.it")
class TestHygieneRules {
@ArchTest
static final ArchRule tests_must_not_set_default_locale =
noClasses().that().resideInAPackage("..it..")
.should().callMethod(Locale.class, "setDefault", Locale.class)
.as("用例禁止直接调用 Locale.setDefault,请用 LocaleGuardExtension");
@ArchTest
static final ArchRule tests_must_not_set_system_property =
noClasses().that().resideInAPackage("..it..")
.should().callMethodWhere(
target().isRawType(System.class)
.and().name().anyOf("setProperty", "clearProperty"))
.as("用例禁止改 System property,请用 PropertyGuardExtension");
@ArchTest
static final ArchRule static_mock_must_be_scoped =
noClasses().should().callMethodWhere(
target().isRawType(Mockito.class).and().name().isEqualTo("mockStatic"))
.allowingChildren(0)
.as("mockStatic 只允许出现在白名单类(如 Clock 门面)");
}
实测:这三条规则在 1400 个用例里一次性揪出 11 处违规(8 处 Locale.setDefault、2 处 setProperty、1 处裸 mockStatic),其中 2 处正是周一/周三「搬家」失败的元凶。
3.3 两个「快照-恢复」Extension(可直接抄)
public class LocaleGuardExtension implements BeforeEachCallback, AfterEachCallback {
private Locale locale; private TimeZone tz;
@Override public void beforeEach(ExtensionContext ctx) {
locale = Locale.getDefault(); tz = TimeZone.getDefault();
Locale.setDefault(Locale.ROOT); TimeZone.setTimeZone("UTC"); // 统一基线
}
@Override public void afterEach(ExtensionContext ctx) {
Locale.setDefault(locale); TimeZone.setTimeZone(tz);
}
}
PropertyGuardExtension 同理:beforeEach 里 System.getProperties().entrySet().stream().filter(e -> e.getKey().startsWith("app.")) 快照,afterEach 逐条 put 回原值、clearProperty 删新增项。注意恢复的是「原值」而不是「删除」——生产默认值本来就可能存在,删了反而引入新泄漏。
3.4 ThreadLocal 的统一出口
public final class BizContext {
private static final ThreadLocal<UserRef> H = new ThreadLocal<>();
public static void set(UserRef r) { H.set(r); }
public static UserRef get() { return H.get(); }
public static void clear() { H.remove(); } // 唯一允许 remove 的入口
}
配一条 JUnit @AfterEach 全局钩子(@ExtendWith(RegisteredExtension) 或 surefire 的 runOrder 无关,直接用 JUnitPlatform.launcher 的 TestExecutionListener):
public class ThreadLocalSweeper implements TestExecutionListener {
@Override public void executionFinished(TestIdentifier id, ExecutionResult r) {
BizContext.clear();
// 其他 ThreadLocal 统一在这里清
}
}
3.5 临时文件的两个铁律
- 永远用
@TempDir Path tmp,不要new File("/tmp/xxx")固定名——CI 上并行度 4 时,两个用例同秒起同名文件,FileAlreadyExistsException直接翻车(我们踩过 2 次); - 必须用固定路径时(某些 SDK 只读配置目录),前缀加
UUID.randomUUID(),并在@AfterEach里递归删——删失败要fail(),不能吞,否则残留文件在下一轮 CI 里继续污染。
四、踩过的坑(反直觉清单)
- ❌
@AfterEach里恢复 Locale 没用——因为泄漏发生在用例中途抛异常时,@AfterEach会执行,但如果用例在静态初始化块里就改了 Locale(比如某个工具类的static { Locale.setDefault(...) }),@AfterEach恢复的是「被污染后」的默认值,等于没恢复。正解:@BeforeAll里强制Locale.setDefault(Locale.ROOT)打基线,再快照。 - ❌ Mockito 的
mockStatic用@MockitoSettings或字段注入——字段注入的 static mock 生命周期跟着 mock 对象走,一旦对象被 GC 或复用,行为不可预期。正解:try (MockedStatic<X> m = Mockito.mockStatic(X.class)) { ... }作用域化,且用 ArchUnit 白名单锁住调用点。 - ❌ 以为「每个用例一个新线程」就隔离了 ThreadLocal——JUnit 并行用的是固定线程池(默认 = CPU 核数),线程被复用,ThreadLocal 值会跨用例存活。必须显式
remove()。 - ❌ 在
@BeforeAll里造数据、在@AfterAll里清数据——并行度 >1 时,A 类的@AfterAll可能在 B 类的@BeforeEach之后执行,直接踩到 B 正在用的数据。数据生命周期必须 ≤ 单个用例(@BeforeEach/@AfterEach),或用例级事务回滚。 - ❌ 用
Thread.sleep(200)等异步结果——Full GC 一次 210ms 就能让 5 个 sleep 型用例同时超时。换成Awaitility或自研轮询(间隔 10ms、上限 3s),把「等待」从时间维度换成条件维度。 - ✅ 一个验证手段:CI 上加一条
mvn verify -Dsurefire.runOrder=reversealphabetical的反向顺序流水线(每周跑一次,不阻塞 MR),专门抓「顺序相关」的隐性耦合。我们上线这条后第 2 周就抓到 3 个正序永远不红的用例。
五、结论与可复用清单
核心结论:flaky 里有一大类不是「环境不稳定」,而是测试代码自己污染了进程内全局状态,只是 JIT 与调度把污染时机变成了概率事件。治法不是「重跑」,而是把「禁止写全局」变成静态规则 + 把「必须写全局」收口到两个快照-恢复 Extension。
可复用清单(按落地成本从低到高):
- ArchUnit 三条规则(半小时):禁
Locale.setDefault、禁System.setProperty、mockStatic白名单——立刻把「新增泄漏」挡在 MR 阶段; - 两个 Guard Extension(1 小时):
LocaleGuard+PropertyGuard,统一基线为Locale.ROOT/UTC,快照-恢复原值; - ThreadLocalSweeper(30 分钟):全局
TestExecutionListener统一remove(); @TempDir+ UUID 前缀(按用例改):禁止固定路径,残留文件fail()不吞;sleep→ 条件轮询(按用例改):Awaitility或自研 10ms/3s 轮询;- CI 反向顺序流水线(每周 1 次):抓正序永不红的隐性耦合;
- 静态单例
resetForTest()(需业务配合):计数、缓存类单例必须暴露重置接口,@BeforeAll调用。
一句话:用例独立性的前提是「不写全局」;做不到「不写」,就必须做到「写完必恢复,且恢复的是原值」。把这条写进团队测试规范,比任何「重跑脚本」都便宜。
参考:
- 站内 @谢景行-248《WireMock 桩服务"状态残留":stub 映射累积 + scenarios 状态机串味》——同样是「进程内状态残留」家族,我这篇聚焦 JVM 全局内存,与桩服务侧互补;
- 站内 @谢清和《治理 CI 里的 flaky test》——其「假失败成本模型」是我立项做这次治理的直接输入。