Skip to content
第 75 / 250 章后端⏱ 10 分钟阅读

第 75 章:单元测试与 Mockito

学习目标

  • 掌握 JUnit 5 + Mockito 单元测试
  • 学会 Spring Boot Test 全套注解
  • 实现 Controller / Service / Mapper 三层测试

一、测试金字塔

最佳比例:70% 单元测试 + 20% 集成测试 + 10% UI 测试。

二、依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- 包含 JUnit 5 + Mockito + AssertJ + Spring Test + JsonPath -->

三、单元测试(Service 层)

基础结构

java
@ExtendWith(MockitoExtension.class)                    // ① JUnit 5 + Mockito
class UserServiceTest {

    @Mock                                                // ② Mock 依赖
    private UserMapper userMapper;

    @InjectMocks                                         // ③ 注入被测对象
    private UserServiceImpl userService;

    @Test
    void testGetById() {
        // ④ Arrange:准备数据
        User user = new User();
        user.setId(1L);
        user.setUsername("zhangsan");
        when(userMapper.selectById(1L)).thenReturn(user);

        // ⑤ Act:调用方法
        User result = userService.getById(1L);

        // ⑥ Assert:验证结果
        assertNotNull(result);
        assertEquals("zhangsan", result.getUsername());
        verify(userMapper, times(1)).selectById(1L);     // ⑦ 验证调用
    }
}

Mockito 核心 API

java
// ① 打桩:指定方法返回值
when(userMapper.selectById(1L)).thenReturn(user);
when(userMapper.selectById(2L)).thenThrow(new RuntimeException("DB error"));

// ② 多次调用不同返回值
when(userMapper.selectById(1L))
    .thenReturn(user1)                                   // 第一次
    .thenReturn(user2)                                   // 第二次
    .thenThrow(new RuntimeException());                  // 第三次抛异常

// ③ 参数匹配器
when(userMapper.selectById(anyLong())).thenReturn(user);
when(userMapper.selectById(eq(1L))).thenReturn(user);
when(userMapper.selectById(argThat(id -> id > 0))).thenReturn(user);

// ④ void 方法模拟
doNothing().when(emailService).send(anyString());
doThrow(new RuntimeException()).when(emailService).send(anyString());

// ⑤ 验证行为
verify(userMapper).selectById(1L);                      // 调用过 1 次
verify(userMapper, times(3)).selectById(1L);            // 调用过 3 次
verify(userMapper, never()).deleteById(anyLong());      // 没调用过
verify(userMapper, atLeastOnce()).updateById(any());
verify(userMapper, atMost(5)).selectById(anyLong());

// ⑥ 验证参数
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userMapper).insert(captor.capture());
assertEquals("zhangsan", captor.getValue().getUsername());

AssertJ 流畅断言

java
// 比 JUnit 的 assertEquals 更可读
assertThat(user.getUsername()).isEqualTo("zhangsan");
assertThat(user.getAge()).isGreaterThan(18).isLessThan(60);
assertThat(user.getEmail()).isNotNull().contains("@");
assertThat(user.getRoles())
    .hasSize(3)
    .extracting("name")
    .containsExactly("ADMIN", "USER", "GUEST");
assertThat(users).extracting("username").contains("zhangsan", "lisi");

// 异常断言
assertThatThrownBy(() -> userService.delete(null))
    .isInstanceOf(BusinessException.class)
    .hasMessage("用户 ID 不能为空");

四、参数化测试

java
@ParameterizedTest
@ValueSource(longs = {1L, 2L, 3L, 100L, 999L})
void testGetByIdWithVariousIds(Long id) {
    User user = new User();
    user.setId(id);
    when(userMapper.selectById(id)).thenReturn(user);

    User result = userService.getById(id);

    assertThat(result.getId()).isEqualTo(id);
}

@ParameterizedTest
@CsvSource({
    "1, zhangsan, 18",
    "2, lisi, 25",
    "3, wangwu, 30"
})
void testCreateUserWithVariousData(long id, String name, int age) {
    // ...
}

@ParameterizedTest
@MethodSource("provideInvalidInputs")
void testValidationWithInvalidInputs(String input) {
    assertThatThrownBy(() -> userService.create(buildDto(input)))
        .isInstanceOf(BusinessException.class);
}

static Stream<String> provideInvalidInputs() {
    return Stream.of("", "  ", null);
}

五、Controller 测试(MockMvc)

