做了这么多年 Spring Boot 项目,我越来越觉得,Spring Boot 测试真正难的不是 API 不会用,而是很多人根本不知道该把测试写到哪一层、该怎么保证它能稳定跑下去。日常开发里最常见的一类问题就是:本地测试全绿,代码合进去之后 CI 挂了;或者仅仅是改了一个实体字段,十几个测试类跟着一起失败;还有一种是全量测试越跑越慢,最后跑一次要十来分钟,团队只能选择性忽略。这些问题的根源基本不在“写测试代码”这个动作,而在“测试策略选型”和“测试环境治理”上。
这篇文章我会直接围绕 Spring Boot 测试这个主题,从怎么分层、怎么选注解、怎么搭数据层测试、怎么处理 Controller 安全上下文,再到我实际踩过的坑和排查链路,一次讲清楚。无论你是刚接触 Spring Boot 项目的新手,还是写了很多年代码但没有认真整理过测试套件的老手,都可以拿来当一份可落地的参考。
1. 测试分层选型:先想明白这一层到底要验证什么
1.1 三种测试范围的区别
我接手过不少项目,打开测试目录一看,整齐划一全是@SpringBootTest。这种做法的代价非常直接:Spring 容器会被完整启动,所有真实 Bean 都会加载,外部依赖只要有一个连不上,测试就起不来。这本质上是把集成测试当单元测试写。如果项目只有二十个用例,这么写倒也没太大问题;一旦用例上百个,光是等容器启动、等事务回滚,就够让人崩溃。
我自己的取舍逻辑其实很简单:
- 验证单个 Service 方法里的复杂分支、参数校验、计算结果,用 JUnit + Mockito,不启动 Spring 容器;
- 验证 Repository 的 SQL 和实体映射,用
@DataJpaTest; - 验证 Controller 的请求解析、参数校验、响应结构,用
@WebMvcTest; - 只有需要验证跨组件事务边界、消息队列真实投递、外部依赖联合调用时,才上
@SpringBootTest。
每往上一层,覆盖范围更广,但代价也更慢、更脆弱。所以不是测试写得越多越好,而是“每一层测试只测这一层该管的事”。
1.2 测试注解怎么选,一张表看清楚
刚开始接触 Spring Boot 测试时,我也被一堆注解绕晕过。后来我习惯用一张表来给团队定“哪个场景用哪个注解”,现在分享出来,可以直接参考:
| 测试诉求 | 推荐主力注解 | 启动范围 | 注意点 |
|---|---|---|---|
| Service / 工具类逻辑 | 无(JUnit + Mockito) | 不启动容器 | 外部依赖全部 mock,专注业务分支 |
| Controller 接口层 | @WebMvcTest | 只装配 MVC 相关 | 用@MockitoBean替换 Service 依赖 |
| JPA 数据层 | @DataJpaTest | 只装配 JPA 相关 | 默认内嵌数据库,自带事务回滚 |
| JSON 序列化 | @JsonTest | 只装配 JSON 相关 | 验证字段名、日期格式、null 处理 |
| 完整链路集成 | @SpringBootTest | 完整上下文 | 建议随机端口 + Testcontainers,控制用例数量 |
特别提醒一下,如果你用的是 Spring Boot 3.4+,原来的@MockBean开始向@MockitoBean迁移。项目升级时这个细节很容易漏,直接照老代码抄的人会发现注解找不到,或者 IDE 提示 deprecated。
1.3 为什么不要一上来就 @SpringBootTest
很多人的习惯是先写@SpringBootTest,测试启动报错再一点点加配置。这种“先启动再调整”的套路,会把大量时间耗在环境问题上。我把@SpringBootTest的几个真实问题列出来,大家感受一下:
- 启动慢。一个中等规模的 Spring Boot 项目,完整启动上下文 10 到 20 秒很常见。如果开发机性能一般,一次全量测试跑几分钟毫不意外。
- 外部依赖耦合强。项目里只要有一个 Bean 依赖 Redis、Nacos、消息队列,测试环境就得先保证这些组件可用。CI 一旦网络不稳,测试结果就会随机失败。
- 上下文缓存压力大。不同测试类只要配置少数不一致,Spring 就会为它们各自启动一个上下文。上下文越多,内存占用和启动耗时都线性上涨。
- 错误定位模糊。上下文里几十个 Bean 一起初始化时,一个 Bean 配错了,会导致所有“不相关”的测试类一起挂掉,你根本没法快速定位。
所以我的建议是:写测试前先圈定要验证的问题边界,用边界去选注解,而不是先写注解再根据报错补配置。
2. 测试环境:上下文缓存、随机端口和外部依赖的“防抖”
2.1 上下文缓存复用机制,以及为什么你总觉得没生效
Spring TestContext 框架在启动时是有缓存的,只要多个测试类的上下文配置完全一致,就会复用同一个ApplicationContext。这个特性是测试套件能高效运行的底层保障。但很多人不知道,Spring 生成缓存 key 时会考虑这些因素:
- 加载上下文的主配置类;
- 激活的 profile(
@ActiveProfiles); properties或@TestPropertySource里的配置;webEnvironment模式;- 各种
ContextCustomizer,包括@MockBean/@MockitoBean带来的自定义器。
我见过最典型的场景:A 测试类在@SpringBootTest里写了properties = "spring.datasource.url=jdbc:h2:mem:a",B 测试类写了properties = "spring.datasource.url=jdbc:h2:mem:b"。开发者的本意是隔离数据,结果却让两个类的缓存 key 不同,Spring 被迫启动两个上下文。表面上“加了缓存”,实际缓存完全没生效。
我的做法是:把所有测试类共用配置统一放到src/test/resources/application-test.yml,在测试类上用统一的@ActiveProfiles("test")。尽量避免在测试类注解里零散塞 properties。这样 cache key 的一致性更容易保证,测试套件也更容易提速。
2.2 RANDOM_PORT 才是适合并行和 CI 的选择
如果集成测试确实需要启动内嵌服务器,我强烈建议使用webEnvironment = WebEnvironment.RANDOM_PORT。用DEFINED_PORT固定端口,本地跑单个测试类可能没问题,但 CI 并发执行或者同一台机器上多个测试进程同时跑时,端口冲突就会立刻出现,而且是那种“时而成功时而失败”的偶发问题,排查起来特别烦。
用随机端口的标准姿势是这样:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class OrderFlowIntegrationTest { @LocalServerPort int port; @Test void shouldPlaceOrder() { TestRestTemplate rest = new TestRestTemplate(); ResponseEntity<String> resp = rest.postForEntity( "http://localhost:" + port + "/orders", "{\"productId\":1,\"quantity\":2}", String.class ); assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue(); } }如果你是 WebFlux 项目,可以用WebTestClient.bindToServer().baseUrl("http://localhost:" + port).build()。核心逻辑一样:端口不写死,环境起来之后动态拿。
2.3 外部依赖用 Mock 服务替代,别让测试结果“看天吃饭”
很多业务代码都要调用第三方 HTTP API,如果测试时直接连真实第三方接口,结果基本不可控。对方接口抖动、超时、字段调整,都会让你的测试随机失败。更麻烦的是,有些测试为了能跑通,会去真实下单、真实发消息,这已经完全不是自动化测试了。
我在项目里更常用 MockWebServer 来模拟第三方服务。它跟 OkHttp 是同一个生态,用法非常简单:
class ExternalApiClientTest { private MockWebServer server; private ExternalApiClient client; @BeforeEach void setUp() throws IOException { server = new MockWebServer(); server.start(); String baseUrl = server.url("/").toString(); client = new ExternalApiClient(baseUrl); } @AfterEach void tearDown() throws IOException { server.shutdown(); } @Test void shouldMapResponse() { server.enqueue(new MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") .setBody("{\"code\":0,\"data\":{\"name\":\"zhang\"}}")); // 调用 client 的方法,断言结果 } }要点是:Mock 服务返回的数据是提前设计好的,不会访问外网,所以结果可重放、可预测。如果涉及签名、token 获取,可以把固定 token 也交给 Mock 处理。真正的三方联调放到 dev 或 staging 环境去做,不要在自动化测试里依赖真实网络。
3. 数据层测试:H2 内存库和 Testcontainers 怎么选
3.1 @DataJpaTest 默认做了什么
@DataJpaTest是 Spring Boot 提供的数据层切片测试注解。它会扫描 JPA 相关组件,只加载和 JPA 相关的配置,同时自带事务回滚。默认情况下,如果项目依赖中有 H2 这种内嵌数据库,它会替换成一个内嵌数据源,测试方法跑完自动 rollback。所以对于简单的 CRUD、findByXxx查询,这个注解用起来非常顺手。
但这里有一个容易被忽略的边界:H2 不是 MySQL,也不是 PostgreSQL。如果项目里用了 JSON 字段、FOR UPDATE、ON DUPLICATE KEY UPDATE,或者业务 SQL 依赖特定数据库方言,H2 上未必能跑通,甚至会给你“正确”的结果但和生产环境 SQL 行为不一致。这种不一致是最可怕的,因为本地测试全绿,上线后才出问题。
所以我现在的习惯是分情况对待:
- 只做简单的增删改查、普通查询条件测试,用 H2 没问题,快而且简单;
- 涉及复杂 SQL、数据库私有函数、JSON 字段操作、事务隔离级别时,用 Testcontainers 跑真实数据库。
3.2 Testcontainers 的最小可用配置
Testcontainers 是目前解决“测试数据库和生产环境不一致”的主流方案。它会在 Docker 里启动一个指定版本的数据库容器,测试结束后自动销毁,数据不残留。最小配置长这样(以 PostgreSQL 为例):
@Testcontainers @SpringBootTest class OrderRepositoryIntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void dataSourceProps(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @Autowired private OrderRepository orderRepository; @Test void shouldQueryOrdersByStatus() { // 写入数据再查询 } }几个实操细节值得留意:
- 容器对象要声明为 static,不能每个测试方法 new 一个。否则每个方法都会启动一个新容器,耗时和资源开销都很可怕。
- 如果多个测试类都需要数据库容器,建议做一个抽象基类,统一创建容器并统一注册动态属性,不要让每个测试类各自启动一个库。
- CI 环境必须确保 Docker 可用。如果确实没有 Docker,可以考虑设置跳过规则,但我不建议把过多测试直接跳过,否则测试对项目的保护作用就下降了。
3.3 测试数据造数要“用完即走”
数据层测试常见的一个坏习惯是:在@BeforeEach里用save()一条一条插入数据。数据量小还好,数据量一多,测试耗时立刻上去了。更好的做法:
@DataJpaTest @Sql(statements = """ insert into t_user (id, email, name) values (1, 'a@example.com', 'A'), (2, 'b@example.com', 'B') """, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void shouldCountUsers() { assertThat(userRepository.count()).isEqualTo(2); } }@Sql可以把前置数据集中管理,SQL 脚本更接近生产环境。注意executionPhase的配置,默认是BEFORE_TEST_METHOD,如果设置为AFTER_TEST_METHOD,则用于清理数据。
另一个坑是:@DataJpaTest默认会回滚每个测试方法的事务,但如果你开了@Rollback(false),或者换成了不支持回滚的方式,插入的数据就可能残留在库里,影响后续测试。所以尽量依赖事务回滚机制,让测试方法之间彼此独立,不要依赖执行顺序。
4. Controller 和接口测试:从 MockMvc 到 WebTestClient
4.1 @WebMvcTest 到底测了什么
@WebMvcTest是用来做 Controller 切片测试的注解。它只装配 Controller 层和 MVC 基础设施,不加载 Service、Repository 等 Spring 容器里的其他 Bean。所以你要用@MockitoBean(老版本是@MockBean)把 Service 依赖替换成 Mock 对象。
Controller 测试的核心不是验证 Service 逻辑,而是验证 HTTP 层面的表现:请求参数怎么解析、@Valid校验是否返回 400、响应 JSON 的结构和字段名对不对、状态码是否符合预期、统一异常处理器是否按约定返回结构。示例:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private UserService userService; @Test void createUser_shouldReturn201() throws Exception { given(userService.create(any())) .willReturn(new UserVO(1L, "zhang")); mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"zhang\"}")) .andExpect(status().isCreated()); } }注意:如果你的项目是 WebFlux 技术栈,对应的是@WebFluxTest+WebTestClient,用法思路一样,只是执行器从 MockMvc 换成了 WebTestClient。
4.2 Spring Security 上下文怎么办
一旦项目里引入了 Spring Security,@WebMvcTest会把 Spring Security 的过滤链也加载进来。这时候你经常会遇到一个现象:接口本身逻辑没问题,但测试请求直接返回 401 或 403,完全没法往下走。
如果测试的目标是接口的参数校验和响应结构,不是权限控制,最简单的做法是在测试类或测试方法上加@WithMockUser:
@WebMvcTest(UserController.class) @WithMockUser(username = "zhangsan", roles = "USER") class UserControllerTest { // ... }加了之后,SecurityContext 里会存在一个模拟认证对象,Controller 里通过@AuthenticationPrincipal或SecurityContextHolder获取当前用户就不会失败。
如果测试的目标就是验证权限规则,就不能随便用@WithMockUser大而化之地跳过,这时候应该构造真实用户,并对SecurityFilterChain的运行结果做断言。否则你只是在“假装测权限”。
4.3 文件上传、异常响应这些特殊场景
文件上传是 Controller 测试里比较容易写错的地方。关键是要用MockMultipartFile构造上传文件:
@Test void shouldUploadFile() throws Exception { mockMvc.perform(multipart("/files") .file(new MockMultipartFile( "file", "hello.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes(StandardCharsets.UTF_8)))) .andExpect(status().isOk()); }这里要注意请求参数名必须和 Controller 方法里的@RequestPart("file")对应。很多初学者写的是"fileName"或者随便起名,结果一直 400,实际上就是参数名不匹配。
异常响应测试也很重要。我的习惯是让 Controller 层尽量不做 try-catch,而是抛业务异常,由@RestControllerAdvice统一处理。测试时通过 mock Service 抛出对应异常,再验证最终的响应体结构:
@Test void shouldReturnBadRequestWhenNameBlank() throws Exception { given(userService.create(any())) .willThrow(new BusinessException("名称不能为空")); mockMvc.perform(post("/users") .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("INVALID_ARGUMENT")); }这样测试才是对“对外契约”真正形成约束,而不是只测了个 200。
5. 测试套件崩溃排查:一次“从 20 秒变 2 分钟”的完整追踪
5.1 现象:全量测试突然越跑越慢
我最近处理过一个真实项目,原本跑完整套测试大概 20 秒,某天开始突然变成 2 分钟。第一次听到这个反馈,我第一反应不是“加机器”,而是先怀疑上下文缓存失效。
排查链路大概是这样的:
- 先抽单个测试类出来跑,比如
mvn test -Dtest=SomeTest,发现单类启动时间并不慢,问题应该出在多个类协作时。 - 再跑全量,留意日志里有没有大量
Closing org.springframework.context...的输出。一旦发现上下文频繁创建和关闭,说明每次测试类都重新启动了完整的 Spring 上下文。 - 逐个检查测试类上的注解配置,最终发现有一半的测试类单独写了
spring.datasource.url,而且值各不相同。每个不同的值都会生成不同的 context cache key,缓存自然失效。 - 修复方式很粗暴但有效:把数据库连接信息统一放到
application-test.yml,所有测试类统一@ActiveProfiles("test"),移除测试类上零散的 properties。改完以后全量时间直接回到 20 多秒。
这个案例说明,排查测试慢的问题,不是靠猜,而是靠观察 Spring 上下文生命周期。看到“上下文启停次数”明显高于预期,就往 cache key 不一致的方向查。
5.2 现象:@MockBean 一堆,测试套件仍然巨慢
还有一种很常见的慢,是@MockBean用太多了。@MockBean会在底层注册一个ContextCustomizer,Spring 在计算 context cache key 时会把所有ContextCustomizer一起算进去。也就是说,只要两个测试类 mock 的 Bean 不同,它们的上下文 cache key 就不同,Spring 就会各自启动一个完整上下文。
这和“每个测试类都启动一次容器”几乎等价。所以如果某个项目里散落着几十个@MockBean,全量测试慢是很正常的。
处理方式我推荐这么几种:
- 尽可能用切片测试,比如
@WebMvcTest本身启动的上下文就比@SpringBootTest轻量很多,即使重新创建,代价也可控。 - 如果必须在同一个大上下文里做多个模块的测试,尽量让不同测试类的
@MockBean集合保持一致,这样 context cache key 还能复用。 - 考虑用
@Import一个专门的@TestConfiguration,在配置里统一声明 mock,而不是每个测试类各写各的。
5.3 现象:测试启动时端口冲突、连接被拒
端口冲突的经典场景是集成测试用了固定端口DEFINED_PORT。本地跑单个类没问题,但 CI 并发或者同一台机器上多个测试进程同时跑,8080 端口就打架了。处理方法上面已经说过:统一用RANDOM_PORT,端口通过@LocalServerPort动态获取。
连接被拒的另一种情况是代码在启动时连接外部的 Redis、数据库,但测试环境没有对应服务。这种问题不是“改端口”能解决的,而是要先把外部依赖 mock 掉,或者用 Testcontainers 把中间件拉起来。测试代码里永远不要直连生产环境或开发环境的中间件,否则测试结果会被环境状态绑架。
5.4 怎么判断是环境问题还是真实代码问题
我摸索出的一个快速判断技巧:先用-Dspring.profiles.active=test明确指定测试 profile,把运维相关的配置都隔离干净;然后跑单个测试方法,如果单方法通过但全量跑就挂,先怀疑测试之间的数据污染或上下文复用问题;如果单个方法就不通过,再看日志里是 Bean 装配失败还是数据条数不对。
另一个有效动作是开启 SQL 输出,观察测试到底执行了什么语句。很多时候你以为是代码逻辑错了,实际是@Sql脚本重复插入,或者回滚没生效导致数据残留。
6. 长期维护测试代码的几条实操建议
6.1 测试命名和包结构要统一
命名看似小事,但对团队协作影响很大。我推荐用shouldExpectResult_whenCondition这种格式,比如shouldReturnUser_whenUserIdExists、shouldThrowException_whenStockNotEnough。这种命名会把“前置条件”和“预期结果”都带出来,后期看测试报告时不用点开代码就能知道业务行为。
包结构上,测试类尽量和被测试类包路径对齐,比如com.example.controller.UserControllerTest放在com.example.controller包下。这样 IDE 跳转、Maven 报告、代码覆盖率工具都不容易错乱。
6.2 并行执行要谨慎,只稳定时才开
JUnit 5 支持并行执行,可以在junit-platform.properties里配置:
junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent以及 Maven Surefire 插件的配置:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <parallel>methods</parallel> <threadCount>4</threadCount> </configuration> </plugin>但我自己的经验是:并行执行只对纯单元测试比较安全。切片测试和集成测试并行之后,会遇到上下文创建竞争、随机端口分配、数据库连接数超限等问题。如果一定要并行,建议用@Tag或按目录拆分,把纯单测和集成测试分开执行,不要一把梭全并行。
6.3 测试代码的 review 不能只看“绿不绿”
我见过太多假测试:方法里只调用了目标接口,没有任何断言,或者用assertDoesNotThrow包住一切,实际什么都没验证。这种测试看着“绿”,实际毫无保护力。
写测试时至少补足三类场景:正常路径、异常路径、边界值。比如参数校验测试,不仅要测name正常的情况,还要测name为空、超长、null 的情况。空集合、单元素集合、大量数据,也要有对应测试。
另外,测试代码要像生产代码一样 review。review 时重点看几个点:断言是否有效、是否依赖执行顺序、是否依赖真实外部网络、是否有不必要的等待或睡眠。一旦发现这些问题,尽早修掉,别让测试变成团队的负债。
这几年我接手项目时有个很直观的感受:只要一个项目的测试套件跑得又快又稳,团队对代码改动的信心就高,重构、升级依赖都敢做。反过来,测试一跑就挂、各种环境依赖,团队就会越来越不信任测试,最后用“我本地跑得通”来掩盖真实风险,这是最危险的。如果你想从今天开始改善自己的 Spring Boot 测试,我个人的建议很直接:先挑最薄弱的 Controller 测试或 Repository 测试开刀,统一一个测试基类,把测试配置收拢到同一个 profile,再把测试代码当作正式代码来评审。每修复一个又慢又脆弱的测试,后面改代码的底气就会多一分。