Note 22. 质量守门员:JUnit 5 单元测试与 MockMvc 集成测试

Note 22. 质量守门员:JUnit 5 单元测试与 MockMvc 集成测试

摘要: 只有经过测试的代码才是可信的。本章我们将构建 Spring Boot 的自动化测试体系。我们将从 JUnit 5Mockito 开始,学习如何编写不依赖 Spring 容器的纯 单元测试(测试 Service 逻辑);接着使用 MockMvc@WebMvcTest 进行 集成测试(测试 Controller 接口),验证 HTTP 请求处理流程。最后,我们将掌握 AssertJ 断言库,写出如自然语言般流畅的断言语句。

本章学习路径

  1. 测试分层:理解单元测试(Unit Test)与集成测试(Integration Test)的区别。
  2. 单元测试:使用 Mockito 隔离依赖,快速验证 Service 层的业务逻辑。
  3. 集成测试:使用 MockMvc 模拟 HTTP 请求,验证 Controller 层的参数绑定与响应。
  4. 断言艺术:使用 AssertJ 编写可读性极高的断言逻辑。

22.1. 测试分层:不要把鸡蛋放在一个篮子里

在开始写代码前,必须厘清两个概念:

类型目标特点适用场景
单元测试测试 单个类/方法 的逻辑(毫秒级),不启动 Spring 容器,依赖全部 Mock(模拟)。Service 层的业务逻辑、工具类。
集成测试测试 组件间 的协作(秒级),启动 Spring 上下文,连接数据库或使用 H2 内存库。Controller 接口、Dao 层 SQL、复杂业务流程。

最佳实践多写单元测试,少写集成测试。单元测试反馈快,容易定位问题;集成测试作为兜底,保证各环节没掉链子。


22.2. [实战] Service 层单元测试 (Mockito)

我们以 UserService 为例。UserService 依赖 UserMapper。在单元测试中,我们不关心数据库里的真实数据,只关心:如果 Mapper 返回了 X,Service 是否能返回 Y?

22.2.1. 准备工作

spring-boot-starter-test 已经包含了 JUnit 5 和 Mockito,无需额外引入。

22.2.2. 编写测试类

文件路径: src/test/java/com/example/demo/service/UserServiceTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.mapper.UserMapper;
import com.example.demo.service.impl.UserServiceImpl;
import com.example.demo.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.when;

/**
* @ExtendWith(MockitoExtension.class): 启用 Mockito 注解支持
* 注意:这里没有用 @SpringBootTest,因为我们不需要启动 Spring 容器
*/
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

// @Mock: 创建一个模拟对象 (假的 Mapper)
@Mock
private UserMapper userMapper;

// @InjectMocks: 创建被测试对象 (Service),并自动把上面的 @Mock 注入进去
@InjectMocks
private UserServiceImpl userService;

@Test
@DisplayName("测试根据ID查询用户 - 正常场景")
void testGetUserById_Success() {
// 1. Given (准备数据与行为)
User mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("test_user");
mockUser.setStatus(1);

// 告诉 Mockito:当有人调用 userMapper.selectById(1L) 时,请返回 mockUser
// 这里的 anyLong() 是参数匹配器
when(userMapper.selectById(1L)).thenReturn(mockUser);

// 2. When (执行被测方法)
UserVO result = userService.getUserById(1L);

// 3. Then (断言结果)
// 使用 AssertJ 风格断言
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getStatusText()).isEqualTo("正常");
}

@Test
@DisplayName("测试根据ID查询用户 - 用户不存在")
void testGetUserById_NotFound() {
// 1. Given
when(userMapper.selectById(anyLong())).thenReturn(null);

// 2. When
UserVO result = userService.getUserById(999L);

// 3. Then
assertThat(result).isNull();
}
}

运行结果:点击运行,你会发现绿条瞬间亮起,耗时通常在 100ms 以内。这就是不启动 Spring 容器的魅力。


22.3. [实战] Controller 层集成测试 (MockMvc)

对于 Controller,我们要验证的是:URL 映射对不对?参数解析对不对?返回的 JSON 格式对不对?

我们需要用到 MockMvc,它能在不启动真实 Tomcat 的情况下,模拟 HTTP 请求发送给 DispatcherServlet。

22.3.1. 使用 @WebMvcTest 切片测试

我们只测试 Controller 层,不需要加载 Service 和 Dao,所以使用 @WebMvcTest 而不是 @SpringBootTest(后者会加载所有 Bean,太慢)。

文件路径: src/test/java/com/example/demo/controller/UserControllerTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.demo.controller;

