Appearance
当前项目生产环境执行单元测试时间大概1032s,大概 17 分钟左右

预期效果是将这个时间优化到 8 分钟左右。
排查定位问题
排查分支
- feature/unit-test-fix
- feature/unit-test-fix-2
一、排查思路:从 CI 日志入手分解耗时
拿到 CI 构建日志后,第一步是按时间线拆解各阶段耗时,定位瓶颈集中在哪个环节:
任务开始
│ Pod 初始化 + 代码克隆
Maven 开始
│ 编译阶段(各模块依次编译)
测试开始
│ 测试执行阶段 ← 通常是大头
BUILD SUCCESS
│ JaCoCo 报告生成
覆盖率解析
│ CI 平台后处理(上传、统计等)
结果上报完成关键分析方法:
- 找最慢的测试类:从日志中提取
Time elapsed: xxx s -- in xxx信息,按耗时降序排列 - 找静默时间段:两行日志之间的时间差如果超过 30s 且无输出,说明有隐藏耗时
- 对照 Reactor Summary:Maven 会打印每个模块的总耗时,快速定位是编译慢还是测试慢
二、常见耗时根因
根因 1:测试中存在真实的 Thread.sleep()
现象:某个测试类耗时远超其他类(如 177s vs 其他类平均 3-5s)
原因:测试重试逻辑时直接调用了生产代码中的真实 sleep。例如重试退避策略 3s → 10s → 30s,一个测试用例就要等 43s,多个重试场景累加后时间爆炸。
修复方案:将硬编码的退避时间提取为可注入字段,测试时通过 ReflectionTestUtils 覆盖为 0:
java
// 生产代码:提取为字段
private long retryBackoffMs = 30_000L;
// 测试代码:@BeforeEach 中覆盖
ReflectionTestUtils.setField(target, "retryBackoffMs", 0L);根因 2:CI 平台后处理的静默期
现象:BUILD SUCCESS 之后到"结果上报"之间有数分钟完全无日志
原因:CI 平台插件在做覆盖率聚合、行数统计、报告上传等操作,但没有 stdout 输出
应对:这部分不在研发可控范围,需要和 CI 平台团队确认是否可优化或增加日志
根因 3:JVM Fork 配置不合理
现象:整体测试时间偏长,但没有单个特别慢的测试类
原因:Surefire 的 reuseForks=false 导致每个测试类都启动一个新 JVM,JVM 启动开销(1-2s)× 测试类数量,累加可观
三、Maven Surefire 关键配置详解
xml
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>2</forkCount>
<reuseForks>true</reuseForks>
<parallel>classes</parallel>
</configuration>
</plugin>forkCount、reuseForks、parallel 三者关系
| 参数 | 控制维度 | 说明 |
|---|---|---|
forkCount=2 | 进程级并行 | 同时启动 2 个 JVM 进程跑测试 |
reuseForks=true/false | JVM 生命周期 | true=复用 JVM,false=每个测试类新建 JVM |
parallel=classes | 线程级并行(JVM 内部) | 每个 JVM 内多个测试类可并发执行 |
reuseForks=false 的历史原因与现状
通常设置 reuseForks=false 是因为 MockedStatic 泄漏问题——如果 mock 了静态方法但没有 close,会污染同一 JVM 内后续测试类。这是一种防御性配置。
判断是否可以改回 true:扫描代码中所有 MockedStatic 使用点,确认全部采用 try-with-resources 写法(自动 close)。如果是,则隔离的防御价值已不存在,可以安全改回 true。
java
// 安全写法:try 结束自动 close,不会污染其他测试
try (MockedStatic<SomeClass> mock = mockStatic(SomeClass.class)) {
// ...
}reuseForks=true 的前置条件
改为 true 后可能触发 byte-buddy 的跨测试类状态污染 bug,表现为大量 NPE:
Cannot invoke "[Ljava.lang.Class;.clone()" because "<local2>.parameterTypes" is null解决方案:升级 byte-buddy 和 mockito 版本:
xml
<properties>
<byte-buddy.version>1.17.8</byte-buddy.version>
<mockito.version>5.18.0</mockito.version>
</properties>⚠️ 注意:reuseForks=true 和版本升级是捆绑关系,缺一不可。
四、argLine 配置说明
xml
<argLine>--add-exports java.base/jdk.internal.module=ALL-UNNAMED -Xmx1536m</argLine>| 参数 | 作用 |
|---|---|
--add-exports java.base/jdk.internal.module=ALL-UNNAMED | JDK 17 模块化系统下,允许 byte-buddy/Mockito 访问 jdk 内部 API |
-Xmx1536m | 给测试 JVM 分配 1.5G 堆内存,防止 reuseForks=true 时堆累积 OOM |
作用域:argLine 仅在 mvn test 的 Surefire fork JVM 中生效,不会编译进 jar,对生产 JVM 零影响。
JaCoCo 的 prepare-agent 会读取 ${argLine} 属性并在前面追加自己的 -javaagent,两者叠加互不冲突。
五、byte-buddy 升级对生产环境的影响评估
各依赖的 scope
| 依赖 | scope | 是否进生产包 |
|---|---|---|
| mockito-core | test | ❌ 不进 |
| byte-buddy-agent | test | ❌ 不进 |
| byte-buddy | runtime | ✅ 会进 |
byte-buddy 在生产中的角色
- Hibernate:生成懒加载代理对象
- Spring AOP:生成 CGLIB 动态代理(
@Transactional、@Async等切面) - Spring Data:Repository 接口代理
升级风险
byte-buddy 向后兼容性好,但如果版本不在 Spring Boot BOM 的验证矩阵内,理论上可能出现:
- 代理生成失败(应用启动报错,立即可发现)
- AOP 切面失效(
@Transactional不生效,最隐蔽)
建议发布策略
- 测试环境全量发布,验证应用启动和功能回归
- 生产环境根据测试结果决定:有问题就退回
reuseForks=false(仅保留 sleep 修复)
六、优化效果预估
| 优化项 | 原理 | 预计节省 |
|---|---|---|
| 消除测试中的真实 sleep | 重试退避时间设为 0 | ~175s |
| reuseForks=true | 省去每个测试类重新启动 JVM 的开销 | ~60-90s |
| 合计 | ~235-265s |
七、总结:排查清单
当项目单元测试执行时间过长时,按以下顺序排查:
- 找最慢的测试类 → 看是否存在真实 sleep/网络调用/大数据构造
- 找 CI 日志中的静默时间段 → 确认是否为平台侧开销
- 检查 Surefire 配置 → forkCount/reuseForks/parallel 是否合理
- 检查 MockedStatic 写法 → 是否全部 try-with-resources,决定能否 reuseForks=true
- 验证依赖版本兼容性 → byte-buddy 升级后需确认生产无影响