先问个问题:你现在的多模块 Spring Boot 项目里,单元测试到底写在哪个模块?如果你和我刚入行时一样,回答“就写在各自模块的 src/test/java 里啊”,那你大概率已经踩过两个坑:一个是模块之间互相依赖,要测 A 模块的 Service,必须把 B、C 的依赖也一起拖进测试 classpath,测试代码最后写成了一坨耦合体;另一个是团队 CI 每次跑完所有模块的 test,耗时从三分钟飙到二十分钟,改一行底层代码,全仓库跟着重新跑一遍。
后来我把测试代码从各业务模块里拆出来,单独建了一个“聚合测试模块”,这些问题基本都解决了。这篇文章就讲清楚这套做法:为什么要单独建模块、POM 怎么设计、目录结构怎么组织、真实案例怎么做,以及我踩过的坑。
1. 为什么要在多模块项目里单独建一个聚合测试模块
1.1 多模块项目里单元测试的常见困境
Maven 多模块解决的是代码编译顺序和依赖复用问题:parent 管理版本,各模块按依赖关系依次构建。但测试代码往往没有跟着一起做“职责分层”,谁写的测试就放在谁的业务模块里。
单模块项目里这么干没问题,一旦拆成多模块,问题就一个接一个冒出来。第一个问题叫依赖扩散。假设你有 user-core、order-core、order-web 三个模块,order-web 依赖 order-core,order-core 依赖 user-core。要给 order-web 里某个 Controller 写测试,测试里要用到 user-core 的某个实体类,你就得让 order-web 的 pom 里直接声明 user-core 依赖,哪怕业务代码里 order-web 根本没直接用过它。这就是为了测试而污染业务模块的依赖树,时间一长,模块边界全是窟窿。
第二个问题叫测试代码无法复用。写几个模块以后你一定会发现,大家都在写自己的 BaseTest、MockBean 工厂、JSON 工具类、随机数据生成器。想抽出来共享?放在哪个模块都不合适——放 user-core 里,order-web 想用就得额外依赖 user-core;放一个 common-test 模块里,等于为了让测试共用几个类,整个项目多出一个始终没人愿意维护的“公共垃圾桶”。
第三个问题更隐蔽,但影响最大:单测和集成测试混在一起,构建时间不可控。Spring Boot 项目里随便一个 @SpringBootTest 启动就要几秒钟,如果所有模块的业务代码都把这种测试放进 surefire 默认执行的 test 阶段,那 Maven 生命周期里每个模块 install 时都会触发一遍全量测试。字符串工具类改了一行注释,结果 CI 把十几个模块的数据库集成测试全部跑完才算完。
第四个问题则是覆盖率统计失真。每个模块单独统计自己的测试覆盖率很简单,但你要的是“全项目核心业务的整体覆盖率”。多个模块各自生成 jacoco.exec,想要合并,还得额外搞配置,稍不留神就漏掉某个模块的数据。
1.2 聚合测试模块到底解决了什么
聚合测试模块的思路,一句话说清楚就是把“测试”本身当成一个独立的产物来看待。业务模块只负责被编译、被依赖,谁都不允许在里面写测试代码;所有测试类、测试基类、测试工具类、测试配置,全部集中在一个名为 test-aggregation(名字随便,我喜欢这个)的模块里。
这样做的好处是立竿见影的。业务模块的 pom 非常干净,只声明真正用到的业务依赖,不会因为测试引入任何无关模块;测试模块可以放心依赖所有被测模块,反正它不进生产包;测试基类写一次,所有业务测试都能继承;CI 里只需要重点跑聚合测试模块的测试,其他模块用 -DskipTests 跳过,因为反正里面也没有测试。
还有一个容易被忽视的好处:团队新成员进来,想了解“这个项目怎么测的”,不用翻遍每个模块的测试目录,打开 test-aggregation 模块就全明白。测试代码的入口和责任边界变得清晰明确。
| 维度 | 测试写在各自业务模块 | 独立聚合测试模块 |
|---|---|---|
| 业务模块依赖 | 被测试需求污染,边界模糊 | 只保留真正的业务依赖 |
| 测试基类复用 | 互相复制,维护困难 | 统一维护,所有测试继承 |
| CI 构建速度 | 所有模块重复跑测试 | 业务模块跳过,集中跑测试 |
| 覆盖率合并 | 需要额外配置 | 天然聚合,按模块分开 |
| 测试入口 | 分散混乱 | 单一入口,结构清晰 |
2. 构建聚合测试模块:POM 设计思路
2.1 模块注册与基础依赖策略
第一步当然是在根 pom 的 modules 里加上聚合测试模块。这里有个顺序问题需要注意:聚合测试模块要放在最后,这样 Maven 在 reactor 构建时,它会最后编译,确保前面所有业务模块都已经构建完毕。
<modules> <module>user-core</module> <module>order-core</module> <module>order-web</module> <module>test-aggregation</module> </modules>然后是 test-aggregation 模块自身的 pom。里面的依赖策略是整个方案的核心,也是最容易搞错的地方。核心原则:把所有你需要在测试时拿到 classpath 的被测模块,全部声明到这个模块的依赖里。
<dependencies> <!-- 被测业务模块 --> <dependency> <groupId>com.example</groupId> <artifactId>user-core</artifactId> </dependency> <dependency> <groupId>com.example</groupId> <artifactId>order-core</artifactId> </dependency> <dependency> <groupId>com.example</groupId> <artifactId>order-web</artifactId> </dependency> <!-- 测试框架,版本统一由父 pom 管理 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>这里我建议被测模块的依赖不写 scope,默认 compile 即可。理由很实际:聚合测试模块的所有代码都在 src/test/java 里,编译测试代码时如果被测模块是 test scope,在复杂依赖链下偶尔会报“找不到类”的诡异问题,而 compile scope 永远不会有这种问题。反正这个模块不会被打进生产包,不需要刻意省那一点依赖体积。
2.2 共享测试依赖:不在每个业务模块里重复引入 starter-test
我见过太多多模块项目,每个业务模块的 pom 里都来一遍 spring-boot-starter-test,版本还经常不统一。独立聚合测试模块以后,这个依赖只出现在 test-aggregation 一个地方,父 pom 的 dependencyManagement 里把版本定死,干净利落。
如果你还需要数据库测试,建议在这个模块里单独引入 H2、Testcontainers 或者嵌入式 Redis,同样只在此处管理。比如我经常加的两个:
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency>很多业务模块的 pom 里之所以有各种 test 依赖,往往是早年“为了测试方便”硬塞进去的。改成聚合模块后,顺手把业务模块里的 test scope 依赖全部清掉,业务模块的依赖声明就回归了它本来该有的样子。
2.3 surefire 和 failsafe 插件:单测与集成测试分开跑
聚合测试模块跑测试时,最关键的插件配置是 surefire 和 failsafe 的分工。默认情况下,surefire 会执行所有名字以 Test 结尾的类,@SpringBootTest 和 MockMvc 这种重测试也会被它执行,结果就是跑一次测试模块要等很久。
我的习惯是:纯单元测试类用 Test 结尾,交给 surefire;需要启动完整 Spring 上下文的集成测试类用 IT 结尾,交给 failsafe。failsafe 在 Maven 生命周期里属于 integration-test 阶段,可以在测试前起服务、测试后清理环境,更适合重测试。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> <configuration> <includes> <include>**/*Test.java</include> </includes> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-failsafe-plugin</artifactId> <version>3.2.5</version> <executions> <execution> <goals> <goal>integration-test</goal> <goal>verify</goal> </goals> </execution> </executions> <configuration> <includes> <include>**/*IT.java</include> </includes> </configuration> </plugin>这样做还有个实际收益:日常开发跑 mvn test 时只跑轻量单测,秒级完成;需要验证整体功能时再跑 mvn verify,把集成测试一起带上,测试生命周期井然有序。
3. 环境搭建:目录结构、启动类与测试基类
3.1 推荐的目录结构
聚合测试模块本身不参与业务编译,所以它的代码只能出现在 src/test/java 里。目录结构我习惯这样组织:
test-aggregation/ ├── pom.xml ├── src/test/java │ └── com/example/test │ ├── base │ │ ├── BaseUnitTest.java │ │ ├── BaseIntegrationTest.java │ │ └── TestApplication.java │ ├── common │ │ ├── JsonUtils.java │ │ └── MockDataFactory.java │ └── cases │ ├── unit │ │ ├── user │ │ │ └── UserServiceTest.java │ │ └── order │ │ └── OrderPriceCalculatorTest.java │ └── integration │ └── order │ └── OrderControllerIT.java └── src/test/resources └── application-test.ymlcases 目录下按被测模块分包,unit 和 integration 分开放,跟 surefire、failsafe 的命名规范天然对应。单位里的测试代码虽然都堆在同一个模块,但看目录就能立刻定位到对应业务模块的测试,不存在找不到的问题。
3.2 聚合模块自己要有 TestApplication 启动类
这是很多人第一次改造时最懵的地方:业务模块里有 Application 启动类,聚合测试模块里没有,@SpringBootTest 怎么扫描 Bean?
解决办法是给聚合测试模块自己写一个测试专用启动类:
package com.example.test; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; @SpringBootApplication @ComponentScan(basePackages = { "com.example.user", "com.example.order" }) public class TestApplication { }注意这里的 ComponentScan 必须显式声明要扫描哪些业务包的组件,不能只写一个大包名兜底。原因很简单:聚合模块的 classpath 里有三四个业务模块,它们可能暴露了相同的组件名或者你根本不需要的自动配置类,明确指定扫描范围才能避免启动时出现莫名其妙的 Bean 冲突。
如果某些业务模块用了条件装配(@ConditionalOnProperty 之类),配合 spring.profiles.active=test 来指定测试环境,效果更好。我的测试配置文件 application-test.yml 里,通常会把数据源切到 H2 内存库,把缓存、消息队列这类外部依赖全部 mock 掉或者关掉,保证集成测试干净可控。
3.3 测试基类:让测试代码可以“空降”
一个测试模块如果没有基类,每个测试类都是“孤儿”,写几个测试以后你会发现重复代码越来越多。聚合测试模块的核心优势就在这里:基类和公共工具类统一放好,所有测试都能继承。
我推荐至少抽两个基类。一个是 BaseUnitTest,给纯逻辑单测用:
package com.example.test.base; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) public abstract class BaseUnitTest { }这个基类本身什么都不做,但它统一了 Mockito 扩展的加载方式,以后想给所有单测加 Mockito 的 strict stubs 或者设置默认 answer,只需改这个基类一处。
另一个是 BaseIntegrationTest,给需要启动 Spring 上下文的测试用:
package com.example.test.base; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @SpringBootTest(classes = TestApplication.class) @ActiveProfiles("test") public abstract class BaseIntegrationTest { }公共工具类也放这里。我写过最多的就是 MockDataFactory,每个业务模块的实体构造逻辑都往里填。单测里要造一个带购物车的订单,直接一个方法调用搞定,不用每次手写十行 setter:
package com.example.test.common; public class MockDataFactory { public static UserEntity createUser(String id, String name) { UserEntity user = new UserEntity(); user.setId(id); user.setName(name); return user; } }这样一来,业务模块里的 src/test/java 目录是不存在的,测试基类与工具类只在聚合模块维护一份,谁要复用谁就来这里改。
4. 实操案例:从一个 Controller 到 Service 的测试闭环
4.1 案例背景与依赖关系
为了让你直观理解整套方案的落地,我拿一个精简版订单系统来举例。项目里有两个被测模块:order-core 提供业务服务和领域模型,order-web 提供 Controller 接口,web 层依赖 core 层。加上聚合测试模块,整体关系大概是:
order-core <- order-web <- test-aggregationorder-web 里有一个简单的下单接口,调用 order-core 的 OrderApplicationService。服务内部还要判断用户是否在黑名单,这部分逻辑在 user-core 里,是 order-core 的依赖。放到以前,这个测试要么写在 order-web 里,要么整个依赖链复制到 order-web 的 pom 里。现在全部测试集中在聚合模块,一条线把所有被测类拉到 classpath 里。
4.2 单元测试示例:Service 与黑名单逻辑
先在聚合模块里写 OrderApplicationService 的单元测试。这个测试不启动任何 Spring 容器,只 mock 掉外部依赖:
package com.example.test.cases.unit.order; import com.example.order.core.OrderApplicationService; import com.example.order.core.model.CreateOrderCommand; import com.example.order.core.repository.OrderRepository; import com.example.user.core.service.UserRiskService; import com.example.test.base.BaseUnitTest; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; class OrderApplicationServiceTest extends BaseUnitTest { @Mock private UserRiskService userRiskService; @Mock private OrderRepository orderRepository; @InjectMocks private OrderApplicationService orderApplicationService; @Test void shouldRejectUserInBlacklist() { CreateOrderCommand command = new CreateOrderCommand(); command.setUserId("u001"); when(userRiskService.isBlacklistUser("u001")).thenReturn(true); assertThrows(IllegalArgumentException.class, () -> orderApplicationService.createOrder(command)); } }这里的要点是把 mock 声明在基类外部,每个测试类自己声明自己需要的 @Mock。基类只保证 MockitoExtension 对所有子类生效,不让基类替你声明 mock,否则测试之间会互相污染。
4.3 集成测试示例:MockMvc 全链路调用
再写一个比较典型的集成测试:直接向 order-web 的 Controller 发请求,验证接口层逻辑,同时让 Service 层的真实逻辑跑起来,但不连数据库。
package com.example.test.cases.integration.order; import com.example.test.base.BaseIntegrationTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import com.example.user.core.service.UserRiskService; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @AutoConfigureMockMvc class OrderControllerIT extends BaseIntegrationTest { @Autowired private MockMvc mockMvc; @MockBean private UserRiskService userRiskService; @Test void shouldCreateOrderWhenUserInWhitelist() throws Exception { when(userRiskService.isBlacklistUser("u001")).thenReturn(false); mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .content("{\"userId\":\"u001\",\"productId\":\"p001\"}")) .andExpect(status().isOk()); } }@AutoConfigureMockMvc 配合 @SpringBootTest(classes = TestApplication.class),启动一个非 Web 的 Spring 上下文来执行 Controller 逻辑,不会真的占用端口。业务模块里不需要写任何测试启动类,一切都发生在聚合模块。
4.4 运行命令与报告输出
测试就绪后,运行方式也分轻量级和重量级。日常开发想只跑单测:
mvn -pl test-aggregation -am test -DskipITs这里 -pl test-aggregation 指定只构建聚合测试模块,-am 是 also make,意思是把 test-aggregation 依赖的 order-web、order-core、user-core 等模块一并拉进构建。这样本地改了代码,直接一条命令就把依赖模块重新编译并执行相关内容,不需要手动去 install 每个业务模块。
想跑完整验证,包括 failsafe 管的那批 IT 测试:
mvn -pl test-aggregation -am verify报告文件会自动输出到 test-aggregation/target/surefire-reports 和 test-aggregation/target/failsafe-reports 下面。覆盖率插件在这个模块里配置的话,还能把所有被测模块的覆盖率统计聚合到一份 Jacoco 报告中,不用像以前那样逐个模块翻报告。
5. 常见问题与排查技巧实录
5.1 Maven 本地有包但是聚合模块引不进来
这个问题的典型场景是:你在本地仓库里已经 install 过 user-core 模块,但是在 IDE 或者命令行里执行 -pl test-aggregation test,发现聚合模块找不到 user-core 的类,编译直接报错。
原因往往是缺少 -am 参数。如果你直接执行 mvn -pl test-aggregation test,Maven 只在本地仓库里找 user-core 的 jar,找不到就报错。而加上 -am 后,Maven 会检查 reactor 内的依赖关系,把 user-core 模块一起加进来重新编译,使用的是刚编译出来的类,而不是本地仓库里可能过期的旧 jar。
还有一种情况是其他模块改动后忘记执行:
mvn clean install -DskipTests各个业务模块没有更新到本地仓库,聚合模块自然拿不到最新代码。我在 CI 里通常会先 install 业务模块,再单独跑 test-aggregation 的 verify,这是比较稳妥的顺序。
5.2 TestApplication 被重复扫描导致 Bean 冲突
这个坑在模块多了以后特别容易踩。比如你在 TestApplication 里写了 @ComponentScan(basePackages = "com.example"),把所有业务包都扫了进来。结果两个业务模块里各自定义了一个同名的 Configuration 类,或者某个自动配置类里出现了相同名字的 Bean,测试启动时就会抛 BeanDefinitionStoreException。
排查思路是:先把 ComponentScan 范围缩小,按被测模块显式列出;实在躲不开冲突,就在 TestApplication 里加上 @SpringBootApplication(exclude = xxxAutoConfiguration.class) 排除对应的自动配置类。不要指望靠 @Primary 或者 @MockBean 去掩盖这个问题,扫描路径干净才是根本解。
还有一种常见情况是每个业务模块都有自己的 Application 启动类,并且都没关掉,聚合测试模块的测试一旦让不同模块的启动类都被扫描到,启动上下文就会因为多个 @SpringBootConfiguration 而失败。所以 TestApplication 这个类的定位要非常明确:它是聚合测试模块私有的配置,绝对不允许放入任何业务模块的包里。
5.3 skipTests 和 test.skip 到底应该用哪个
这个问题几乎是必问的。maven.test.skip=true 会让测试代码都无法编译,业务模块即使还有测试类,也会直接跳过编译和运行,速度最快。hmm,我见过有人在聚合测试模块里误配了 maven.test.skip=true,结果所有测试都被跳过,报错的构建反而显示 BUILD SUCCESS,浪费了半天排查时间。
正确的使用方式是:如果只是想临时跳过测试,但不是不做测试代码编译,用 -DskipTests;如果要彻底跳过某个模块的测试以及测试代码编译,用 -Dmaven.test.skip=true。我改造多模块项目的习惯是,在 CI 的公共服务模块编译阶段用 -Dmaven.test.skip=true,因为那些模块里本来就不应该有测试代码,连 test 目录都省了编译;聚合测试模块则永远不加这两个参数。
5.4 Mockito 的 when 方法一直不生效
很多从业务模块测试切到聚合模块测试的人,会遇到 mock 不生效的情况:when(userRiskService.isBlacklistUser(...)).thenReturn(true) 写了,但是跑起来发现服务里调用它时返回的是 null,或者走了真实逻辑。
多数原因是测试类没有继承 BaseUnitTest,或者被测试的对象不是用 @InjectMocks 注入的。还有一种坑是 @MockBean 混在 @Mock 里用:在集成测试里 mock 的对象需要用 @MockBean 替换 Spring 容器里的 Bean,而不是 @Mock;在单测里对象是手动 new 出来的依赖,才用 @Mock + @InjectMocks。这两套注解体系搞混,mock 自然不生效。
诊断方式也很直接:在断言前打断点看一眼 mock 的对象和被测对象是否同一个实例;如果不同,一定是在对象注入上出了问题。
5.5 测试模块的依赖一变,所有模块都要重新构建
聚合测试模块因为依赖了所有被测模块,它的构建顺序天然排在整个项目最后。你只要改动任意一个业务模块,再跑聚合模块测试时,Maven 都会把这条依赖链上的模块重新编译,这其实不是问题,而是 Maven reactor 的正常行为。
如果你觉得这样效率低,可以在业务模块的 pom 里配合 Maven Profile 做优化:开发模式只编业务模块,CI 模式才触达聚合测试模块。但我的建议是,聚合测试模块不应该太纠结这份构建耗时,因为换来的测试代码集中管理、模块边界清晰、CI 质量门禁统一,这些收益远大于多出来的几十秒编译时间。
我改造过好几次多模块测试结构,如果说有什么一定要坚持的原则,那就是:每个模块只负责自己的事。业务模块专心提供业务能力,聚合测试模块专心负责验证,测试基类和工具类只在最需要它的地方维护一份。这种拆分初看会觉得多了一个模块,实际跑过几轮迭代后你会发现,测试代码不再是贴着业务代码“长出来”的附属品,而是整个项目里一个独立、清晰、可以单独维护的资产。如果你正在多模块项目里被测试代码折磨,不妨花一个下午试试这个方案,后面你会回来感谢自己的。