Chapter 05

单元测试与集成测试

JUnit 5、Mockito、MockMvc、TestContainers,构建可靠的测试体系,保障代码质量。

为什么测试至关重要

测试不仅仅是"确认代码能跑"。对于专业工程师而言,自动化测试至少带来三个层面的价值:

回归保护(Regression Protection)
当你修改某功能时,测试套件能立即发现是否破坏了已有行为,防止"改了 A 坏了 B"的情况在生产环境才发现。
设计指导(Design Guidance)
难以测试的代码往往意味着设计耦合度过高。当你发现某段代码写测试很麻烦时,这是重构信号——代码职责不清晰,依赖关系混乱。
活文档(Living Documentation)
测试代码描述了"系统在各种输入下应有的行为"。好的测试用例比任何文档都更准确、更及时。

测试金字塔

┌───────────────┐ │ E2E 测试 │ 少量,慢,成本高 │ (Playwright) │ ╱└───────────────┘╲ ╱ ┌─────────────────┐╲ ╱ │ 集成测试 │ ╲ ╱ │ (@SpringBootTest)│ ╲ ╱ └─────────────────┘ ╲ ╱ ┌───────────────────────────┐╲ ╱ │ 单元测试 │ ╲ ╱ │ (JUnit 5 + Mockito) │ ╲ ╱ └───────────────────────────┘ ╲ ──────────────────────────────────────── 多量,快,成本低 建议比例:单元测试 70% + 集成测试 20% + E2E 测试 10%

核心概念名词解释

JUnit 5
Java 最流行的测试框架,由 JUnit Platform + JUnit Jupiter + JUnit Vintage 三部分组成。Spring Boot Test 默认集成 JUnit 5,@Test、@BeforeEach、@AfterEach、@ParameterizedTest 等注解来自 JUnit Jupiter。
Mockito
Java Mock 框架,用于创建测试替身(Test Double)。可以 mock 一个接口或类,预设其方法的返回值,从而隔离被测单元的外部依赖(如数据库、HTTP 调用)。
@Mock 与 @InjectMocks
@Mock 创建 mock 对象;@InjectMocks 创建被测类的实例并自动将 @Mock 对象注入其中(通过构造器或字段)。配合 @ExtendWith(MockitoExtension.class) 使用。
@SpringBootTest
启动完整的 Spring ApplicationContext 进行集成测试,加载所有 Bean、配置、数据库连接等。测试更接近真实环境但启动较慢。可通过 webEnvironment 参数控制是否启动真实 HTTP 服务器。
MockMvc
Spring MVC 测试框架,可以模拟 HTTP 请求并验证响应,无需启动真实服务器。支持链式 API:perform(get("/users/1")).andExpect(status().isOk())。
TestContainers
通过 Docker 启动真实的数据库/消息队列容器来进行集成测试。相比 H2 内存数据库,TestContainers 使用与生产环境相同的数据库,避免方言差异导致的问题。

单元测试:UserService

单元测试的目标是隔离测试一个类的逻辑,不涉及任何真实的外部依赖(数据库、网络等)。

@ExtendWith(MockitoExtension.class)  // 激活 Mockito 注解处理
class UserServiceTest {

    @Mock
    private UserRepository userRepository;  // Mock 依赖

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserServiceImpl userService;  // 被测类,依赖自动注入

    @Test
    @DisplayName("根据 ID 查询用户 — 用户存在时返回 DTO")
    void findById_UserExists_ReturnsDTO() {
        // Given(准备数据和 mock 行为)
        User user = User.builder().id(1L).username("alice").email("alice@example.com").build();
        Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        // When(执行被测方法)
        UserResponseDTO result = userService.findById(1L);

        // Then(断言结果)
        assertThat(result).isNotNull();
        assertThat(result.getUsername()).isEqualTo("alice");
        assertThat(result.getEmail()).isEqualTo("alice@example.com");
        verify(userRepository).findById(1L);  // 验证 mock 被正确调用
    }

    @Test
    @DisplayName("根据 ID 查询用户 — 用户不存在时抛出异常")
    void findById_UserNotFound_ThrowsException() {
        // Given
        Mockito.when(userRepository.findById(99L)).thenReturn(Optional.empty());

        // When & Then(断言异常)
        assertThatThrownBy(() -> userService.findById(99L))
            .isInstanceOf(EntityNotFoundException.class)
            .hasMessageContaining("99");
    }

    @Test
    @DisplayName("创建用户 — 邮箱已存在时抛出业务异常")
    void create_DuplicateEmail_ThrowsBusinessException() {
        // Given
        UserCreateDTO dto = new UserCreateDTO("bob", "existing@example.com", "Password1");
        Mockito.when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);

        // When & Then
        assertThatThrownBy(() -> userService.create(dto))
            .isInstanceOf(BusinessException.class);
        verify(userRepository, never()).save(any());  // 确保未调用 save
    }
}

集成测试:MockMvc 测试 HTTP 接口

@SpringBootTest
@AutoConfigureMockMvc          // 自动配置 MockMvc
@Transactional                 // 每个测试后自动回滚,不污染数据
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;  // JSON 序列化/反序列化

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("POST /users — 创建用户成功返回 201")
    void createUser_ValidInput_Returns201() throws Exception {
        UserCreateDTO dto = new UserCreateDTO("testuser", "test@example.com", "Password1");

        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(dto)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.data.username").value("testuser"))
            .andExpect(jsonPath("$.data.email").value("test@example.com"))
            .andExpect(jsonPath("$.data.id").exists());
    }

    @Test
    @DisplayName("POST /users — 邮箱格式错误返回 400")
    void createUser_InvalidEmail_Returns400() throws Exception {
        UserCreateDTO dto = new UserCreateDTO("testuser", "not-an-email", "Password1");

        mockMvc.perform(post("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(dto)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value(400));
    }

    @Test
    @DisplayName("GET /users/{id} — 需要认证,未登录返回 401")
    void getUser_Unauthenticated_Returns401() throws Exception {
        mockMvc.perform(get("/users/1"))
            .andExpect(status().isUnauthorized());
    }
}

TestContainers:使用真实 PostgreSQL 测试

@SpringBootTest
@Testcontainers  // 激活 TestContainers 注解
class UserRepositoryTest {

    // 声明 PostgreSQL 容器(自动启动 Docker 容器)
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    // 将容器的连接信息动态注入 Spring 配置
    @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 UserRepository userRepository;

    @Test
    void findByEmail_ReturnsCorrectUser() {
        User user = User.builder()
            .username("alice")
            .email("alice@test.com")
            .passwordHash("hash")
            .build();
        userRepository.save(user);

        Optional<User> found = userRepository.findByEmail("alice@test.com");
        assertThat(found).isPresent();
        assertThat(found.get().getUsername()).isEqualTo("alice");
    }
}

测试覆盖率:JaCoCo

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
        <execution>
            <id>check</id>
            <goals><goal>check</goal></goals>
            <configuration>
                <rules>
                    <rule>
                        <limits>
                            <!-- 强制 Service 层覆盖率 ≥ 80% -->
                            <limit>
                                <counter>LINE</counter>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>
Tip 集成测试加 @Transactional 注解可以在测试结束后自动回滚数据,不污染测试数据库。注意:如果测试方法调用了异步操作(@Async)或在新事务中执行(REQUIRES_NEW),这些操作不会被回滚。
Info 对于测试命名,推荐遵循"方法名_场景_预期结果"的格式,如 findById_UserNotFound_ThrowsException。配合 @DisplayName 可以写中文描述,让测试报告更易读。