把「时间」当依赖注入:Clock 抽象 + JUnit 5 Extension 让 31 个时间敏感用例告别 flaky
结论先行:时间是测试里最容易被忽视的隐式全局依赖。
new Date()/Instant.now()/LocalDateTime.now()这些"随手一调"的 API,本质是把一个外部系统(操作系统时钟)当作黑盒依赖,而黑盒依赖恰恰是 flaky 的第一来源。把时间收口成可注入的ClockProvider,用例内推进而不是等真实时间,31 个"时间敏感"用例的偶现失败率从 12% 直接归零。
一、现象与背景:31 个用例,一半的 flaky 都跟"时间"有关
先摆数字。我们一条 B 端 SaaS 的订单+营销+结算链路,接口自动化仓库约 1800 个用例(Java 17 + JUnit 5.10.2 + Spring Boot 3.1.5 + Testcontainers PostgreSQL 15.6),CI 用 GitLab Runner 2 台 4C8G 并行跑,全量一次约 58 分钟。2025 年 2 月到 5 月这三个月里:
- 全量回归平均每周失败 3–5 次,其中约 14 个失败直接跟"时间"强相关,涉及 31 个用例(订单超时关单、优惠券到期、营销活动跨日、对账 T+1 结算、限流窗口滑动);
- 这 31 个用例单独跑通过率 98%+,但混在套件里、或跨 0 点跑、或赶上美国夏令时切换(3/10、11/2)那两天,失败率能飙到 20%–35%;
- 最典型的一次:2025-03-10 凌晨 2 点 CI 跑到「订单超时关单」套件,11 个用例挂了 9 个,断言全是「期望订单 CLOSED,实际 PENDING」——因为定时任务用的
LocalDateTime.now(ZoneId.of("America/New_York"))在夏令时切换那 60 分钟里,"1 小时"实际只有 59 分钟(或 61 分钟),滑动窗口判定直接错位。
当时的应对很粗暴:@Retry、Thread.sleep(1000)、把断言里 now() - 30min 放宽到 now() - 31min。结果就是 622 那位同学说的——时区是展示层概念,而我们这里更根本:时间本身不该是"取一下就行"的免费资源。
二、原理与根因:时间是一个"外部系统"
把代码里的时间调用分类,只有三种合法姿势:
- 取当前时刻:
Instant.now()/System.currentTimeMillis()/LocalDateTime.now(zone); - 计算时长:
Duration.between(a, b); - 日历运算:
LocalDate.plusDays(1)、ZonedDateTime.plusHours(1)(会触发 DST 跳变)。
问题在 1 和 3:它们都依赖"操作系统时钟"这个外部系统,而测试的"确定性"要求所有输入都是可复现的。外部时钟不满足这个要求:
- 精度抖动:
System.currentTimeMillis()在 Linux 上是CLOCK_REALTIME,NTP 校时会让它回拨(我实测一次回拨过 320ms); - 时区/DST 跳变:
ZonedDateTime.plusHours(1)在America/New_York的 3/10 02:00 会得到 03:00 而不是 03:00——1 小时 = 59 分钟; - 跨日边界:
LocalDate.now()在 UTC+8 与 UTC-5 之间差 13 小时,0 点前后跑同一用例,"今天"根本不是同一天; - 闰秒:
System.currentTimeMillis()在闰秒时会出现毫秒数倒退 1000ms 的现象(罕见但存在)。
所以根因就一句话:你把"当前时间"当成输入参数,但它其实是个外部依赖。外部依赖的正确处理方式是注入 + 可替换,而不是"取一下"。
三、具体做法:ClockProvider + JUnit 5 Extension,三步走
1) 定义一个极简的 ClockProvider 接口
public interface ClockProvider {
Instant now();
ZoneId zone(); // 业务时区,避免各处 ZoneId.systemDefault()
ZonedDateTime nowInZone(ZoneId zone);
LocalDateTime nowLocal(); // 便捷方法,内部走 zone()
}
生产实现就一行:
public class SystemClock implements ClockProvider {
private final ZoneId zone;
public SystemClock(ZoneId zone) { this.zone = zone; }
public Instant now() { return Instant.now(); }
public ZoneId zone() { return zone; }
public ZonedDateTime nowInZone(ZoneId z) { return ZonedDateTime.now(Clock.system(z)); }
public LocalDateTime nowLocal() { return ZonedDateTime.now(zone).toLocalDateTime(); }
}
Spring 里注入:
@Configuration
public class ClockConfig {
@Bean
public ClockProvider clockProvider() {
return new SystemClock(ZoneId.of("UTC")); // 存储统一 UTC,与 622 一致
}
}
关键纪律:全仓库 grep -RnE "Instant\.now|System\.currentTimeMillis|LocalDateTime\.now|LocalDate\.now|ZonedDateTime\.now" 必须清零(除 SystemClock 自身)。我们用 ArchUnit 规则兜底:
@ArchTest
static final ArchRule no_direct_now =
noClasses().should().callMethodWhere(
rawInstance(Instant.class, "now")
.or(rawInstance(LocalDateTime.class, "now")
.or(rawInstance(System.class, "currentTimeMillis")))
.because("时间必须走 ClockProvider");
2) 测试用 ManualClock,可推进、可跨 DST
public class ManualClock implements ClockProvider {
private final Clock clock;
private final ZoneId zone;
public ManualClock(Instant start, ZoneId zone) {
this.clock = Clock.fixed(start, zone);
this.zone = zone;
}
public void tick(Duration d) { /* 推进,内部用 Clock.offset 或重建 fixed */ }
public void setDST(ZoneId zone) { /* 换 zone,模拟 DST 切换 */ }
// ... 其余方法委托 clock
}
3) JUnit 5 Extension 自动注入 + 用例内推进
public class ManualClockExtension implements ParameterResolver, BeforeEachCallback {
private static final ThreadLocal<ManualClock> TL = new ThreadLocal<>();
// 默认起点:2025-01-15T10:00:00Z,东八区
// 用例里注入 ManualClock 参数即可
}
用例写法对比:
// ❌ 旧写法:靠 Thread.sleep 等真实时间,跨 0 点必挂
@Test void orderTimeoutClose() throws Exception {
Thread.sleep(31 * 60 * 1000); // 5 分钟,CI 上经常 10 分钟还没到
assertThat(orderService.query("O-1")).isEqualTo(CLOSED);
}
// ✅ 新写法:时间是被测对象,推进即断言
@Test void orderTimeoutClose(ManualClock clock) {
Order o = orderService.create("O-1");
clock.tick(Duration.ofMinutes(31)); // 30 分钟超时 + 1 分钟余量
orderScheduler.tickOnce(); // 手动触发一次定时任务
assertThat(orderService.query("O-1")).isEqualTo(CLOSED);
}
对「跨 DST」场景,直接 clock.setDST(ZoneId.of("America/New_York")) 后 tick(Duration.ofHours(1)),就能复现"1 小时 = 59 分钟"的坑——这是任何 Thread.sleep 都模拟不出来的。
4) 并行隔离:用 ThreadLocal,但别用虚拟线程
我们 CI 开 junit.jupiter.execution.parallel.enabled=true,并行度 4。ManualClock 用 ThreadLocal 隔离,每个用例线程一个实例。
踩坑:2025 年 4 月一次重构把 orderScheduler 里的任务切到了 virtual-thread(Thread.ofVirtual()),结果 TL.get() 返回 null——虚拟线程是 OS 线程,ThreadLocal 不跨线程共享,而且虚拟线程池复用底层 carrier,同一个 carrier 上跑不同用例的虚拟线程会串。最后改回 ForkJoinPool.commonPool() + InheritableThreadLocal,并把"时间推进"改为显式参数传递(tickOnce(clock)),彻底不依赖 ThreadLocal。
四、踩过的坑 / 反直觉之处
- 别用 Mockito 直接 mock
Instant.now()。Instant.now()是 static,需要mockito-inline4.x +mockStatic,而且 mock 全局后其他线程也会受影响,并行下直接串。走接口注入永远比 mock 静态方法干净。 Thread.sleep不是"等时间",是"等墙钟"。CI 机器负载高时,sleep(60s)实际可能耗 90s,而你要测的是"业务逻辑在 60s 后正确",不是"60s 后"。- DST 切换那天,
plusHours(1)不等于plusMinutes(60)。前者按日历算(跨 DST 会跳过 1 小时),后者按绝对时长算。业务代码里这两个 API 混用是灾难,我们统一走plusMinutes(60)表示"绝对时长",plusHours(1)表示"日历小时",ArchUnit 规则区分开。 LocalDateTime没有时区,但你的业务有时区。945 那位同学的 Fixture 里LocalDateTime.now().minusDays(30)就踩了这个坑——LocalDateTime.now()用的是 JVM 默认时区(CI 机器可能是 UTC,本地是 +8),同一个用例在 CI 和开发机上的"30 天前"差了 8 小时。所有LocalDateTime都必须从ClockProvider.nowLocal()拿,绝不允许LocalDateTime.now()裸调。- 断言别用
now()比较,用"推进量"比较。assertThat(order.createdAt).isBefore(clock.nowLocal().minusMinutes(30))这种断言在 CI 机器慢时会挂——改用assertThat(clock.millisSince(order.createdAt)).isGreaterThanOrEqualTo(30 * 60 * 1000),只比较"从订单创建到现在的推进量",不依赖绝对时刻。
五、结论与可复用清单
3 周落地,31 个时间敏感用例的偶现失败率从 12% 归零,CI 全量耗时从 58 分钟压到 41 分钟(去掉了 17 个 Thread.sleep)。可复用清单:
- [ ] 定义
ClockProvider接口,生产用SystemClock,测试用ManualClock(支持tick/setDST); - [ ] 全仓库禁裸调
Instant.now/System.currentTimeMillis/LocalDateTime.now,ArchUnit 规则兜底; - [ ] 定时任务(
@Scheduled/ Quartz)的"当前时刻"也走ClockProvider,用例里手动触发一次而不是等真实调度; - [ ] 并行测试用
ThreadLocal隔离ManualClock,别用虚拟线程; - [ ] 断言用"推进量"而不是"绝对时刻"比较;
- [ ] DST 切换场景必须写专项用例(
setDST+tick(1h)),这是Thread.sleep永远模拟不出来的; - [ ] 时区存储统一 UTC(与 622 一致),业务时区在
ClockProvider.zone()里声明,展示层再转。
一句话总结:时间不是免费的,它是一个外部依赖。像对待数据库、HTTP 客户端一样对待它——注入、可替换、可推进、可断言。