Spring Boot Test 深度实战——@SpringBootTest、切片测试、测试配置隔离
Spring Boot Test 深度实战——@SpringBootTest、切片测试、测试配置隔离
适读人群:Spring Boot 项目的测试写得一团糟,或者测试运行很慢、配置老是出问题的 Java 工程师 | 阅读时长:约14分钟 | 核心价值:搞清楚 Spring Boot Test 的分层机制,写出快速、可靠的 Spring 测试
那次让测试套件从 8 分钟跑到 45 秒的优化
2022年我参与了一个项目的测试优化,当时他们的 CI 流水线上测试要跑 8 分钟。
我打开项目一看,全是这样的测试:
@SpringBootTest // 全量启动整个 Spring 容器
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void testGetUserName() {
// 这个测试只需要 UserController 和 UserService
// 但 @SpringBootTest 把整个应用的 Bean 都启动了
// 数据库连接、Redis 连接、消息队列……全拉起来
}
}120 个测试类,全部用 @SpringBootTest。每个测试类都触发一次完整的 Spring 上下文启动,加上上下文不能复用(因为配置各不相同),每次启动需要 3-4 秒。
问题很清楚:大量只需要测一个 Controller 或一个 Service 的测试,却启动了整个应用。
优化之后,把 120 个测试类拆分成三层:
- 30% 改成纯单元测试(不用 Spring 容器)
- 50% 改成切片测试(
@WebMvcTest、@DataJpaTest等) - 20% 保留完整
@SpringBootTest(集成测试)
结果:测试时间从 8 分钟降到 45 秒。
@SpringBootTest 的代价与适用场景
@SpringBootTest 做的事情:启动一个接近完整的 Spring 应用上下文,包括所有 @Component、@Service、@Repository、@Controller,以及所有配置类。
这很重量级。它适合的场景是:
- 验证多个层之间的集成是否正确(Controller → Service → Repository)
- 验证 Spring 配置是否正确(Bean 依赖关系、Auto-configuration)
- 端到端流程测试
不适合的场景:
- 只测一个 Service 的逻辑
- 只测一个 Controller 的请求/响应格式
- 只测 JPA 的查询语句
减少 @SpringBootTest 的启动代价
当必须用 @SpringBootTest 时,让上下文可以被复用:
// 同一个注解配置 = 同一个上下文(Spring 会复用)
@SpringBootTest
@ActiveProfiles("test")
// 只要上面的注解配置完全相同,多个测试类共享同一个 Spring 上下文
class OrderServiceIntegrationTest { ... }
@SpringBootTest
@ActiveProfiles("test")
class PaymentServiceIntegrationTest { ... }如果每个测试类的 @SpringBootTest 配置不同(classes 参数、properties 参数不同),Spring 会为每个配置创建一个新的上下文,无法复用。所以要尽量统一配置。
切片测试:Spring Boot 提供的精准测试工具
切片测试是 Spring Boot Test 最核心的优化机制:只启动被测层所需的那部分 Spring 上下文。
@WebMvcTest:测试 Controller 层
@WebMvcTest(UserController.class) // 只加载 UserController,不加载 Service、DAO
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // 用 @MockBean 替代真实的 Service
private UserService userService;
@Test
void testGetUser_success() throws Exception {
UserVO mockUser = new UserVO(1L, "张三", "zhangsan@test.com");
when(userService.getUser(1L)).thenReturn(Optional.of(mockUser));
mockMvc.perform(get("/api/users/1")
.header("Authorization", "Bearer test-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.email").value("zhangsan@test.com"));
}
@Test
void testGetUser_notFound() throws Exception {
when(userService.getUser(999L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"));
}
@Test
void testCreateUser_invalidEmail() throws Exception {
String requestBody = """
{
"name": "张三",
"email": "not-a-valid-email",
"password": "password123"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("email"));
}
}@WebMvcTest 只加载:Controller、ControllerAdvice、Filter、WebMvcConfigurer。不加载 Service、Repository、Component。
@DataJpaTest:测试 Repository 层
@DataJpaTest // 只启动 JPA 相关组件 + H2 内存数据库
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void testFindByUserId() {
// 准备数据
Order order1 = entityManager.persistAndFlush(
Order.builder().userId(1L).status(OrderStatus.PENDING)
.amount(new BigDecimal("100")).build()
);
Order order2 = entityManager.persistAndFlush(
Order.builder().userId(1L).status(OrderStatus.PAID)
.amount(new BigDecimal("200")).build()
);
entityManager.persistAndFlush(
Order.builder().userId(2L).status(OrderStatus.PENDING)
.amount(new BigDecimal("50")).build()
);
// 测试查询
List<Order> userOrders = orderRepository.findByUserId(1L);
assertEquals(2, userOrders.size());
// 验证排序(按创建时间倒序)
List<Order> pendingOrders = orderRepository.findByUserIdAndStatus(1L, OrderStatus.PENDING);
assertEquals(1, pendingOrders.size());
assertEquals(order1.getId(), pendingOrders.get(0).getId());
}
@Test
void testCustomQuery_findOrdersWithAmountGreaterThan() {
entityManager.persistAndFlush(Order.builder().userId(1L).amount(new BigDecimal("100")).build());
entityManager.persistAndFlush(Order.builder().userId(1L).amount(new BigDecimal("500")).build());
entityManager.persistAndFlush(Order.builder().userId(1L).amount(new BigDecimal("50")).build());
List<Order> largeOrders = orderRepository.findByAmountGreaterThan(new BigDecimal("200"));
assertEquals(1, largeOrders.size());
assertEquals(0, new BigDecimal("500").compareTo(largeOrders.get(0).getAmount()));
}
}常用切片测试注解速查
| 注解 | 加载的组件 | 适用场景 |
|---|---|---|
@WebMvcTest | Controller、Filter、Advice | Controller 层测试 |
@DataJpaTest | JPA、H2 | Repository 层测试 |
@DataMongoTest | MongoDB | MongoDB Repository 测试 |
@DataRedisTest | Redis | Redis Repository 测试 |
@WebFluxTest | WebFlux Controllers | 响应式 Controller 测试 |
@JsonTest | Jackson/Gson | JSON 序列化/反序列化测试 |
@RestClientTest | RestTemplate/WebClient | HTTP 客户端测试 |
测试配置隔离:让测试不影响生产配置
@TestConfiguration
// 测试专用配置,不会被主配置扫描到
@TestConfiguration
public class TestSecurityConfig {
@Bean
@Primary // 替换生产环境的 Bean
public SecurityFilter testSecurityFilter() {
// 测试环境的安全过滤器(直接放行所有请求)
return new NoOpSecurityFilter();
}
}
// 在测试类里导入
@WebMvcTest(UserController.class)
@Import(TestSecurityConfig.class)
class UserControllerTest { ... }application-test.yml
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
redis:
host: localhost
port: 6380 # 测试用的 Redis 端口(Docker 启动)
# 关闭不需要的功能
scheduling:
enabled: false # 测试时不跑定时任务
feign:
client:
config:
default:
connect-timeout: 500
read-timeout: 500在测试类里激活:
@SpringBootTest
@ActiveProfiles("test")
class IntegrationTest { ... }用 @DynamicPropertySource 动态配置
当需要动态获取端口(比如 Testcontainers)时:
@SpringBootTest
@Testcontainers
class RedisIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7.0")
.withExposedPorts(6379);
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
}
@Test
void testRedisCache() {
// Redis 连接地址是动态的,由 Testcontainers 分配
}
}踩坑实录三则
踩坑一:@WebMvcTest 测试里安全过滤器拦截了所有请求,全部 401
现象:@WebMvcTest 测试里所有请求都返回 401,即使加了 @WithMockUser 也不行。
原因:项目有自定义的 JWT 安全过滤器,@WebMvcTest 会加载 Filter,JWT 过滤器把所有没有 JWT Token 的请求都拦截了。
解法一:在测试里提供测试用的 SecurityConfig:
@WebMvcTest(UserController.class)
@Import(TestSecurityConfig.class) // 测试安全配置,放行所有请求
class UserControllerTest { ... }解法二:在测试里 Mock 安全相关的 Bean:
@WebMvcTest(UserController.class)
class UserControllerTest {
@MockBean
private JwtTokenProvider jwtTokenProvider; // Mock JWT 验证
@BeforeEach
void setUp() {
when(jwtTokenProvider.validateToken(anyString())).thenReturn(true);
when(jwtTokenProvider.getUserIdFromToken(anyString())).thenReturn(1L);
}
}踩坑二:@DataJpaTest 里使用了真实数据库的 SQL 方言,H2 不支持
现象:@DataJpaTest 跑起来报 SQL 语法错误,类似 Syntax error in SQL statement。
原因:Repository 里有 @Query 使用了 PostgreSQL/MySQL 特有的 SQL 语法(如 ILIKE、CONCAT_WS),H2 不支持这些。
解法一:用 H2 兼容模式:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL"
})
class OrderRepositoryTest { ... }解法二:不用 H2,直接用真实数据库(Testcontainers):
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}踩坑三:多个 @SpringBootTest 类的上下文无法复用,测试很慢
现象:明明都用了 @SpringBootTest,但启动了很多个 Spring 上下文,内存也涨得很高。
原因:不同测试类的上下文配置略有不同:
- 有的用了
@MockBean(会强制创建新上下文) - 有的用了不同的
properties - 有的用了不同的
@ActiveProfiles
解法:
- 把公共的
@MockBean和配置提取到基类里 - 减少使用
@MockBean(用@Profile配置不同环境的实现替代) - 确保需要共享上下文的测试类有完全相同的配置
// 基类统一配置
@SpringBootTest
@ActiveProfiles("test")
@Transactional
abstract class BaseIntegrationTest {
// 公共的 @MockBean 放这里
@MockBean
protected EmailSender emailSender;
@MockBean
protected SmsService smsService;
}
// 子类只写自己的测试逻辑
class OrderServiceIntegrationTest extends BaseIntegrationTest {
@Test
void testCreateOrder() { ... }
}测试分层是 Spring Boot 测试的核心策略。记住这个规则:能用切片测试解决的,就不要用 @SpringBootTest;能用纯单元测试解决的,就不要启动 Spring 容器。
