09-Spring 单元测试详解
·
09. Spring 测试详解
9.1 Spring 测试概述
Spring Framework 提供了全面的测试支持,包括单元测试、集成测试和端到端测试。spring-test 模块提供了与 Spring 容器集成的测试工具。
9.1.1 测试类型
测试金字塔:
/\
/ \
/ E2E\ 端到端测试(少)
/______\
/ \
/ Integration\ 集成测试(中)
/______________\
/ \
/ Unit Tests \ 单元测试(多)
/____________________\
9.1.2 测试依赖
<dependencies>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Test -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- AssertJ -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<!-- TestContainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
9.2 单元测试
9.2.1 纯单元测试
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService;
@Test
void createUser_shouldSaveUserAndSendEmail() {
// Given
UserRequest request = new UserRequest("John", "john@example.com");
User savedUser = new User(1L, "John", "john@example.com");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
doNothing().when(emailService).sendWelcomeEmail(anyString());
// When
User result = userService.createUser(request);
// Then
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getName()).isEqualTo("John");
verify(userRepository).save(any(User.class));
verify(emailService).sendWelcomeEmail("john@example.com");
}
@Test
void createUser_withDuplicateEmail_shouldThrowException() {
// Given
UserRequest request = new UserRequest("John", "john@example.com");
when(userRepository.existsByEmail("john@example.com")).thenReturn(true);
// When/Then
assertThatThrownBy(() -> userService.createUser(request))
.isInstanceOf(DuplicateEmailException.class)
.hasMessageContaining("Email already exists");
verify(userRepository, never()).save(any());
}
@Test
void getUser_shouldReturnUser() {
// Given
User user = new User(1L, "John", "john@example.com");
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
// When
Optional<User> result = userService.getUser(1L);
// Then
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("John");
}
@Test
void getUser_notFound_shouldReturnEmpty() {
// Given
when(userRepository.findById(1L)).thenReturn(Optional.empty());
// When
Optional<User> result = userService.getUser(1L);
// Then
assertThat(result).isEmpty();
}
@ParameterizedTest
@CsvSource({
"john@example.com, true",
"invalid-email, false",
"test@test.com, true"
})
void isValidEmail_shouldValidateCorrectly(String email, boolean expected) {
boolean result = userService.isValidEmail(email);
assertThat(result).isEqualTo(expected);
}
}
9.2.2 使用 AssertJ
class AssertJExamplesTest {
@Test
void basicAssertions() {
User user = new User(1L, "John", "john@example.com");
// 基本断言
assertThat(user).isNotNull();
assertThat(user.getName()).isEqualTo("John");
assertThat(user.getEmail()).contains("@").endsWith(".com");
// 组合断言
assertThat(user)
.extracting(User::getName, User::getEmail)
.containsExactly("John", "john@example.com");
// 对象断言
assertThat(user)
.hasFieldOrPropertyWithValue("name", "John")
.hasFieldOrPropertyWithValue("id", 1L);
}
@Test
void collectionAssertions() {
List<User> users = Arrays.asList(
new User(1L, "John", "john@example.com"),
new User(2L, "Jane", "jane@example.com"),
new User(3L, "Bob", "bob@example.com")
);
// 列表断言
assertThat(users)
.hasSize(3)
.extracting(User::getName)
.containsExactly("John", "Jane", "Bob")
.contains("John", "Jane")
.doesNotContain("Alice");
// 过滤断言
assertThat(users)
.filteredOn(user -> user.getId() > 1)
.hasSize(2)
.extracting(User::getName)
.containsExactly("Jane", "Bob");
}
@Test
void exceptionAssertions() {
assertThatThrownBy(() -> userService.getUser(-1L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid user ID")
.hasNoCause();
}
@Test
void stringAssertions() {
String text = "Hello, Spring Testing!";
assertThat(text)
.isNotEmpty()
.startsWith("Hello")
.contains("Spring")
.hasSize(22);
}
@Test
void dateAssertions() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime yesterday = now.minusDays(1);
assertThat(now)
.isAfter(yesterday)
.isBeforeOrEqualTo(LocalDateTime.now().plusSeconds(1));
}
}
9.3 Spring 集成测试
9.3.1 基本集成测试
@SpringJUnitConfig(AppConfig.class)
@Transactional
class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void save_shouldPersistUser() {
// Given
User user = new User(null, "John", "john@example.com");
// When
User saved = userRepository.save(user);
// Then
assertThat(saved.getId()).isNotNull();
User found = userRepository.findById(saved.getId()).orElse(null);
assertThat(found).isNotNull();
assertThat(found.getName()).isEqualTo("John");
}
@Test
void findByEmail_shouldReturnUser() {
// Given
User user = new User(null, "John", "john@example.com");
userRepository.save(user);
// When
Optional<User> found = userRepository.findByEmail("john@example.com");
// Then
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("John");
}
}
9.3.2 使用 @SpringBootTest
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private ObjectMapper objectMapper;
@Test
void getUser_shouldReturnUser() throws Exception {
// Given
User user = new User(null, "John", "john@example.com");
user = userRepository.save(user);
// When/Then
mockMvc.perform(get("/api/users/{id}", user.getId())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(user.getId()))
.andExpect(jsonPath("$.name").value("John"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
@Test
void createUser_shouldCreateAndReturnUser() throws Exception {
// Given
UserRequest request = new UserRequest("Jane", "jane@example.com");
// When/Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Jane"))
.andExpect(jsonPath("$.email").value("jane@example.com"))
.andExpect(header().exists("Location"));
}
@Test
void getUser_notFound_shouldReturn404() throws Exception {
mockMvc.perform(get("/api/users/{id}", 99999L))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("User not found"));
}
@Test
void createUser_invalidData_shouldReturn400() throws Exception {
// Given - 无效的请求(缺少必填字段)
String invalidRequest = "{\"name\": \"\"}";
// When/Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").exists());
}
}
9.3.3 WebTestClient(响应式测试)
@SpringBootTest
@AutoConfigureWebTestClient
class ReactiveUserControllerTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private ReactiveUserRepository userRepository;
@Test
void getUser_shouldReturnUser() {
// Given
User user = new User("1", "John", "john@example.com");
userRepository.save(user).block();
// When/Then
webTestClient.get()
.uri("/api/users/{id}", "1")
.exchange()
.expectStatus().isOk()
.expectBody(User.class)
.value(u -> assertThat(u.getName()).isEqualTo("John"));
}
@Test
void getAllUsers_shouldReturnFlux() {
// Given
userRepository.save(new User("1", "John", "john@example.com")).block();
userRepository.save(new User("2", "Jane", "jane@example.com")).block();
// When/Then
webTestClient.get()
.uri("/api/users")
.exchange()
.expectStatus().isOk()
.expectBodyList(User.class)
.hasSize(2);
}
@Test
void streamUsers_shouldReturnSse() {
webTestClient.get()
.uri("/api/users/stream")
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM);
}
}
9.4 切片测试
9.4.1 @WebMvcTest
@WebMvcTest(UserController.class)
class UserControllerSliceTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
void getUser_shouldReturnUser() throws Exception {
// Given
User user = new User(1L, "John", "john@example.com");
when(userService.getUser(1L)).thenReturn(Optional.of(user));
// When/Then
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"));
verify(userService).getUser(1L);
}
@Test
void createUser_shouldCreateUser() throws Exception {
// Given
UserRequest request = new UserRequest("Jane", "jane@example.com");
User created = new User(1L, "Jane", "jane@example.com");
when(userService.createUser(any())).thenReturn(created);
// When/Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/users/1"));
}
}
9.4.2 @DataJpaTest
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryDataJpaTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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);
}
@Autowired
private TestEntityManager entityManager;
@Autowired
private UserRepository userRepository;
@Test
void findByEmail_shouldReturnUser() {
// Given
User user = new User(null, "John", "john@example.com");
entityManager.persist(user);
entityManager.flush();
// When
Optional<User> found = userRepository.findByEmail("john@example.com");
// Then
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("John");
}
@Test
void existsByEmail_shouldReturnTrue() {
// Given
User user = new User(null, "John", "john@example.com");
entityManager.persist(user);
// When
boolean exists = userRepository.existsByEmail("john@example.com");
// Then
assertThat(exists).isTrue();
}
}
9.4.3 @JdbcTest
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryJdbcTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private NamedParameterJdbcTemplate namedTemplate;
private UserJdbcRepository repository;
@BeforeEach
void setUp() {
repository = new UserJdbcRepository(jdbcTemplate, namedTemplate);
}
@Test
void findById_shouldReturnUser() {
// Given
jdbcTemplate.update(
"INSERT INTO users (name, email, status) VALUES (?, ?, ?)",
"John", "john@example.com", "ACTIVE"
);
Long id = jdbcTemplate.queryForObject(
"SELECT LAST_INSERT_ID()", Long.class);
// When
User user = repository.findById(id);
// Then
assertThat(user).isNotNull();
assertThat(user.getName()).isEqualTo("John");
}
}
9.5 测试配置
9.5.1 测试专用配置
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public EmailService mockEmailService() {
return Mockito.mock(EmailService.class);
}
@Bean
public Clock fixedClock() {
return Clock.fixed(
Instant.parse("2024-01-01T00:00:00Z"),
ZoneId.of("UTC")
);
}
}
@SpringBootTest
@Import(TestConfig.class)
class ServiceWithTestConfigTest {
@Autowired
private Clock clock;
@Test
void testWithFixedTime() {
assertThat(clock.instant()).isEqualTo(
Instant.parse("2024-01-01T00:00:00Z"));
}
}
9.5.2 Profile 特定测试
@SpringBootTest
@ActiveProfiles("test")
class ProfileSpecificTest {
@Value("${app.test-value}")
private String testValue;
@Test
void shouldLoadTestProfile() {
assertThat(testValue).isEqualTo("test-value");
}
}
// application-test.yml
// app:
// test-value: test-value
9.5.3 属性覆盖
@SpringBootTest(properties = {
"app.feature.enabled=false",
"app.timeout=1000"
})
class PropertyOverrideTest {
@Value("${app.feature.enabled}")
private boolean featureEnabled;
@Value("${app.timeout}")
private int timeout;
@Test
void propertiesShouldBeOverridden() {
assertThat(featureEnabled).isFalse();
assertThat(timeout).isEqualTo(1000);
}
}
9.6 安全测试
9.6.1 使用 @WithMockUser
@SpringBootTest
@AutoConfigureMockMvc
class SecureControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER")
void userAccess_shouldAllow() throws Exception {
mockMvc.perform(get("/api/user/resource"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void adminAccess_shouldDeny() throws Exception {
mockMvc.perform(get("/api/admin/resource"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void adminAccess_shouldAllow() throws Exception {
mockMvc.perform(get("/api/admin/resource"))
.andExpect(status().isOk());
}
@Test
void noAuth_shouldRequireAuthentication() throws Exception {
mockMvc.perform(get("/api/user/resource"))
.andExpect(status().isUnauthorized());
}
}
9.6.2 自定义 Security 上下文
@Test
@WithUserDetails(value = "john@example.com", userDetailsServiceBeanName = "userDetailsService")
void withCustomUserDetails() throws Exception {
mockMvc.perform(get("/api/profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("john@example.com"));
}
// 自定义注解
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "user";
String[] roles() default { "USER" };
long userId() default 1L;
}
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails userDetails = new CustomUserDetails(
annotation.userId(),
annotation.username(),
"password",
AuthorityUtils.createAuthorityList(annotation.roles())
);
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
context.setAuthentication(auth);
return context;
}
}
9.7 测试最佳实践
9.7.1 测试命名规范
class UserServiceTest {
// 好的命名:描述行为
@Test
void shouldThrowExceptionWhenUserNotFound() {
}
@Test
void shouldSendEmailWhenOrderIsCompleted() {
}
@Test
void shouldReturnEmptyListWhenNoUsersExist() {
}
// 避免:过于技术化的命名
@Test
void testGetUser() { // 不推荐
}
@Test
void getUserTest() { // 不推荐
}
}
9.7.2 Given-When-Then 结构
@Test
void shouldDecreaseInventoryWhenOrderIsPlaced() {
// Given
Product product = new Product(1L, "Laptop", 100);
productRepository.save(product);
OrderRequest request = new OrderRequest(1L, 10);
// When
orderService.placeOrder(request);
// Then
Product updatedProduct = productRepository.findById(1L).get();
assertThat(updatedProduct.getStock()).isEqualTo(90);
}
9.7.3 测试数据构建器
public class UserBuilder {
private Long id;
private String name = "John Doe";
private String email = "john@example.com";
private UserStatus status = UserStatus.ACTIVE;
public UserBuilder withId(Long id) {
this.id = id;
return this;
}
public UserBuilder withName(String name) {
this.name = name;
return this;
}
public UserBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserBuilder withStatus(UserStatus status) {
this.status = status;
return this;
}
public User build() {
User user = new User();
user.setId(id);
user.setName(name);
user.setEmail(email);
user.setStatus(status);
return user;
}
public static UserBuilder aUser() {
return new UserBuilder();
}
}
// 使用
@Test
void testWithBuilder() {
User user = UserBuilder.aUser()
.withId(1L)
.withName("Jane")
.withEmail("jane@example.com")
.build();
// ...
}
9.8 总结
Spring 测试支持提供了全面的测试能力:
- 单元测试:使用 JUnit 5 + Mockito
- 集成测试:使用
@SpringBootTest - 切片测试:
@WebMvcTest、@DataJpaTest、@JdbcTest - 响应式测试:使用
WebTestClient - 容器化测试:使用 TestContainers
- 安全测试:使用
@WithMockUser
最佳实践:
- 使用 Given-When-Then 结构组织测试
- 使用 AssertJ 进行流畅断言
- 使用测试数据构建器创建测试数据
- 合理使用
@Transactional回滚测试数据 - 使用 TestContainers 进行数据库集成测试
更多推荐


所有评论(0)