Note 22. 质量守门员:JUnit 5 单元测试与 MockMvc 集成测试
发表于更新于
字数总计:2k阅读时长:8分钟阅读量: 广东
Note 22. 质量守门员:JUnit 5 单元测试与 MockMvc 集成测试
摘要: 只有经过测试的代码才是可信的。本章我们将构建 Spring Boot 的自动化测试体系。我们将从 JUnit 5 和 Mockito 开始,学习如何编写不依赖 Spring 容器的纯 单元测试(测试 Service 逻辑);接着使用 MockMvc 和 @WebMvcTest 进行 集成测试(测试 Controller 接口),验证 HTTP 请求处理流程。最后,我们将掌握 AssertJ 断言库,写出如自然语言般流畅的断言语句。
本章学习路径
- 测试分层:理解单元测试(Unit Test)与集成测试(Integration Test)的区别。
- 单元测试:使用 Mockito 隔离依赖,快速验证 Service 层的业务逻辑。
- 集成测试:使用 MockMvc 模拟 HTTP 请求,验证 Controller 层的参数绑定与响应。
- 断言艺术:使用 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) class UserServiceTest {
@Mock private UserMapper userMapper;
@InjectMocks private UserServiceImpl userService;
@Test @DisplayName("测试根据ID查询用户 - 正常场景") void testGetUserById_Success() { User mockUser = new User(); mockUser.setId(1L); mockUser.setUsername("test_user"); mockUser.setStatus(1);
when(userMapper.selectById(1L)).thenReturn(mockUser);
UserVO result = userService.getUserById(1L);
assertThat(result).isNotNull(); assertThat(result.getId()).isEqualTo(1L); assertThat(result.getStatusText()).isEqualTo("正常"); }
@Test @DisplayName("测试根据ID查询用户 - 用户不存在") void testGetUserById_NotFound() { when(userMapper.selectById(anyLong())).thenReturn(null);
UserVO result = userService.getUserById(999L);
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(UserController.class) class UserControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private UserService userService;
@Test @DisplayName("GET /users/{id} - 应该返回 200 和 JSON 数据") void testGetUser_ShouldReturnUser() throws Exception { UserVO mockVO = new UserVO(); mockVO.setId(100L); mockVO.setUsername("mock_controller");
given(userService.getUserById(100L)).willReturn(mockVO);
mockMvc.perform(get("/users/100") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.id").value(100)) .andExpect(jsonPath("$.data.user_name").value("mock_controller")); } }
|
22.4. 进阶:断言神器 AssertJ
JUnit 自带的 assertEquals 虽然能用,但可读性较差。Spring Boot 推荐使用 AssertJ。
对比感受:
1 2 3 4 5 6 7
| assertEquals(expected, actual);
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") .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()) .andExpect(jsonPath("$.code").value(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. 核心避坑指南
Mock 不生效
- 现象:
userMapper 是 null,或者调用真实数据库了。 - 原因:忘记加
@ExtendWith(MockitoExtension.class);或者混用了 Spring 的 @Autowired 和 Mockito 的 @Mock。 - 对策:纯单元测试严禁使用
@Autowired。
@WebMvcTest 启动失败
- 现象:报错
NoSuchBeanDefinitionException: UserService。 - 原因:
@WebMvcTest 只扫描 Controller,不扫描 Service。但 Controller 依赖了 Service。 - 对策:必须使用
@MockBean 将 Controller 依赖的所有 Service 模拟出来。
JsonPath 路径错误
- 现象:
No value at JSON path "$.data.username"。 - 原因:Jackson 配置了驼峰转下划线,返回的是
user_name。 - 对策:检查实际返回的 JSON 字符串(可以在
perform 后加 .andDo(print()) 在控制台打印响应详情)。