Spring测试框架指南
约 1601 字大约 5 分钟
springtesting
2025-04-09
概述
Spring 提供了完善的测试支持框架,覆盖从单元测试到集成测试的各个层面。通过 @SpringBootTest、@WebMvcTest、@DataJpaTest 等注解,可以按需加载 Spring 上下文,实现快速、精准的测试。本文详细介绍 Spring Boot 测试框架的核心注解、Mock 技术和最佳实践。
测试金字塔
测试注解体系
| 注解 | 加载范围 | 适用场景 |
|---|---|---|
@SpringBootTest | 完整 ApplicationContext | 集成测试、端到端测试 |
@WebMvcTest | Web 层(Controller、Filter、Advice) | Controller 单元测试 |
@DataJpaTest | JPA 层(Repository、EntityManager) | Repository 单元测试 |
@WebFluxTest | WebFlux 层 | 响应式 Controller 测试 |
@JsonTest | JSON 序列化/反序列化 | DTO 序列化测试 |
@SpringBootTest —— 完整集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderServiceIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@MockBean
private PaymentClient paymentClient;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
}
@Test
void shouldCreateOrderSuccessfully() {
// Arrange
when(paymentClient.charge(any())).thenReturn(new PaymentResult("SUCCESS"));
CreateOrderRequest request = new CreateOrderRequest();
request.setProductId("PROD-001");
request.setQuantity(2);
request.setAmount(new BigDecimal("199.00"));
// Act
ResponseEntity<Order> response = restTemplate.postForEntity(
"/api/orders", request, Order.class);
// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getStatus()).isEqualTo("CREATED");
// 验证数据库
Optional<Order> saved = orderRepository.findById(response.getBody().getId());
assertThat(saved).isPresent();
assertThat(saved.get().getAmount()).isEqualByComparingTo("199.00");
}
@Test
void shouldReturn404WhenOrderNotFound() {
ResponseEntity<ErrorResponse> response = restTemplate.getForEntity(
"/api/orders/99999", ErrorResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}WebEnvironment 选项
// MOCK(默认):模拟Servlet环境,不启动真实服务器
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
// RANDOM_PORT:启动真实服务器,随机端口
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// DEFINED_PORT:启动真实服务器,使用配置端口
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
// NONE:不加载Web环境
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)@WebMvcTest —— Web 层切片测试
只加载 Web 层相关的 Bean(Controller、ControllerAdvice、Filter 等),不加载 Service、Repository。
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUserWhenFound() throws Exception {
// Arrange
User user = new User(1L, "alice", "alice@example.com");
when(userService.findById(1L)).thenReturn(Optional.of(user));
// Act & Assert
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"));
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.findById(99L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/users/99"))
.andExpect(status().isNotFound());
}
@Test
void shouldValidateRequestBody() throws Exception {
// 缺少必填字段
String invalidRequest = """
{
"username": "",
"email": "not-an-email"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors.length()").value(greaterThan(0)));
}
@Test
void shouldCreateUserSuccessfully() throws Exception {
String request = """
{
"username": "bob",
"email": "bob@example.com"
}
""";
User created = new User(2L, "bob", "bob@example.com");
when(userService.create(any(CreateUserRequest.class))).thenReturn(created);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2))
.andExpect(jsonPath("$.username").value("bob"));
verify(userService).create(argThat(req ->
req.getUsername().equals("bob") &&
req.getEmail().equals("bob@example.com")));
}
}MockMvc 高级用法
@WebMvcTest(OrderController.class)
class OrderControllerAdvancedTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldSupportPagination() throws Exception {
Page<Order> page = new PageImpl<>(
List.of(new Order("ORD-1"), new Order("ORD-2")),
PageRequest.of(0, 20), 100);
when(orderService.findAll(any(Pageable.class))).thenReturn(page);
mockMvc.perform(get("/api/orders")
.param("page", "0")
.param("size", "20")
.param("sort", "createdAt,desc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.totalElements").value(100))
.andExpect(jsonPath("$.totalPages").value(5));
}
@Test
void shouldHandleAuthentication() throws Exception {
mockMvc.perform(get("/api/orders")
.with(SecurityMockMvcRequestPostProcessors.user("admin")
.roles("ADMIN")))
.andExpect(status().isOk());
}
@Test
void shouldHandleFileUpload() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.csv",
MediaType.TEXT_PLAIN_VALUE,
"id,name\n1,Alice".getBytes());
mockMvc.perform(multipart("/api/orders/import")
.file(file))
.andExpect(status().isOk());
}
}@DataJpaTest —— JPA 层切片测试
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByEmail() {
// Arrange
User user = new User();
user.setUsername("alice");
user.setEmail("alice@example.com");
user.setStatus(UserStatus.ACTIVE);
entityManager.persistAndFlush(user);
// Act
Optional<User> found = userRepository.findByEmail("alice@example.com");
// Assert
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("alice");
}
@Test
void shouldFindActiveUsersCreatedAfter() {
LocalDateTime yesterday = LocalDateTime.now().minusDays(1);
User active = createUser("active", UserStatus.ACTIVE);
User inactive = createUser("inactive", UserStatus.INACTIVE);
entityManager.persistAndFlush(active);
entityManager.persistAndFlush(inactive);
List<User> result = userRepository.findByStatusAndCreatedAtAfter(
UserStatus.ACTIVE, yesterday);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUsername()).isEqualTo("active");
}
@Test
void shouldSupportPagination() {
for (int i = 0; i < 25; i++) {
entityManager.persist(createUser("user" + i, UserStatus.ACTIVE));
}
entityManager.flush();
Page<User> page = userRepository.findByStatus(
UserStatus.ACTIVE, PageRequest.of(0, 10));
assertThat(page.getTotalElements()).isEqualTo(25);
assertThat(page.getTotalPages()).isEqualTo(3);
assertThat(page.getContent()).hasSize(10);
}
private User createUser(String username, UserStatus status) {
User user = new User();
user.setUsername(username);
user.setEmail(username + "@example.com");
user.setStatus(status);
return user;
}
}@MockBean 与 @SpyBean
@SpringBootTest
class ServiceIntegrationTest {
// MockBean:完全替换容器中的Bean为Mock对象
@MockBean
private ExternalPaymentService paymentService;
// SpyBean:保留原始行为,但可以验证调用和部分Mock
@SpyBean
private NotificationService notificationService;
@Autowired
private OrderService orderService;
@Test
void shouldProcessOrderWithMockedPayment() {
when(paymentService.charge(any())).thenReturn(new PaymentResult("OK"));
orderService.processOrder(new OrderRequest("PROD-1", 1));
verify(paymentService).charge(any());
verify(notificationService).sendConfirmation(any());
}
@Test
void shouldSkipNotificationOnFailure() {
when(paymentService.charge(any()))
.thenThrow(new PaymentException("Declined"));
assertThrows(PaymentException.class,
() -> orderService.processOrder(new OrderRequest("PROD-1", 1)));
verify(notificationService, never()).sendConfirmation(any());
}
}Testcontainers 集成
@SpringBootTest
@Testcontainers
class UserServiceContainerTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
.withExposedPorts(6379);
@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);
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired
private UserService userService;
@Test
void shouldCrudWithRealDatabase() {
User created = userService.create(new CreateUserRequest("alice", "alice@example.com"));
assertThat(created.getId()).isNotNull();
Optional<User> found = userService.findById(created.getId());
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("alice");
}
}测试配置
独立测试配置
// 测试专用配置类
@TestConfiguration
public class TestSecurityConfig {
@Bean
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()).build();
}
}
// 在测试中引入
@SpringBootTest
@Import(TestSecurityConfig.class)
class SecurityDisabledTest {
// 安全过滤器被测试配置覆盖
}测试 Profile
# 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
show-sql: true@SpringBootTest
@ActiveProfiles("test")
class ProfileBasedTest {
// 使用 application-test.yml 配置
}测试最佳实践
1. 测试命名规范
@Test
void shouldReturnUserWhenValidIdProvided() { }
@Test
void shouldThrowExceptionWhenUserNotFound() { }
@Test
void shouldReturn400WhenEmailInvalid() { }2. 遵循 AAA 模式
@Test
void shouldCalculateOrderTotal() {
// Arrange - 准备测试数据
Order order = new Order();
order.addItem(new OrderItem("P1", 2, new BigDecimal("10.00")));
order.addItem(new OrderItem("P2", 1, new BigDecimal("25.00")));
// Act - 执行被测方法
BigDecimal total = order.calculateTotal();
// Assert - 验证结果
assertThat(total).isEqualByComparingTo("45.00");
}3. 选择合适的测试切片
总结
Spring Boot 测试框架提供了从单元测试到集成测试的完整解决方案。@WebMvcTest 和 @DataJpaTest 等切片测试注解只加载必要的上下文,提高测试速度。@MockBean 隔离外部依赖,Testcontainers 提供真实的基础设施环境。遵循测试金字塔原则,大量编写单元测试,适量编写集成测试,少量编写端到端测试,以确保测试套件既全面又高效。
贡献者
更新日志
2026/3/14 13:09
查看所有更新日志
9f6c2-feat: organize wiki content and refresh site setup于