import com.example.demo.service.UserService;
import com.example.demo.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
* @WebMvcTest: 只实例化 Web 层相关的 Bean (Controller, ExceptionHandler 等)
*/
@WebMvcTest(UserController.class)
class UserControllerTest {

@Autowired
private MockMvc mockMvc; // 模拟 HTTP 请求的核心工具

@MockBean // 将 Service 模拟掉,放入 Spring 容器,替换真实的 Service
private UserService userService;

@Test
@DisplayName("GET /users/{id} - 应该返回 200 和 JSON 数据")
void testGetUser_ShouldReturnUser() throws Exception {
// 1. 准备数据
UserVO mockVO = new UserVO();
mockVO.setId(100L);
mockVO.setUsername("mock_controller");

// 模拟 Service 行为
given(userService.getUserById(100L)).willReturn(mockVO);

// 2. 发起请求并验证
mockMvc.perform(get("/users/100") // 发起 GET 请求
.contentType(MediaType.APPLICATION_JSON))
// 3. 验证 HTTP 状态码是否为 200
.andExpect(status().isOk())
// 4. 验证返回的 JSON 内容 (jsonPath 语法)
.andExpect(jsonPath("$.code").value(200)) // 验证 Result 包装
.andExpect(jsonPath("$.data.id").value(100))
.andExpect(jsonPath("$.data.user_name").value("mock_controller")); // 验证 Jackson 注解生效
}
}

22.4. 进阶:断言神器 AssertJ

JUnit 自带的 assertEquals 虽然能用,但可读性较差。Spring Boot 推荐使用 AssertJ

对比感受

1
2
3
4
5
6
7
// JUnit 5 原生
assertEquals(expected, actual);
// 容易搞混哪个是期望值,哪个是实际值

// AssertJ
assertThat(actual).isEqualTo(expected);
// 读起来像英语句子:断言(实际值).等于(期望值)

AssertJ 常用 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 字符串
assertThat(name).startsWith("test").endsWith("user").hasSize(9);

// 集合
assertThat(userList)
.hasSize(3)
.contains(userA)
.doesNotContain(userB)
.extracting("username") // 提取所有对象的 username 属性
.contains("zhangsan", "lisi");

// 异常
assertThatThrownBy(() -> service.errorMethod())
.isInstanceOf(BusinessException.class)
.hasMessage("余额不足");

22.5. 本章总结与测试速查

摘要回顾
本章我们构建了代码的“安全网”。我们区分了单元测试与集成测试的边界,学会了使用 Mockito 在不依赖数据库的情况下验证业务逻辑,使用 MockMvc 验证接口协议的正确性,并掌握了 AssertJ 这一优雅的断言工具。

遇到以下 3 种测试场景时,请直接参考代码模版:

1. 场景一:测试 Service (纯逻辑)

需求:验证积分计算逻辑,不查库。
方案@ExtendWith(MockitoExtension.class)
代码

1
2
3
4
5
6
7
8
9
@InjectMocks OrderService service;
@Mock UserMapper mapper;

@Test
void test() {
when(mapper.selectById(1L)).thenReturn(vipUser);
service.calculate(1L);
verify(mapper).updateScore(any()); // 验证是否调用了更新方法
}

2. 场景二:测试 Controller (接口协议)

需求:验证参数校验是否生效,日期格式化是否正确。
方案@WebMvcTest + MockMvc
代码

1
2
3
4
5
mockMvc.perform(post("/users")
.content("{\"age\": -1}") // 发送非法数据
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) // 即使业务失败,HTTP 状态码也可能是 200
.andExpect(jsonPath("$.code").value(400)); // 验证业务状态码为 400

3. 场景三:测试复杂 SQL (Dao 层)

需求:验证 MyBatis-Plus 的 Wrapper 写得对不对。
方案@MybatisPlusTest (需要引入 MP 测试依赖) 或 @SpringBootTest + H2 内存数据库。
代码

1
2
3
4
5
6
7
8
9
@SpringBootTest
class MapperTest {
@Autowired UserMapper mapper;
@Test
void testSql() {
User user = mapper.selectOne(new QueryWrapper<User>().eq("name", "tom"));
assertThat(user).isNotNull();
}
}

4. 核心避坑指南

  1. Mock 不生效

    • 现象userMapper 是 null,或者调用真实数据库了。
    • 原因:忘记加 @ExtendWith(MockitoExtension.class);或者混用了 Spring 的 @Autowired 和 Mockito 的 @Mock
    • 对策:纯单元测试严禁使用 @Autowired
  2. @WebMvcTest 启动失败

    • 现象:报错 NoSuchBeanDefinitionException: UserService
    • 原因@WebMvcTest 只扫描 Controller,不扫描 Service。但 Controller 依赖了 Service。
    • 对策:必须使用 @MockBean 将 Controller 依赖的所有 Service 模拟出来。
  3. JsonPath 路径错误

    • 现象No value at JSON path "$.data.username"
    • 原因:Jackson 配置了驼峰转下划线,返回的是 user_name
    • 对策:检查实际返回的 JSON 字符串(可以在 perform 后加 .andDo(print()) 在控制台打印响应详情)。