Spring Boot测试分层:@SpringBootTest、@WebMvcTest、@DataJpaTest的选择
Spring Boot测试分层:@SpringBootTest、@WebMvcTest、@DataJpaTest的选择
适读人群:写单元测试和集成测试的Java后端开发者 | 阅读时长:约15分钟
开篇故事
刚开始写测试那会儿,我所有测试都用@SpringBootTest。代码是对了,但每跑一次CI,测试要花好几分钟。后来一个同事说:你这样不对,@SpringBootTest会启动整个容器,很慢。
他让我看了Spring Boot的测试切片(Test Slices)文档,才知道原来测试也有分层:
- 测Controller层,用
@WebMvcTest,不启动数据库 - 测Repository层,用
@DataJpaTest,只起内存数据库 - 测完整流程,用
@SpringBootTest
改完之后,CI测试时间从5分钟降到了1分半。
今天把这三个注解的使用场景、源码原理和最佳实践全部整理出来。
一、测试分层的设计思想
Spring Boot的测试切片基于"只加载测试所需的最小上下文"原则:
二、三种测试注解详解
2.1 @SpringBootTest:完整集成测试
// 加载完整ApplicationContext,最接近真实运行环境
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll(); // 清理数据,确保测试隔离
}
@Test
void createUser_shouldReturn201() {
CreateUserRequest request = new CreateUserRequest("alice", "alice@example.com");
ResponseEntity<UserDTO> response = restTemplate.postForEntity(
"http://localhost:" + port + "/api/users",
request,
UserDTO.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().username()).isEqualTo("alice");
// 验证数据库也写入了
assertThat(userRepository.count()).isEqualTo(1);
}
}WebEnvironment选项:
RANDOM_PORT:启动真实的Web服务器,随机端口(推荐用于完整测试)DEFINED_PORT:使用server.port配置的端口MOCK(默认):不启动真实服务器,Mock Servlet环境,配合MockMvc使用NONE:不启动Web环境,适合测试非Web组件
2.2 @WebMvcTest:Controller层切片测试
// 只加载Web层,不加载Service/Repository
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
// 必须Mock所有Controller依赖的Service
@MockBean
private UserService userService;
@Test
void getUser_shouldReturn200WithUserData() throws Exception {
// 准备Mock数据
UserDTO mockUser = new UserDTO(1L, "alice", "alice@example.com");
when(userService.findById(1L)).thenReturn(Optional.of(mockUser));
// 执行请求
mockMvc.perform(get("/api/users/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
// 验证Service被调用了
verify(userService).findById(1L);
}
@Test
void createUser_withInvalidInput_shouldReturn400() throws Exception {
// 空用户名,应该返回400
CreateUserRequest invalidRequest = new CreateUserRequest("", "not-an-email");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
@Test
void getUser_whenNotFound_shouldReturn404() throws Exception {
when(userService.findById(999L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
}
}@WebMvcTest的源码实现:
// @WebMvcTest 通过 @TypeExcludeFilters(WebMvcTypeExcludeFilter.class)
// 只加载以下类型的Bean:
// - @Controller, @ControllerAdvice
// - @JsonComponent
// - Filter
// - WebMvcConfigurer
// - HandlerMethodArgumentResolver
// 而Service、Repository等业务层Bean不会被加载2.3 @DataJpaTest:Repository层切片测试
// 只加载JPA相关Bean,默认使用内存数据库(H2)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
// replace=NONE 使用真实配置的数据库(通常不推荐,会影响生产数据)
// 默认 replace=AUTO_CONFIGURED 使用内存H2
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager; // JPA测试专用EntityManager
@Test
void findByEmail_shouldReturnUser() {
// 使用TestEntityManager插入测试数据(绕过Repository层)
User user = new User();
user.setUsername("alice");
user.setEmail("alice@example.com");
entityManager.persistAndFlush(user);
// 测试Repository方法
Optional<User> found = userRepository.findByEmail("alice@example.com");
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("alice");
}
@Test
void findActiveUsers_shouldOnlyReturnActiveOnes() {
// 插入测试数据
User active = entityManager.persistAndFlush(
User.builder().username("alice").active(true).build());
User inactive = entityManager.persistAndFlush(
User.builder().username("bob").active(false).build());
List<User> activeUsers = userRepository.findByActiveTrue();
assertThat(activeUsers).hasSize(1);
assertThat(activeUsers.get(0).getUsername()).isEqualTo("alice");
}
@Test
@Transactional // @DataJpaTest默认每个测试在事务中,结束后回滚
void createUser_shouldPersistCorrectly() {
User user = User.builder()
.username("charlie")
.email("charlie@test.com")
.createdAt(LocalDateTime.now())
.build();
User saved = userRepository.save(user);
entityManager.flush();
entityManager.clear(); // 清除一级缓存,确保下面的查询真的走数据库
User reloaded = userRepository.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getUsername()).isEqualTo("charlie");
assertThat(reloaded.getCreatedAt()).isNotNull();
}
}三、完整代码示例:三层测试体系
3.1 Service层测试(纯单元测试,不用Spring)
// Service层:用纯Mockito,不需要Spring,最快
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private UserServiceImpl userService;
@Test
void register_shouldSaveUserAndPublishEvent() {
// Arrange
CreateUserRequest request = new CreateUserRequest("alice", "alice@test.com", "ref123");
User savedUser = User.builder().id(1L).username("alice").build();
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// Act
User result = userService.register(request);
// Assert
assertThat(result.getId()).isEqualTo(1L);
verify(userRepository).save(any(User.class));
verify(eventPublisher).publishEvent(any(UserRegisteredEvent.class));
// Email是异步的,这里不验证(由EventListener的测试负责)
}
@Test
void register_whenEmailExists_shouldThrowException() {
CreateUserRequest request = new CreateUserRequest("alice", "alice@test.com", null);
when(userRepository.existsByEmail("alice@test.com")).thenReturn(true);
assertThatThrownBy(() -> userService.register(request))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("alice@test.com");
}
}3.2 测试配置文件隔离
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
mail:
host: localhost # 测试中不发真实邮件
# 关闭不需要的功能
spring:
devtools:
restart:
enabled: false// 测试基类,统一配置
@SpringBootTest
@ActiveProfiles("test")
@Transactional // 每个测试自动回滚,保证数据隔离
abstract class BaseIntegrationTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
}四、踩坑实录
坑1:@WebMvcTest和Security配置冲突
现象:@WebMvcTest测试时,所有请求都返回401,即使没有权限要求。
根因:@WebMvcTest会加载SecurityConfiguration(它是WebMvcConfigurer的子类),Spring Security会默认保护所有请求。
解决:
@WebMvcTest(UserController.class)
@Import(SecurityTestConfig.class) // 引入测试专用Security配置
class UserControllerTest { ... }
// 测试专用Security配置:放行所有请求
@TestConfiguration
public class SecurityTestConfig {
@Bean
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(csrf -> csrf.disable())
.build();
}
}坑2:@DataJpaTest默认替换DataSource
@DataJpaTest默认会把你配置的DataSource替换成H2内存数据库(@AutoConfigureTestDatabase(replace=Replace.AUTO_CONFIGURED))。如果你的SQL用了MySQL特有语法,测试会失败。
解决:要么兼容H2的MySQL模式,要么指定用真实配置:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test") // 使用test profile的数据库配置
class RepositoryTest { ... }坑3:@SpringBootTest上下文缓存失效
现象:测试跑得越来越慢,明明只有几个测试,但容器启动了很多次。
根因:Spring Test框架会缓存ApplicationContext,但缓存的key包括很多参数(Profile、Properties、ContextLoader等)。如果不同测试类的配置不一样,会导致多次启动容器。
最佳实践:
// 尽量让测试类使用相同的Context配置,触发缓存命中
// 避免在@SpringBootTest的properties里添加不同参数
// 把公共配置放到基类
@SpringBootTest(
webEnvironment = RANDOM_PORT,
properties = {"spring.profiles.active=test"} // 统一用test profile
)
abstract class BaseTest { ... }坑4:MockBean导致上下文缓存失效
每次在测试中加@MockBean,都会触发创建新的ApplicationContext(因为Bean定义变了)。如果每个测试类都有不同的@MockBean,上下文就无法复用。
解决:把常用的Mock集中到基类,或者用@MockBeanOverride(Spring Boot 3.4+新特性)。
五、总结与延伸
测试分层决策矩阵:
| 测试目标 | 推荐注解 | 速度 |
|---|---|---|
| Controller逻辑、参数验证、HTTP状态码 | @WebMvcTest | 快 |
| SQL查询、JPA映射、Repository方法 | @DataJpaTest | 快 |
| 业务逻辑 | 纯JUnit+Mockito | 最快 |
| 完整流程、多层协作 | @SpringBootTest | 慢 |
| JSON序列化/反序列化 | @JsonTest | 最快 |
测试金字塔原则:单元测试最多、集成测试次之、端到端测试最少。@SpringBootTest是"端到端"的范畴,不应该是主力测试方式。
下一篇开始进入Function Call系列,聊LLM是如何识别函数调用意图的。