java
@WebMvcTest(UserController.class)                       // ① 只加载 Web 层
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;                            // ② 模拟 HTTP 请求

    @MockBean
    private UserService userService;                     // ③ Mock Service

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void testGetById() throws Exception {
        // ④ Mock Service 返回值
        User user = new User();
        user.setId(1L);
        user.setUsername("zhangsan");
        when(userService.getVOById(1L)).thenReturn(toVO(user));

        // ⑤ 发起请求
        mockMvc.perform(get("/api/user/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())                   // 验证状态码
            .andExpect(jsonPath("$.code").value(200))    // 验证业务码
            .andExpect(jsonPath("$.data.username").value("zhangsan"))
            .andDo(print());                              // 打印请求/响应
    }

    @Test
    void testCreateUser() throws Exception {
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("newuser");
        dto.setEmail("new@example.com");

        when(userService.create(any())).thenReturn(100L);

        mockMvc.perform(post("/api/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(dto)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data").value(100));
    }

    @Test
    void testValidationFailure() throws Exception {
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("");                              // 空用户名,触发校验

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

六、Service 集成测试

java
@SpringBootTest                                       // ① 加载完整 Spring 上下文
@Transactional                                         // ② 测试后自动回滚
@Rollback                                              // ③ 强制回滚
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserMapper userMapper;

    @Test
    void testCreateAndQuery() {
        // ④ 创建
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("test_" + System.currentTimeMillis());
        dto.setEmail("test@example.com");
        dto.setPassword("password123");

        Long userId = userService.create(dto);
        assertThat(userId).isNotNull();

        // ⑤ 查询
        UserVO vo = userService.getVOById(userId);
        assertThat(vo).isNotNull();
        assertThat(vo.getUsername()).startsWith("test_");
    }
}

七、Mapper 测试(MyBatis-Plus)

java
@SpringBootTest
@Transactional
@Rollback
class UserMapperTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    void testInsert() {
        User user = new User();
        user.setUsername("test_user");
        user.setEmail("test@example.com");

        int rows = userMapper.insert(user);
        assertThat(rows).isEqualTo(1);
        assertThat(user.getId()).isNotNull();
    }

    @Test
    void testSelectById() {
        User user = userMapper.selectById(1L);
        if (user != null) {
            assertThat(user.getId()).isEqualTo(1L);
        }
    }

    @Test
    void testLambdaQuery() {
        List<User> users = userMapper.selectList(
            new LambdaQueryWrapper<User>()
                .eq(User::getStatus, 1)
                .orderByDesc(User::getCreateTime));

        assertThat(users).isNotNull();
    }
}

八、测试覆盖率

xml
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>
bash
mvn test
# 生成报告:target/site/jacoco/index.html

覆盖率目标

  • 行覆盖 ≥ 80%
  • 分支覆盖 ≥ 70%
  • 核心业务逻辑 ≥ 90%

不要盲目追求 100%。Getter/Setter、简单 toString 不需要测。

九、Mock 与 Stub 的取舍

java
// ✅ Mock:验证交互("调用了几次")
@Mock private EmailService emailService;

@Test
void testRegisterSendsEmail() {
    userService.register(dto);

    verify(emailService, times(1)).sendWelcome(anyString());   // 验证调用
}

// ✅ Stub:设置返回值("返回什么")
@Mock private UserMapper userMapper;
when(userMapper.selectById(1L)).thenReturn(user);             // 设置返回值

// ⚠️ 真实对象 vs Mock 对象
// 复杂依赖(数据库、远程服务)→ Mock
// 简单值对象(DTO、Entity)→ 真实对象

十、测试组织(Given-When-Then)

java
@Test
void testCreateOrder() {
    // Given:准备测试数据
    OrderDTO dto = new OrderDTO();
    dto.setUserId(1L);
    dto.setSkuId(100L);
    dto.setQuantity(2);

    User user = new User();
    user.setId(1L);
    when(userMapper.selectById(1L)).thenReturn(user);

    Product product = new Product();
    product.setId(100L);
    product.setStock(100);
    when(productMapper.selectById(100L)).thenReturn(product);

    // When:执行业务
    Long orderId = orderService.create(dto);

    // Then:验证结果
    assertThat(orderId).isNotNull();
    verify(productMapper).deductStock(100L, 2);     // 库存扣减
    verify(orderMapper).insert(any(Order.class));   // 订单创建
}

十一、常见测试模式

1. 测试私有方法

java
// ❌ 不推荐:用反射测试私有方法
Method m = UserService.class.getDeclaredMethod("privateMethod");
m.setAccessible(true);
m.invoke(userService);

// ✅ 推荐:重构为包内可见方法,单测时调用

2. 测试时间相关逻辑

java
@Test
void testExpireLogic() {
    // ① 用 Mockito 控制时间
    try (MockedStatic<LocalDateTime> mocked = Mockito.mockStatic(LocalDateTime.class, CALLS_REAL_METHODS)) {
        LocalDateTime fixedTime = LocalDateTime.of(2026, 1, 1, 12, 0);
        mocked.when(LocalDateTime::now).thenReturn(fixedTime);

        Order order = new Order();
        order.setCreateTime(fixedTime.minusHours(1));

        boolean expired = orderService.isExpired(order);
        assertThat(expired).isTrue();
    }
}

3. 测试异常路径

java
@Test
void testGetByIdThrowsException() {
    when(userMapper.selectById(999L)).thenReturn(null);

    assertThatThrownBy(() -> userService.getVOById(999L))
        .isInstanceOf(BusinessException.class)
        .hasMessageContaining("用户不存在");
}

十二、测试最佳实践

实践说明
一个测试一个断言点测试失败时能立刻定位
测试名描述行为testRegister_WithExistingUsername_ThrowsException
AAA 模式Arrange-Act-Assert 结构清晰
测试要快单元测试每个 < 100ms
测试要独立不依赖其他测试的执行顺序
测试要可重复跑多少次结果都一样
边界条件必测null、空、0、最大、最小
集成测试用 @Transactional + @Rollback测试后自动清理数据

十三、本章小结

要点关键
框架JUnit 5 + Mockito + AssertJ + Spring Test
单元测试@Mock + @InjectMocks不加载 Spring
Controller 测试@WebMvcTest + MockMvc
集成测试@SpringBootTest + @Transactional + @Rollback
验证verify(mock, times(n)).method()
参数捕获ArgumentCaptor
断言AssertJ 的 assertThat() 链式
覆盖率JaCoCo,目标 ≥ 80%

动手练习

练习 1:基础题

为 UserService 编写完整的单元测试:增、删、改、查、列表、分页,每个方法至少 2 个测试用例(正常 + 异常)。

练习 2:进阶题

为 UserController 编写 MockMvc 测试:覆盖正常返回、参数校验失败、Service 抛异常三种场景。

练习 3:思考题

你刚接手的项目一行测试都没有。如何从零搭建测试体系?优先级和策略是什么?


下一章第 76 章:集成测试与 Testcontainers

本站基于 VitePress 构建 · 由 Codebook 团队维护