
三年前我刚接手一个四个业务模块的 Spring Boot 工程时做的第一件事就是从 user-service 的 src/test/java 里拷了一份 BaseIntegrationTest 到 order-service——两份文件内容几乎一样只是包名不同、数据库连接方式不同。后来我数了一下这类高度相似的测试基类和工具类散落在七个模块里。我想维护过多模块 Maven 项目的朋友对这个场景都不陌生。这篇文章要聊的就是在 Maven 多模块 Spring Boot 项目中构建一个独立的聚合测试模块我习惯叫 test-support有的团队叫 test-common / test-kit把测试基类、测试工具、容器管理和测试依赖统一收敛到一起。业务模块通过 test-jar 依赖快速复用测试资产跨模块的集成测试也有一个明确的栖息地。文章会给出两种落地路线、完整 pom 配置、共享基类设计以及我实测踩过的五个连环坑。1. 多模块项目测试的隐性成本三笔最容易攒下的测试债1.1 第一笔测试工具类在每个业务模块里复制粘贴Maven 多模块项目默认有个强隔离策略模块之间的常规依赖只看src/main/javasrc/test下的测试代码不会通过普通dependency共享。这个设计本意是好的防止测试产物污染生产但副作用也很明显——团队一旦没人懂 test-jar 这种共享机制就只剩复制粘贴一条路。不信你可以回顾一下自己的项目BaseIntegrationTest、RandomDataUtils、MockResponseBuilder这些类是不是每个业务模块的src/test/java里各有一份而且相互之间大概率已经进化出差异了。有人说 H2 不依赖 Docker有人说 Testcontainers 才是正道有人说自己本地装了个 MySQL 直接连。改一个公共断言工具要在三四个模块里同步改漏一个就埋雷。复制粘贴的根因是 Maven 默认不让常规依赖见到 test classpath所以你也没法用正常方式去引用另一个模块的测试类。要解决这个问题必须显式开辟一条共享通道——也就是 test-jar 或者独立测试聚合工程后面会详细展开。1.2 第二笔跨模块集成测试不知道放哪个模块多模块项目里最尴尬的问题一个涉及 user-service 创建用户、order-service 下单、gateway 转发链路的集成测试到底放哪放 user-service 里它得依赖 order-service 的接口和测试数据模块职责被污染。放 order-service 里同理。最常出现的结果是谁先遇到谁先塞最后网关工程或者最上层的聚合 pom 工程里长出了一堆别的模块的测试代码。每次跑全量构建那个最上层工程要跑十几分钟而它本意根本不想承载业务测试。你注意这个问题不是测试代码量的问题是归属感的问题。业务模块的单元测试有清晰的属地跨模块的集成测试没有。没有一个独立的聚合测试模块这类测试就永远在借住别人的地盘时间一长必然滋生混乱。1.3 第三笔测试依赖版本各写各的升级时全盘崩溃Spring Boot 多模块项目最常见的测试依赖是spring-boot-starter-test它内部带 JUnit、AssertJ、Mockito 等一整套库。麻烦在于如果你在 user-service 的 pom 里写了它order-service 的 pom 里也写了它版本号在 Spring Boot 父 pom 的统一管理下通常不会差太多但一旦有人手贱加了version或者某个模块单独引了高版本的 Testcontainers版本分裂就开始了。我见过一个项目里 A 模块用 JUnit 5.8B 模块用 JUnit 5.9平时各跑各的没问题直到要引入 Testcontainers 的新特性才发现 B 模块的容器镜像拉取行为跟 A 模块完全不同。这种依赖版本各自为政的问题在单体项目里不存在在多模块项目里却是常态。聚合测试模块配合父 pom 的 dependencyManagement可以把测试相关依赖全部锁在同一水平线上谁也别想乱来。2. 两种落地路线共享 test-jar 与独立聚合测试工程2.1 方案 Atest-support 共享测试模块打 test-jartest-support 是一个普通的 Maven 子模块它不参与任何业务逻辑只放可复用的测试基建。核心机制是利用 Maven 的 maven-jar-plugin 把该模块src/test/java下编译出来的测试类额外打成一个 classifier 为tests的 jar也就是 test-jar。业务模块想用 test-support 里的BaseIntegrationTest只需要在 pom 里这样声明dependency groupIdcom.example/groupId artifactIdtest-support/artifactId version${project.version}/version scopetest/scope typetest-jar/type /dependencytypetest-jar/type是核心它会去解析com.example:test-support:tests这个带特殊 classifier 的构件。test-support 自身不依赖任何业务模块所以不存在循环依赖。2.2 方案 Baggregation-test 独立聚合测试工程test-support 解决的是共享测试类的问题但它不解决跨模块集成测试放哪的问题。后者的答案是一个真正的聚合测试模块我习惯叫它 aggregation-test。它的定位很简单在 pom 中依赖所有被测业务模块scope 用 test然后把跨模块的集成测试统一写在它自己的src/test/java里。比如dependencies dependency groupIdcom.example/groupId artifactIduser-service/artifactId version${project.version}/version scopetest/scope /dependency dependency groupIdcom.example/groupId artifactIdorder-service/artifactId version${project.version}/version scopetest/scope /dependency dependency groupIdcom.example/groupId artifactIdtest-support/artifactId version${project.version}/version scopetest/scope typetest-jar/type /dependency /dependencies这里 dependency 全部是 test scope符合它测试专用工程的身份。它可以在 test 编译期使用所有业务模块的 main 类从而启动完整 Spring Boot 上下文做端到端验证。聚合测试工程本身可以参与或不参与生产发布取决于你是否愿意把它 install 到本地仓库——我们项目里会让它参与构建这样 CI 在一个命令里就能跑完所有测试。2.3 对比什么时候选 A、什么时候选 B维度test-support 共享模块aggregation-test 聚合工程核心定位共享测试基建基类、工具、容器管理跨模块集成测试的宿主依赖方向被各模块依赖不依赖业务模块依赖所有被测业务模块打包方式test-jarclassifiertests普通 jar依赖全为 test scope是否参与业务构建是必须先 install 生成 test-jar是但不影响业务模块典型内容BaseIntegrationTest、容器封装、随机数据工具UserApiIT、OrderFlowIT、端到端场景主要风险test 依赖不传递需要配套依赖管理新增模块时 pom 要同步更新一句话选型只想共享测试基建选 A想给跨模块测试找个家选 B。两者不是互斥关系。2.4 我推荐的组合打法实践下来A 和 B 配合使用效果最好。test-support 作为底座提供所有模块都要用的测试基类和容器管理aggregation-test 作为顶层入口依赖所有业务模块和 test-support专门放跨模块场景用例。业务模块自己的单元测试引用 test-support 即可不用管 aggregation-testCI 全量构建时单独跑一次 aggregation-test 就能覆盖所有跨模块链路。这个组合的另一个好处是依赖方向特别清晰业务模块只依赖 test-supporttest-support 不依赖任何业务模块aggregation-test 依赖所有业务模块——整个构建图是树状而不是环状Maven 不会报循环依赖。3. 实操搭建pom 配置、目录结构与第一个共享基类3.1 模块目录与父 pom 声明假设项目已经存在 common-core、user-service、order-service 几个模块现在要新增 test-support 和 aggregation-test。完整目录长这样my-project/ ├── pom.xml ├── common-core/ ├── user-service/ ├── order-service/ ├── test-support/ │ └── src/ │ └── test/ │ └── java/ │ └── com/example/support/ │ ├── BaseIntegrationTest.java │ ├── BaseWebTest.java │ └── RandomDataUtils.java └── aggregation-test/ └── src/ └── test/ └── java/ └── com/example/it/ ├── UserApiIT.java └── OrderFlowIT.java在父 pom 的modules里加两行modules modulecommon-core/module moduleuser-service/module moduleorder-service/module moduletest-support/module moduleaggregation-test/module /modules注意模块顺序不能乱写。Maven 按依赖关系自动排序但如果我手动指定构建顺序通常把 test-support 放在业务模块之后、aggregation-test 之前更直观。3.2 test-support 的 pom 关键配置逐行拆解直接看一个能跑的 pom?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdcom.example/groupId artifactIdmy-project/artifactId version1.0.0-SNAPSHOT/version /parent artifactIdtest-support/artifactId dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdjunit-jupiter/artifactId scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdmysql/artifactId scopetest/scope /dependency dependency groupIdorg.testcontainers/groupId artifactIdredis/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId executions execution goals goaltest-jar/goal /goals /execution /executions /plugin plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration skipTeststrue/skipTests /configuration /plugin /plugins /build /project这里有两个关键点很多人第一次会踩。第一test-support 依赖的 starter-test 和 testcontainers 全部是 test scope。因为 Maven 对 test scope 依赖不传递业务模块引用 test-jar 时拿不到这些第三方测试库。下面两个解决思路二选一思路一在父 pom 的dependencies统一加一份 test scope 的 starter-test 和 testcontainers让所有子模块自动继承。优点是业务模块 pom 里不再需要重复声明测试依赖缺点是对所有子模块全局生效不够灵活。思路二每业务模块自己声明自己需要的测试依赖test-support 只管自己的测试类能编译就行。优点是控制粒度细缺点是每个模块 pom 都要写一遍。我实际项目里用的是思路一因为测试依赖本来就该聚合管理不然聚合测试模块就失去了一半意义。第二test-support 自身的 surefire 要配skipTeststrue/skipTests。它只提供测试类给别的模块用自己不应该跑测试。否则你哪次不小心在里面写了个测试类全量构建时它也会被执行而且因为它依赖了 Testcontainers还会莫名其妙地启动 Docker 容器。3.3 业务模块怎么引用 test-support在 user-service 的 pom 里加这么一段dependency groupIdcom.example/groupId artifactIdtest-support/artifactId version${project.version}/version scopetest/scope typetest-jar/type /dependency如果父 pom 的 dependencyManagement 已经管理了 test-support 的版本version可以省略。注意typetest-jar/type背后关联的 classifier 默认就是tests所以不需要再加classifiertests/classifier加了反而可能冲突。3.4 第一个能跑的 BaseIntegrationTest接下来在 test-support 的src/test/java下写一个共享基类。这个类的作用是统一所有模块的 Spring Boot 测试环境包括容器管理package com.example.support; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; Testcontainers SpringBootTest(webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT) ActiveProfiles(test) public abstract class BaseIntegrationTest { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0.32) .withDatabaseName(app_test) .withUsername(test) .withPassword(test); DynamicPropertySource static void mysqlProps(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); } }注意容器是 static 的保证同一个 JVM 内所有测试类共享同一个 MySQL 容器不会每个测试类起一个新库。DynamicPropertySource是 Spring 5.2 之后的标准做法比手动设置spring.datasource.url更优雅因为可以用容器运行时的真实连接信息。业务模块里的测试类只需要这样继承class UserServiceTest extends BaseIntegrationTest { // 直接具备完整 Spring Boot 环境 }这样 team 里所有人都能跑一致的测试环境不会出现我本地能跑你本地不能跑的问题。3.5 首次构建的命令与顺序新 clone 项目后第一次跑测试有个隐藏的构建顺序问题test-support 的 test-jar 必须先生成并 install 到本地仓库业务模块的 test 阶段才能解析到它。如果直接跑mvn test业务模块可能会报 Could not resolve dependencies。推荐的首跑命令是mvn clean install -DskipTests -pl test-support -am-pl test-support指定只构建 test-support-am表示同时构建它依赖的上游模块。-DskipTests只跳过已有测试的执行不会跳过测试代码的编译所以 test-jar 依然会生成。之前提过千万不要在需要 test-jar 时用-Dmaven.test.skiptrue那个参数会直接跳过测试代码的编译test-jar 根本打不出来后文踩坑部分再细说。4. 聚合模块里放什么共享测试基建的六件套4.1 基础测试类与 MockMvc 封装第一件套是基础测试类的封装。除了上一节那个 BaseIntegrationTest通常还会按场景拆出 Web 层的封装package com.example.support; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; AutoConfigureMockMvc public abstract class BaseWebTest extends BaseIntegrationTest { Autowired protected MockMvc mockMvc; Autowired protected ObjectMapper objectMapper; protected String toJson(Object obj) throws Exception { return objectMapper.writeValueAsString(obj); } }这样业务模块里做 Controller 测试时不需要每个模块都写一遍AutoConfigureMockMvc和 ObjectMapper 注入。实测里最大的收益是如果公司代码规范要求所有测试用例走同一种 MockMvc 配置你只需要改这一个文件就能全项目生效。4.2 Testcontainers 容器统一管理第二件套是容器管理。除了 MySQL多模块项目常会用到 Redis、Kafka、Elasticsearch。把这些容器封装在 test-support 里可以避免每个模块各自定义一套容器配置。一个实用的做法是定义专门的容器启动类package com.example.support; import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerImageName; public final class TestContainers { private static final GenericContainer? REDIS new GenericContainer(DockerImageName.parse(redis:7.0)) .withExposedPorts(6379); public static GenericContainer? redis() { return REDIS; } }当然更推荐用 Testcontainers 官方提供的模块类比如RedisContainer。关键是这个类一旦在 test-support 里定义好所有业务模块的测试都能直接引用不再出现order-service 用 Redis 5、user-service 用 Redis 7这种不一致。我对容器统一管理最大的感悟是它不只是省代码而是强制整个团队使用同一套测试环境基线这是测试稳定的前提。4.3 测试数据构造器与随机数据工具第三件套是测试数据工具。很多项目里测试数据生成逻辑散落在各个模块比如模块 A 用Math.random()模块 B 用UUID.randomUUID()生成的字符串长度还不一样。把这些统一收敛到 test-supportpackage com.example.support; import java.util.concurrent.ThreadLocalRandom; public final class RandomDataUtils { private static final String CHARS abcdefghijklmnopqrstuvwxyz0123456789; private RandomDataUtils() { } public static String randomString(int length) { StringBuilder sb new StringBuilder(length); for (int i 0; i length; i) { sb.append(CHARS.charAt(ThreadLocalRandom.current().nextInt(CHARS.length()))); } return sb.toString(); } public static Long randomLong() { return ThreadLocalRandom.current().nextLong(1_000_000L); } }生产环境用这个工具比手写UUID.randomUUID().toString()更可控因为你能设定长度和字符集。一些测试框架甚至推荐使用固定种子生成数据这样用例可复现。我在项目里见过最离谱的是有人用System.currentTimeMillis()生成手机号测试跑快了会重复。统一工具类之后这些问题都消失了。4.4 JSON 断言与数据比对工具第四件套是 JSON 断言工具。Spring Boot 的 starter-test 自带 jsonassert但直接用它心智负担较高。建议在 test-support 里封装一层package com.example.support; import com.jayway.jsonpath.JsonPath; import org.springframework.test.web.servlet.ResultActions; public final class JsonAssertUtils { private JsonAssertUtils() { } public static String readJson(ResultActions result, String path) throws Exception { String content result.andReturn().getResponse().getContentAsString(); return JsonPath.read(content, path).toString(); } }当然如果你习惯 AssertJ 的json().isEqualTo也可以封一层。反正原则是把 JSON 的解析、断言、错误信息格式化都集中在一处。多模块项目里各模块对接口返回结构的断言方式五花八门统一封装后接口变更时只需要更新一个类。4.5 可选测试配置TestConfiguration 的正确用法第五件套是TestConfiguration配置类。很多时候测试需要 mock 掉某个外部 RPC 客户端或者替换某个 Bean。把这些配置类放在 test-support 里有一个隐藏的好处多个模块可以复用同一个测试配置模板。package com.example.support; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; TestConfiguration public class MockExternalClientConfig { Bean public SomeExternalClient someExternalClient() { return new MockExternalClient(); } }业务模块测试类上只要这样引入即可Import(MockExternalClientConfig.class) class SomeServiceTest extends BaseIntegrationTest { }注意TestConfiguration不会被 Spring Boot 的组件扫描自动加载必须显式Import这是它和普通Configuration最大的区别。把这类配置统一放到 test-support 里能保证所有模块对外部服务的 mock 行为一致不会出现这个模块 mock 了、那个模块忘了 mock 的情况。4.6 包名与目录约定第六件套不是代码是约定。test-support 里的共享类包名统一用com.example.support业务模块的测试代码里 import 这个包下的类。aggregation-test 里的集成测试统一用com.example.it包并且类名以IT结尾方便 surefire / failsafe 识别。不要小看包名约定。多模块项目里如果不统一就会出现每个模块各自又搞了一个 internal 包的混乱局面。包名也是聚合测试模块的广场大家从同一个地方 import代码结构自然就收敛了。5. 实测中踩过的坑test-jar 依赖与构建顺序的连环雷5.1 坑一忘掉 test-jar 的 type 或 classifier报找不到符号最典型的报错长这样[ERROR] /xx/user-service/src/test/java/xx/UserServiceTest.java:[10,29] package com.example.support does not exist排查思路第一步是看业务模块的 pom 里是否写了typetest-jar/type。有人会只写scopetest然后发现 test-support 的普通 jar 里根本没有BaseIntegrationTest.class——因为在 test-support 里那个类本来就放在src/test/java下普通 jar 不含它。加typetest-jar/type后依赖就会解析到test-support:tests这个特殊构件。还有个容易混淆的点test-jar 默认 classifier 是tests所以你在依赖里写classifiertests/classifier也能达到类似效果但这时候 type 不要写成jar否则 Maven 解析坐标会怪怪的。最省心的写法就是type用test-jar其他交给 Maven。5.2 坑二用 -Dmaven.test.skiptrue 把 test-jar 一起跳没了很多老 Java Maven 用户习惯了-Dmaven.test.skiptrue来跳过测试但这个参数对 test-support 模块是致命的。它会跳过测试代码的编译maven-jar-plugin的 test-jar goal 找不到 test-classes 目录最后生成的 artifacts 里就没有 test-jar。结果就是你在本地仓库里确实看到了 test-support 的目录但里面只有test-support-1.0.0-SNAPSHOT.jar没有test-support-1.0.0-SNAPSHOT-tests.jar。业务模块构建时还是找不到com.example.support包。正确做法是用-DskipTests它只跳过执行不跳过编译test-jar 照常生成。我建议在团队的 CI 脚本里明文统一写好两个场景的参数# 快速构建但保留测试编译产物 mvn clean install -DskipTests # 全量测试 mvn clean verify5.3 坑三IDE 里编译通过命令行构建却失败IDEA 有个让所有人都困惑的行为当你把 test-support 作为依赖 module 导入后IDE 可以在编译时直接从另一个 module 的 target/test-classes 里拿类所以很多人在 IDE 里跑测试没问题一到命令行mvn test就报依赖解析失败。原因还是 Maven 仓库里没有安装 test-support 的 test-jar。IDE 编译走的是项目内部模块依赖Maven 走的是本地仓库 artifacts两者根本不是一回事。解决方法是回到 3.5 节的首跑命令先把 test-support install 一遍。并且我有个习惯每次改了 test-support 里共享类的方法签名都手动跑一次mvn install -pl test-support -am -DskipTests不改 test-support 的情况下业务模块的日常测试不需要多次 install。5.4 坑四test-support 反向依赖业务模块造成循环引用有个同事觉得 test-support 里反正要写集成测试基类干脆让 test-support 直接依赖所有业务模块这样基类药物注入更方便。结果 Maven 构建直接报循环依赖user-service 依赖 test-supporttest-support 依赖 user-service构建图无法拓扑排序。这是聚合测试模块设计中最容易犯的架构错误。test-support 的定位是无业务依赖的测试底座它只能依赖第三方库和 Spring Boot 测试框架绝不能反向依赖任何业务模块。如果你需要在基类里使用某个业务模块的 Bean 类型那不是 write once 的问题说明该测试类本身就属于某个特定模块应该放在那个模块的src/test里而不是塞进共享底座。5.5 坑五聚合测试模块跑起来显示没有测试可运行aggregation-test 建好之后第一次跑mvn test -pl aggregation-test你可能会看到Tests run: 0, Failures: 0, Errors: 0, Skipped: 0原因通常是 surefire 默认只识别*Test.java、*Tests.java等命名规则而你在聚合测试模块里把跨模块用例都命名成UserApiIT、OrderFlowIT它默认不认。解决方法是给 aggregation-test 的 surefire 配置 includesplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration includes include**/*Test.java/include include**/*Tests.java/include include**/*IT.java/include /includes /configuration /plugin更规范的做法是用 maven-failsafe-plugin 跑集成测试单元测试用 surefire集成测试用 failsafe两个插件的生命周期阶段不同能把单测和集成测试在 CI 里分开执行。我个人的体会是聚合测试模块能让你非常自然地引入这种单测 / 集成测试分离的纪律因为所有集成测试都集中在了一个工程里处理命名规则反而成为一种纯粹的技术选型。测试基建这件事表面上是 pom 配置和目录结构的小事实际操作起来却牵一发动全身。把 test-support 这一层架构搭稳之后最大收益不是少写了几百行重复代码而是整个团队有了统一的测试基线和一套测试代码该往哪里放的明确规则。后来接手这个项目的新人第一次写集成测试就知道去 aggregation-test 里写再也不会往网关模块里乱塞用例了。这大概就是聚合测试模块最值得投入的地方。