Skip to content

Java知识体系

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

image.png

预期效果是将这个时间优化到 8 分钟左右。

排查定位问题

排查分支

  • feature/unit-test-fix
  • feature/unit-test-fix-2

一、排查思路:从 CI 日志入手分解耗时

拿到 CI 构建日志后,第一步是按时间线拆解各阶段耗时,定位瓶颈集中在哪个环节:

任务开始
    │  Pod 初始化 + 代码克隆
Maven 开始
    │  编译阶段(各模块依次编译)
测试开始
    │  测试执行阶段 ← 通常是大头
BUILD SUCCESS
    │  JaCoCo 报告生成
覆盖率解析
    │  CI 平台后处理(上传、统计等)
结果上报完成

关键分析方法:

  1. 找最慢的测试类:从日志中提取 Time elapsed: xxx s -- in xxx 信息,按耗时降序排列
  2. 找静默时间段:两行日志之间的时间差如果超过 30s 且无输出,说明有隐藏耗时
  3. 对照 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/falseJVM 生命周期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-UNNAMEDJDK 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-coretest❌ 不进
byte-buddy-agenttest❌ 不进
byte-buddyruntime✅ 会进

byte-buddy 在生产中的角色

  • Hibernate:生成懒加载代理对象
  • Spring AOP:生成 CGLIB 动态代理(@Transactional@Async 等切面)
  • Spring Data:Repository 接口代理

升级风险

byte-buddy 向后兼容性好,但如果版本不在 Spring Boot BOM 的验证矩阵内,理论上可能出现:

  • 代理生成失败(应用启动报错,立即可发现)
  • AOP 切面失效(@Transactional 不生效,最隐蔽)

建议发布策略

  1. 测试环境全量发布,验证应用启动和功能回归
  2. 生产环境根据测试结果决定:有问题就退回 reuseForks=false(仅保留 sleep 修复)

六、优化效果预估

优化项原理预计节省
消除测试中的真实 sleep重试退避时间设为 0~175s
reuseForks=true省去每个测试类重新启动 JVM 的开销~60-90s
合计~235-265s

七、总结:排查清单

当项目单元测试执行时间过长时,按以下顺序排查:

  1. 找最慢的测试类 → 看是否存在真实 sleep/网络调用/大数据构造
  2. 找 CI 日志中的静默时间段 → 确认是否为平台侧开销
  3. 检查 Surefire 配置 → forkCount/reuseForks/parallel 是否合理
  4. 检查 MockedStatic 写法 → 是否全部 try-with-resources,决定能否 reuseForks=true
  5. 验证依赖版本兼容性 → byte-buddy 升级后需确认生产无影响