Spring Boot 集成测试分层策略——哪些用 @SpringBootTest,哪些用切片
Spring Boot 集成测试分层策略——哪些用 @SpringBootTest,哪些用切片
适读人群:Java 后端开发者、Spring Boot 实践者 | 阅读时长:约 15 分钟 | 核心价值:精准选择测试注解,让测试既高效又全面
入职新公司的第一个月,我被一个存在了两年的"传统"深深困扰了。
那个项目的测试代码,几乎所有测试类都是 @SpringBootTest。Controller 测试是 @SpringBootTest,Repository 测试是 @SpringBootTest,Service 单元测试也是 @SpringBootTest。整个应用上下文,每次都完整加载一遍:连接池、Kafka 监听器、定时任务、所有的 Bean,300 多个。
结果就是,跑一个 Repository 测试,需要等 40 秒 Spring 上下文启动,然后 1 秒内测试结束。跑整套 CI,光是测试就需要 35 分钟。
我问老员工为什么要这样写,他说:"@SpringBootTest 最保险,啥都有,不会漏。"
这句话,是对集成测试最大的误解之一。
@SpringBootTest 的"保险"是有代价的:它慢、笨重,而且经常测了不该测的东西。测试的精确度和覆盖范围是可以对齐的,前提是你要明白每种测试注解的边界在哪里。
今天这篇,把 Spring Boot 测试分层的选择逻辑彻底说清楚。
一、Spring Boot 测试注解全景图
Spring Boot 提供了多种"切片(Slice)"测试注解,每个只加载应用的特定层:
| 注解 | 加载范围 | 适用场景 | 启动速度 |
|---|---|---|---|
@SpringBootTest | 完整应用上下文 | 端到端集成测试 | 慢(10-60s) |
@WebMvcTest | Web 层(Controller、Filter、Interceptor) | Controller 单元/切片测试 | 快(2-5s) |
@DataJpaTest | JPA 层(Repository、Entity、JPA配置) | Repository 测试 | 中(5-10s) |
@DataMongoTest | MongoDB 层 | MongoDB Repository 测试 | 中 |
@DataRedisTest | Redis 层 | Redis 相关测试 | 中 |
@JsonTest | JSON 序列化/反序列化 | DTO 序列化测试 | 极快(<1s) |
@RestClientTest | RestTemplate/WebClient | HTTP 客户端测试 | 快 |
| 无注解(纯 JUnit) | 无 Spring 上下文 | 纯业务逻辑单元测试 | 极快(<1s) |
二、决策树:如何选择测试类型
三个问题定位你该用什么:
需要 Spring 上下文吗? 如果是纯计算逻辑(税率计算、格式转换),不需要,用纯 JUnit。
需要完整的应用上下文吗? 如果只测某一层,用对应的切片注解。
需要测试完整请求链路吗? 从 HTTP 请求到数据库响应,用
@SpringBootTest+ 真实容器。
三、各类测试详解与示例
3.1 纯单元测试(无 Spring 上下文)
适合:业务逻辑、计算函数、转换器
// 不需要任何 Spring 注解
class PriceCalculatorTest {
private final PriceCalculator calculator = new PriceCalculator();
@Test
void 会员折扣_黄金会员_享受九折() {
BigDecimal originalPrice = new BigDecimal("100.00");
BigDecimal discount = calculator.calculateDiscount(originalPrice, MemberLevel.GOLD);
assertThat(discount).isEqualByComparingTo("90.00");
}
@Test
void 满减活动_满300减50_正确计算() {
BigDecimal total = new BigDecimal("350.00");
BigDecimal finalPrice = calculator.applyPromotion(total,
Promotion.of(300, 50));
assertThat(finalPrice).isEqualByComparingTo("300.00");
}
}启动时间: < 100ms
3.2 @WebMvcTest:Controller 切片测试
适合:验证 Controller 的路由、参数绑定、请求/响应格式、权限验证
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService; // Service 层 Mock 掉
@Test
void 获取用户_存在_返回200和用户数据() throws Exception {
// given
UserDto userDto = new UserDto(1L, "张三", "zhangsan@example.com");
given(userService.getUser(1L)).willReturn(Optional.of(userDto));
// when & then
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.email").value("zhangsan@example.com"));
}
@Test
void 创建用户_请求体缺少必填字段_返回400() throws Exception {
String invalidJson = """
{
"name": ""
}
"""; // 缺少 email 字段
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
@Test
@WithMockUser(roles = "ADMIN")
void 删除用户_管理员权限_返回204() throws Exception {
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(roles = "USER")
void 删除用户_普通用户权限_返回403() throws Exception {
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isForbidden());
}
}关键理解: @WebMvcTest 只加载 Web 层,Service 和 Repository 都需要 @MockBean。这个级别的测试是测 Controller 逻辑的,不是测业务逻辑的。
3.3 @DataJpaTest:Repository 切片测试
适合:测试自定义查询、JPQL、Native Query、分页
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 不替换数据源(用 Testcontainers)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void 按用户ID和状态查询_分页_结果正确() {
// given
for (int i = 1; i <= 15; i++) {
Order order = Order.builder()
.userId(1001L)
.status(i % 3 == 0 ? OrderStatus.COMPLETED : OrderStatus.PENDING)
.amount(new BigDecimal(i * 100))
.build();
entityManager.persistAndFlush(order);
}
// when
Page<Order> result = orderRepository.findByUserIdAndStatus(
1001L, OrderStatus.PENDING, PageRequest.of(0, 5));
// then
assertThat(result.getTotalElements()).isEqualTo(10); // 15 个里 10 个是 PENDING
assertThat(result.getContent()).hasSize(5); // 第一页 5 条
}
@Test
void 统计查询_按状态分组统计数量_结果正确() {
// given
List<Order> orders = List.of(
buildOrder(OrderStatus.PENDING),
buildOrder(OrderStatus.PENDING),
buildOrder(OrderStatus.COMPLETED),
buildOrder(OrderStatus.CANCELLED)
);
orders.forEach(o -> entityManager.persistAndFlush(o));
// when
List<OrderStatusCount> counts = orderRepository.countByStatus();
// then
Map<OrderStatus, Long> countMap = counts.stream()
.collect(Collectors.toMap(OrderStatusCount::getStatus, OrderStatusCount::getCount));
assertThat(countMap.get(OrderStatus.PENDING)).isEqualTo(2);
assertThat(countMap.get(OrderStatus.COMPLETED)).isEqualTo(1);
}
private Order buildOrder(OrderStatus status) {
return Order.builder()
.userId(1001L)
.status(status)
.amount(new BigDecimal("100.00"))
.build();
}
}3.4 @SpringBootTest:完整集成测试
保留给真正需要端到端验证的场景:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderCreationE2ETest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@LocalServerPort
private int port;
@Autowired
private OrderRepository orderRepository;
@Test
void 创建订单_完整链路_HTTP请求到数据库到Kafka消息() {
// given
String requestBody = """
{
"userId": 1001,
"items": [{"productId": 100, "quantity": 2, "price": 199.00}]
}
""";
// when - 真实 HTTP 请求
RestAssured
.given()
.port(port)
.contentType(ContentType.JSON)
.body(requestBody)
.when()
.post("/api/orders")
.then()
.statusCode(201)
.body("orderId", notNullValue())
.body("status", equalTo("PENDING"));
// then - 验证数据库
await().atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertThat(orderRepository.count()).isEqualTo(1));
}
}四、三个踩坑实录
坑 1:@DataJpaTest 默认替换数据源用 H2
现象: 用了 @DataJpaTest,但测试连的还是 H2,MySQL 的特性测试不到。
原因: @DataJpaTest 默认配置 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY),会把数据源替换成内嵌 H2。
解法:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// 加上这一行,不替换数据源,然后用 Testcontainers 提供真实 MySQL坑 2:@WebMvcTest 加载了不需要的安全配置
现象: 加了 @WebMvcTest 后,所有接口都返回 401,需要在每个测试里都加 @WithMockUser,很麻烦。
原因: @WebMvcTest 会加载 Spring Security 的 Web 配置,如果你的应用配置了全局安全规则,测试就会受影响。
解法一: 每个测试加 @WithMockUser(roles = "USER")(推荐,因为这也是在测权限)。
解法二: 测试安全配置的覆盖(只在不关心权限的测试里用):
@WebMvcTest(ProductController.class)
@Import(TestSecurityConfig.class) // 覆盖安全配置
class ProductControllerTest {
// ...
}
@TestConfiguration
class TestSecurityConfig {
@Bean
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.build();
}
}坑 3:@SpringBootTest 的上下文缓存机制导致测试顺序敏感
现象: 测试 A 和测试 B 分别修改了同一个 Bean 的状态,后跑的测试结果受前一个测试影响。
原因: Spring 的测试上下文会被缓存,不同测试类共享同一个上下文时,Bean 的状态也被共享了。
解法: 对于会修改 Bean 状态的测试,加 @DirtiesContext 强制刷新上下文:
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class StatefulIntegrationTest {
// 每个测试方法结束后刷新上下文
// 注意:这会让测试变慢,只在必要时使用
}五、测试策略全景建议
测试金字塔的合理分布(经验值):
- 纯单元测试:60%(快,覆盖业务逻辑)
- 切片测试(WebMvcTest/DataJpaTest):25%(中速,覆盖层间交互)
- 完整集成测试(SpringBootTest + Testcontainers):15%(慢,覆盖关键链路)
把 @SpringBootTest 的数量控制在整体测试的 15% 以内,是保持测试套件健康的关键。
选对注解,不是偷懒,是精准。
