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 测试支持提供了全面的测试能力:

  1. 单元测试:使用 JUnit 5 + Mockito
  2. 集成测试:使用 @SpringBootTest
  3. 切片测试@WebMvcTest@DataJpaTest@JdbcTest
  4. 响应式测试:使用 WebTestClient
  5. 容器化测试:使用 TestContainers
  6. 安全测试:使用 @WithMockUser

最佳实践:

  • 使用 Given-When-Then 结构组织测试
  • 使用 AssertJ 进行流畅断言
  • 使用测试数据构建器创建测试数据
  • 合理使用 @Transactional 回滚测试数据
  • 使用 TestContainers 进行数据库集成测试

更多推荐