第 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:思考题
你刚接手的项目一行测试都没有。如何从零搭建测试体系?优先级和策略是什么?