18-[质量保障] Spring Test:构建可靠的应用

6. [质量保障] Spring Test:构建可靠的应用

6.1. 基础构筑:测试环境与核心理念

在编写任何测试代码之前,我们必须先确保两件事:一是我们的“工具箱”是齐全的,二是我们的“指导思想”是正确的。本节将为您铺平这两条道路。

6.1.1. 依赖先行:检查我们的“测试工具箱”

一个好消息是,Spring Initializr 已经为我们准备好了一切。当您创建项目时,pom.xml 中会自动包含一个名为 spring-boot-starter-test 的依赖,它就是我们的“测试工具箱”。

文件路径: demo-system/pom.xml

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

这个工具箱里包含了四件宝物,让我们来逐一认识它们:

工具它的角色通俗解释
JUnit 5测试的“裁判员”它是执行我们测试代码的框架,负责运行测试、收集结果,并最终告诉我们测试是通过了 (Pass) 还是失败了 (Fail)。
Spring TestSpring世界的“翻译官”它是一座桥梁,让 JUnit 5 能够理解 Spring 的应用上下文(Application Context),使我们可以在测试中获取和使用 Spring 管理的 Bean。
AssertJ结果的“鉴定师”它提供了一套非常流畅、易读的 API 来验证我们的代码结果是否符合预期。例如 assertThat(name).isEqualTo("张三"); 读起来就像一句自然语言。
Mockito专业的“特技演员”它是最重要的工具之一。当我们的代码依赖其他复杂组件时,Mockito 可以创建一个“假的”替代品(称为 Mock),让我们能隔离地测试当前的代码。

6.1.2. 核心理念:为什么要“隔离”?

想象一下我们要测试一辆汽车的发动机。

  • 集成测试: 把发动机装进完整的汽车里,打着火,开上路跑一圈。这种方式很真实,能测试所有部件的协同工作,但如果车子没启动,你很难立刻知道问题是出在发动机、变速箱还是电路系统上。而且,每次测试都要开动整辆车,成本很高,速度也很慢。

  • 单元测试: 把发动机拆下来,放到一个专用的测试台上。我们用模拟的油管、电路和传动轴连接它,然后启动。如果发动机正常运转,我们就知道发动机本身是好的。这个过程非常快,而且能精准定位问题。

在我们的软件中,UserServiceImpl 就是“发动机”,而它依赖的 UserMapper 就是“变速箱”。纯单元测试的目标,就是把 UserServiceImpl 这台“发动机”单独拿出来测试,用 Mockito 创造一个假的“变速箱”(UserMapper)来配合它,从而确保 UserServiceImpl 自身的业务逻辑是绝对正确的。


6.1.3. 两种核心测试模式的澄清

基于以上理念,Spring Boot 的测试主要分为两种泾渭分明的模式。混淆这两种模式,是导致测试失败和混乱的根源

目标:快、准、狠地测试单个类
这种模式完全不涉及 Spring 容器,是我们测试 Service 层和工具类的首选。

  • 核心工具

    1. @ExtendWith(MockitoExtension.class):告诉 JUnit 5:“这场测试由 Mockito 负责!”
    2. @InjectMocks:标记我们要测试的“发动机”(例如 UserServiceImpl)。
    3. @Mock:标记需要被模拟的“变速箱”和其他依赖(例如 UserMapper)。
  • 特征

    • 不启动 Spring 容器。
    • 执行速度极快,以毫秒计。
    • 测试代码中绝对不会出现 @SpringBootTest@Autowired

目标:测试组件间的真实协作
当我们需要测试像 Controller、数据库交互这类依赖 Spring 框架功能的场景时,就需要启动一个真实的 Spring 容器。

  • 核心工具

    1. @SpringBootTest 或测试切片(如 @WebMvcTest):告诉 Spring:“请为我启动一个测试用的应用环境!”
    2. @Autowired:从 Spring 容器中获取真实的 Bean 实例。
    3. @MockBean:当我们需要在 Spring 容器中,用一个“假的”Bean 替换掉一个“真的”Bean 时使用。
  • 特征

    • 启动一个真实的 Spring 容器。
    • 执行速度相对较慢。
    • 用于测试跨层调用或框架集成点。

6.2. [核心实践] 纯单元测试:快如闪电的业务逻辑验证

我们实践的第一个、也是最重要的测试类型,就是纯单元测试。它的核心是隔离——把我们的“发动机”(UserServiceImpl)单独拿出来,用一个假的“变速箱”(UserMapper)来配合,以此验证“发动机”本身的逻辑是否正确。

核心工具: 本节我们将只使用 @ExtendWith(MockitoExtension.class), @InjectMocks, 和 @Mock。请注意,全程不会出现 @SpringBootTest


6.2.1. 场景设定:测试 UserServiceImpl

  • 被测试对象: UserServiceImpl
  • 被模拟的依赖: UserMapper
  • 被测试方法: findUserById(Long id)
  • 核心验证逻辑:
    1. UserMapper 返回一个 User 实体时,UserServiceImpl 能否正确地将其转换为 UserVO
    2. UserMapper 返回 null 时,UserServiceImpl 能否同样返回 null

6.2.2. 编写纯单元测试

现在,我们来创建测试文件。按照 Maven 的标准约定,测试代码应该放在 src/test/java 目录下,并且包结构与主代码(src/main/java)保持一致。

我们遵循遵循的是 BDD(行为驱动开发)标准,强调从用户行为和需求出发,通过 Given - When - Then 这种结构化方式来描述和验证软件功能

Given 是设定测试初始条件,像准备好输入数据等;

When 是执行要测试的方法或操作;

Then 则是验证操作后的输出结果是否符合预期 。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
69
70
71
72
73
74
75
76
package com.example.demosystem.service.impl;

import com.example.demosystem.entity.User;
import com.example.demosystem.mapper.UserMapper;
import com.example.demosystem.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.Mockito.when;

// 【关键】@ExtendWith(MockitoExtension.class): 告诉 JUnit 5 使用 Mockito 扩展。
// 它会负责初始化被 @Mock 和 @InjectMocks 注解的字段。
@ExtendWith(MockitoExtension.class)
@DisplayName("用户服务纯单元测试")
class UserServiceImplTest {

// @InjectMocks: 告诉 Mockito 创建 UserServiceImpl 的一个真实实例。
// 然后,Mockito 会查找所有被 @Mock 标注的字段,并自动将它们注入到这个实例中。
// 这就是我们的“发动机”。
@InjectMocks
private UserServiceImpl userService;

// @Mock: 告诉 Mockito 创建一个 UserMapper 接口的模拟实现(一个“假的”对象)。
// 所有对这个 userMapper 实例的方法调用都会被 Mockito 拦截,而不会执行任何真实代码。
// 这就是我们的“假变速箱”。
@Mock
private UserMapper userMapper;

@Test
@DisplayName("当用户存在时,根据ID应能成功查询到用户信息")
void testFindUserById_whenUserExists() {
// --- GIVEN (给定一个预设条件) ---
// 1. 我们虚构一个 User 实体对象,作为 userMapper 的预期返回值。
User mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setNickname("测试用户");
mockUser.setStatus(1);

// 2. 定义模拟对象的行为(“打桩”):
// 当调用 userMapper.selectById(1L) 方法时,我们并不执行真实的 SQL,
// 而是让 Mockito 直接返回上面创建的 mockUser 对象。
when(userMapper.selectById(1L)).thenReturn(mockUser);

// --- WHEN (执行我们要测试的动作) ---
// 调用我们真正要测试的 service 方法。
UserVO resultVO = userService.findUserById(1L);

// --- THEN (验证结果是否符合预期) ---
// 使用 AssertJ 进行流式断言,验证 service 的转换逻辑是否正确。
assertThat(resultVO).isNotNull();
assertThat(resultVO.getId()).isEqualTo(1L);
assertThat(resultVO.getUserName()).isEqualTo("testuser");
assertThat(resultVO.getNickname()).isEqualTo("测试用户");
}

@Test
@DisplayName("当用户不存在时,根据ID查询应返回null")
void testFindUserById_whenUserNotExists() {
// --- GIVEN ---
// 定义行为:当调用 userMapper.selectById 一个不存在的ID (如99L) 时,返回 null。
when(userMapper.selectById(99L)).thenReturn(null);

// --- WHEN ---
UserVO resultVO = userService.findUserById(99L);

// --- THEN ---
// 验证当 mapper 返回 null 时,service 是否也正确地返回了 null。
assertThat(resultVO).isNull();
}
}

执行与观察:
您可以直接在 IDEA 中运行这个测试类或单个测试方法。您会发现测试几乎是瞬时完成的。

核心结论: 我们成功地、完全隔离地验证了 UserServiceImpl 的内部业务逻辑,而整个过程完全没有启动 Spring Boot 应用,也没有连接数据库。这正是单元测试强大且高效的魅力所在。


6.2.3. [深入] 行为验证:verify 的使用

痛点:如何测试 void 方法?

6.2.2 中,我们测试的 findUserById 方法有返回值,所以我们可以通过断言返回值来判断方法是否正确。但如果一个方法没有返回值(void),比如 deleteUser,我们该如何测试它呢?

1
2
3
4
5
6
7
8
9
// UserService.java
void deleteUserById(Long id);

// UserServiceImpl.java
@Override
public void deleteUserById(Long id) {
// 核心逻辑就是调用 mapper
userMapper.deleteById(id);
}

我们无法断言返回值,但我们的测试目标是:确保 userService.deleteUserById(1L) 在被调用时,其内部的 userMapper.deleteById(1L) 方法也必须被正确地调用了。

这就是行为验证的用武之地,而 Mockito.verify() 正是实现这一目标的核心工具。


编写行为验证测试

我们回到 UserServiceImplTest.java,为 deleteUser 方法添加一个新的测试用例。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
// ... (imports and existing class structure) ...
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

// ... (within UserServiceImplTest class) ...

@Test
@DisplayName("删除用户时,应正确调用Mapper的deleteById方法")
void testDeleteUser_shouldCallMapperCorrectly() {
// --- GIVEN ---
// 对于 void 方法,通常我们不需要"打桩",因为没有返回值需要我们去模拟。
// 当然,如果 `userMapper.deleteById` 会抛出异常,我们也可以用 when(...).thenThrow(...) 来打桩,
// 这将在 6.2.5 节讲解。
// 注意,如果这里测试失败的话是我们之前的service使用Hutool进行了判断,他和mock并不是很兼容需要删除掉判断检测是否已存在
Long userIdToDelete = 1L;

// --- WHEN ---
// 执行我们要测试的 void 方法
userService.deleteUserById(userIdToDelete);

// --- THEN ---
// 【关键】使用 Mockito.verify() 来验证行为
// 这行代码的意思是:“请验证 userMapper 这个模拟对象,
// 它的 deleteById 方法是否被调用了,并且调用次数是否恰好为 1 次,
// 且传入的参数是否就是 userIdToDelete (1L)?”
// 如果上述任一条件不满足(例如,没被调用、被调用了2次、或参数是2L),测试将失败。
verify(userMapper, times(1)).deleteById(userIdToDelete);
}

verify 的更多用法

verify 是一个非常灵活的工具,它还有很多强大的验证模式:

  • 验证从未被调用:

    1
    2
    // 验证 userMapper 的 insert 方法在本次测试中从未被调用过
    verify(userMapper, never()).insert(any(User.class));
  • 验证至少/至多调用次数:

    1
    2
    3
    4
    // 验证至少被调用了一次
    verify(userMapper, atLeast(1)).deleteById(1L);
    // 验证至多被调用了五次
    verify(userMapper, atMost(5)).deleteById(1L);
  • 使用参数匹配器: 当我们不关心传入的具体参数值,只关心类型时:

    1
    2
    // 验证 deleteById 方法被调用过,且参数是任意的 Long 类型值
    verify(userMapper).deleteById(anyLong());

核心结论:
when(...).thenReturn(...) 用于设定(Given)模拟对象的返回值
verify(...) 用于验证(Then)模拟对象的行为是否发生。

对于 void 方法,行为验证 (verify) 是我们最主要的、有时也是唯一的测试手段。它确保了被测试单元和其协作者之间的“交互约定”是正确的。


6.2.4. [深入] 深入测试:验证交互、细节与异常

在真实的业务场景中,一个方法通常不只是简单的数据转换。它包含了前置校验、与多个依赖组件的交互、内部状态处理以及异常情况的抛出。一个专业的单元测试必须能够全面地覆盖这些复杂的逻辑。

现在,更完整的 UserServiceImpl 中的 saveUser 方法为目标,来编写一套能体现专业水准的单元测试。

被测试方法源码回顾:
文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.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
@Override
public Long saveUser(UserEditDTO dto) {
// 1. 业务校验:断言用户名不存在
User existingUser = userMapper.selectOne(
new QueryWrapper<User>().lambda().eq(User::getUsername, dto.getUsername()));
if (existingUser != null) {
// 如果用户已存在,抛出自定义业务异常
throw new BusinessException(ResultCode.UserAlreadyExists);
}

// 2. DTO -> PO: 使用Convert一步到位
User user = Convert.convert(User.class, dto);

// 3. 与另一个服务交互
notificationService.sendWelcomeEmailAsync(dto.getUsername());

// 4. 设置内部默认值
user.setStatus(1);
user.setCreateTime(LocalDateTime.now());

// 5. 核心持久化操作
userMapper.insert(user);

return user.getId();
}

测试“成功路径”:verifyArgumentCaptor 的组合拳

ArgumentCaptor 是 Mockito 框架中的一个工具类,主要用于在单元测试中捕获方法调用时的参数值。在测试过程中,我们经常需要验证某个方法是否被调用,以及调用时传入的参数值是否符合预期。ArgumentCaptor 提供了一种便捷的方式来捕获和断言这些参数值。使用 ArgumentCaptor,您可以:

  • 捕获特定方法的参数。
  • 对捕获的参数进行断言,确保它们符合测试的预期。
  • 在测试中重用捕获的参数值。

目标: 验证当用户名不存在时,saveUser 方法能否正确执行所有预期操作。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
69
70
71
72
73
74
75
76
package com.example.demosystem.service.impl;

import com.example.demosystem.dto.user.UserEditDTO;
import com.example.demosystem.entity.User;
import com.example.demosystem.mapper.UserMapper;
import com.example.demosystem.service.NotificationService;
import com.example.demosystem.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
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.Mockito.*;

// 【关键】@ExtendWith(MockitoExtension.class): 告诉 JUnit 5 使用 Mockito 扩展。
// 它会负责初始化被 @Mock 和 @InjectMocks 注解的字段。
@ExtendWith(MockitoExtension.class)
@DisplayName("用户服务纯单元测试")
class UserServiceImplTest {

// @InjectMocks: 告诉 Mockito 创建 UserServiceImpl 的一个真实实例。
// 然后,Mockito 会查找所有被 @Mock 标注的字段,并自动将它们注入到这个实例中。
// 这就是我们的“发动机”。
@InjectMocks
private UserServiceImpl userService;

// @Mock: 告诉 Mockito 创建一个 UserMapper 接口的模拟实现(一个“假的”对象)。
// 所有对这个 userMapper 实例的方法调用都会被 Mockito 拦截,而不会执行任何真实代码。
// 这就是我们的“假变速箱”。
@Mock
private UserMapper userMapper;

@Mock
private NotificationService notificationService;

@Test
@DisplayName("保存用户成功路径:应设置默认值、调用邮件服务和插入方法")
void testSaveUser_happyPath() {
// --- GIVEN ---
// 1. 准备输入数据
UserEditDTO newUserDTO = new UserEditDTO();
newUserDTO.setUsername("newUser");
newUserDTO.setEmail("newUser@example.com");

// 2. 设定前置校验的行为:模拟用户不存在
// any() 是一个参数匹配器,表示我们不关心具体的查询条件是什么
when(userMapper.selectOne(any())).thenReturn(null);


// 3. 准备参数捕获器,用于捕获传递给 insert 方法的 User 对象
ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class);

// --- WHEN ---
userService.saveUser(newUserDTO);


// --- THEN ---
// 1. 验证与 NotificationService 的交互
// 验证 notificationService.sendWelcomeEmailAsync 方法被调用了1次,
// 且传入的参数是 "newUser"
verify(notificationService, times(1)).sendWelcomeEmailAsync("newUser");

// 2. 验证与 UserMapper 的交互,并捕获参数
// 验证 userMapper.insert 方法被调用了1次,并捕获传入的 User 对象
verify(userMapper, times(1)).insert(userArgumentCaptor.capture());

// 3. 对捕获的参数进行详细断言
User capturedUser = userArgumentCaptor.getValue();
// User(id=null, username=newUser, password=null, email=newUser@example.com, status=1, createTime=2025-08-20T19:35:55.725441800)
System.out.println(capturedUser);
}
}

测试“失败路径”:验证异常与防御性编程

目标: 验证当用户名已存在时,saveUser 方法能否如期抛出 BusinessException,并且不会执行后续的任何操作。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
// ... (imports and existing class structure) ...
import static org.assertj.core.api.Assertions.assertThatThrownBy;

// ... (within UserServiceImplTest class) ...

@Test
@DisplayName("保存用户失败路径:当用户名已存在时,应抛出业务异常")
void testSaveUser_whenUserExists_shouldThrowException() {
// --- GIVEN ---
// 1. 准备输入数据
UserEditDTO newUserDTO = new UserEditDTO();
newUserDTO.setUsername("existingUser");

// 2. 设定前置校验的行为:模拟用户已存在
when(userMapper.selectOne(any())).thenReturn(new User());

// --- WHEN & THEN ---
// 3. 使用 AssertJ 的 assertThatThrownBy 来验证异常
assertThatThrownBy(() -> {
// 将会抛出异常的调用放在这里
userService.saveUser(newUserDTO);
})
// 断言抛出的异常是 BusinessException 类型
.isInstanceOf(BusinessException.class)
// 并且,断言异常的消息与预期的错误码消息一致
.hasMessage(ResultCode.UserAlreadyExists.getMessage());

// 4. 【关键】防御性验证:确保在校验失败后,后续的交互从未发生
verify(notificationService, never()).sendWelcomeEmailAsync(any());
verify(userMapper, never()).insert(any(User.class));
}

核心结论:
通过组合使用 when (设定条件)、verify (验证交互)、ArgumentCaptor (捕获细节) 和 assertThatThrownBy (验证异常),我们为 saveUser 这个相对复杂的业务方法构建了一套全面而健壮的单元测试防护网。它不仅能验证成功时的结果,更能确保失败时的处理逻辑也如我们预期般稳固。


6.3. [核心实践] 集成测试:验证组件间的协同工作

6.2 节,我们像是在测试台上测试一台独立的“发动机”(UserServiceImpl)。现在,我们要把“发动机”装回“车身”,并连接上“仪表盘”和“控制电路”(Controller 和 Spring MVC 框架),然后测试这辆“汽车”作为一个整体系统能否正确响应我们的操作。这就是集成测试

6.3.1. 为何需要集成测试?(灵魂拷问:Postman 不香吗?)

在学习本节前,相信很多读者(包括正在阅读的您)心中都会有一个巨大的疑问:

“为什么我要学习一套这么复杂的测试框架?我用 Postman 或其他 API 工具,对着启动好的程序发一个真实的 POST 请求,然后看看返回结果,不也是测试吗?那样不是更简单、更真实吗?”

您能提出这个问题,说明您已经思考到了测试策略的核心。这个问题的答案,正是区分“手动测试”与“自动化质量保障”的关键。

对比维度您的直觉 (如 Postman)我们正在学的方法 (MockMvc)
测试目标验证一个完整、正在运行的系统验证开发中的 Controller 代码是否正确
运行环境需要手动启动整个 Spring Boot 应用、数据库、Redis…无需启动应用,在内存中模拟 Web 环境
执行速度 (秒级甚至分钟级) (毫秒级)
自动化难以集成到自动化构建流程(CI/CD)极易集成,是 CI/CD 的核心环节
稳定性 (测试结果易受网络、数据库数据变化的影响) (依赖被 Mock,每次运行结果都一样,可重复)
最佳用途开发完成后、部署前的手动探索性测试系统功能验收开发过程中,作为代码提交前的自动化质量卡点

核心价值:
您用 Postman 的方式,是一次性的功能验证。而我们学习 MockMvc,是为了构建一套可重复的、自动化的安全网

在团队协作中,这套安全网可以确保任何人的任何一次代码提交,都不会意外地破坏掉您或其他同事编写的 API 接口。它将“质量保障”从一件靠人力和自觉性的事情,变成了一个由机器自动执行的、可靠的工程流程。这,就是它虽然复杂,但却无可替代的理由。


6.3.2. 前置准备:解决多模块的“上下文”难题

现在,我们正式开始集成测试的准备工作。首先,必须解决那个在多模块项目中必定会遇到的“大坑”。

问题: 直接在 demo-system 这样的子模块中运行集成测试(如 @WebMvcTest),会因为找不到主启动类而失败,抛出 Unable to find a @SpringBootConfiguration 异常。

解决方案:为 demo-system 模块的测试环境创建一个专门的、轻量级的启动类。

  1. demo-system 模块的 src/test/java 目录下,创建一个与主代码平行的包,例如 com.example.demosystem
  2. 在这个包里创建一个新的 Java 类 TestApplication.java

文件路径: demo-system/src/test/java/com/example/demosystem/TestApplication.java (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.demosystem;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

/**
* 这个类是专门为 demo-system 模块的测试环境提供 Spring Boot 上下文。
* 它本身可以是一个空壳,注解会完成所有工作。
*/
@SpringBootApplication
// 如果你的其他模块(如 demo-framework)的 Bean 也需要被扫描到,
// 可以加上 @ComponentScan 注解来扩大扫描范围。
// 假设你的所有模块包都以 com.example 开头
@ComponentScan("com.example")
@MapperScan("com.example.demosystem.mapper")
public class TestApplication {
}

完成了这个简单的准备工作,我们就为后续所有集成测试铺平了道路。


6.3.3. [实战] 编写精准的 Controller 集成测试

在完成了前置的环境准备后,我们现在可以正式为 UserController 编写集成测试。

文件路径: demo-system/src/test/java/com/example/demosystem/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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package com.example.demosystem.controller;

import com.example.democommon.common.ResultCode;
import com.example.demosystem.service.UserService;
import com.example.demosystem.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
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.cache.CacheManager;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;


// @WebMvcTest 会创建一个只包含 Web 层组件的、轻量级的 Spring 测试上下文。
// 由于我们创建了 TestApplication.java, 它会作为这个上下文的入口。
@WebMvcTest(controllers = UserController.class)
@DisplayName("用户控制器Web层测试")
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

// 我们只需要 Mock 掉 Controller 直接依赖的 Service 即可。
// 如果测试启动时因为其他 @Configuration (如 CacheConfig) 而失败,
// 才需要考虑使用 @ComponentScan.Filter 进行排除。
@MockBean
private UserService userService;

@MockBean
private CacheManager manager;

@Test
@DisplayName("GET /users/{id} - 当用户存在时,应返回成功和正确的用户信息")
void testGetUserById_whenUserExists() throws Exception {
// --- GIVEN ---
UserVO mockUserVO = new UserVO();
mockUserVO.setId(1L);
// 根据 UserVO 的真实字段名,我们应该设置 name 属性
mockUserVO.setName("testuser");

when(userService.findUserById(1L)).thenReturn(mockUserVO);

// --- WHEN & THEN ---
mockMvc.perform(get("/users/1"))
// 验证 HTTP 状态码为 200
.andExpect(status().isOk())
// 验证返回的 JSON 内容
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
// 验证 JSON 内容的 code 属性
.andExpect(jsonPath("$.code").value(200))
// 验证 JSON 内容的 message 属性
.andExpect(jsonPath("$.data.id").value(1L))
// 验证 JSON 内容的 name 属性
// 根据 @JsonProperty("user_name"),正确的 jsonPath 表达式应为 user_name
.andExpect(jsonPath("$.data.user_name").value("testuser"));
}

@Test
@DisplayName("GET /users/{id} - 当用户不存在时,应返回错误信息")
void testGetUserById_whenUserNotExists() throws Exception {
// --- GIVEN ---
// 模拟 Service 层返回 null
when(userService.findUserById(99L)).thenReturn(null);

// --- WHEN & THEN ---
// 根据 UserController 的真实逻辑,当用户不存在时,它会返回一个包含错误信息的 Result 对象
mockMvc.perform(get("/users/99"))
.andExpect(status().isOk()) // HTTP 状态码依然是 200
.andExpect(jsonPath("$.code").value(ResultCode.ERROR.getCode())) // 业务码是错误码
.andExpect(jsonPath("$.message").value("用户不存在")) // 验证错误信息
.andExpect(jsonPath("$.data").isEmpty()); // data 字段应为空
}
}

6.3.4. [实战] 深入 MockMvc:测试 POST 请求与请求体

我们已经成功地测试了 GET 请求,确保了“读”操作的正确性。接下来,我们将更进一步,学习如何测试“写”操作,这需要我们向 Controller 发送一个带有 JSON 请求体(Request Body)的 POST 请求。

目标: 为 UserController 中的 POST /users (新增用户) 接口编写集成测试,确保它能正确接收、处理 DTO,并返回预期的创建成功响应。

我们将继续在 UserControllerTest.java 中添加新的测试方法。

文件路径: demo-system/src/test/java/com/example/demosystem/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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

package com.example.demosystem.controller;
import com.example.demosystem.dto.user.UserEditDTO;
import com.example.demosystem.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
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.cache.CacheManager;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
* 用户控制器 Web 层测试
*/
@WebMvcTest(controllers = UserController.class)
@DisplayName("用户控制器Web层测试")
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private UserService userService;

@MockBean
private CacheManager cacheManager;

@Autowired
private ObjectMapper jacksonObjectMapper;

@Test
@DisplayName("POST /users - 使用合法的DTO应能成功创建用户")
void testSaveUser_withValidDTO_shouldSucceed() throws Exception {
// --- GIVEN ---
// 1. 准备一个用于创建用户的 DTO 对象
UserEditDTO newUserDTO = new UserEditDTO();
newUserDTO.setUsername("newUser");
newUserDTO.setEmail("newUser@example.com");
newUserDTO.setPassword("password123");

// 2. "打桩" Service 层:
// 告诉 Mockito,当 userService.saveUser 方法被以任何 UserEditDTO 对象调用时,
// 都应该返回一个虚构的新用户 ID,例如 100L。
// 我们使用 any() 是因为 Service 层的详细逻辑已在单元测试中验证过,
// 在这里我们只关心 Controller 和框架的集成。
when(userService.saveUser(any(UserEditDTO.class))).thenReturn(100L);


// --- WHEN & THEN ---
mockMvc.perform(post("/users") // 1. 模拟一个 POST 请求
// 2. 【关键】设置请求头的 Content-Type 为 application/json
// 这告诉 Controller 我们发送的是 JSON 数据
.contentType(MediaType.APPLICATION_JSON)
// 3. 【关键】使用 objectMapper 将我们的 DTO 对象序列化为 JSON 字符串
// 并将其作为请求体发送
.content(jacksonObjectMapper.writeValueAsString(newUserDTO)))
// 4. 对响应进行断言
// 根据 UserController 的实现,成功创建应返回 HTTP 201 Created
.andExpect(status().isCreated())
// 验证我们自定义响应体中的业务码和数据
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(100L));
}
}

核心结论:
通过这个测试,我们掌握了模拟带有请求体的 POST 请求的关键步骤:

  1. 使用 @AutowiredObjectMapper 来将 Java 对象转换为 JSON 字符串。
  2. perform() 中使用 .contentType() 来声明请求体格式。
  3. 使用 .content() 来承载 JSON 字符串作为请求体。
  4. 使用 status().isCreated() 来断言 RESTful 风格的创建成功状态码。

至此,您已经掌握了测试 Controller 中最核心的“读”(GET)和“写”(POST)操作的能力。


6.3.5. [进阶] 深入 MockMvc:测试校验失败与异常处理

到目前为止,我们测试的都是“成功路径”(Happy Path)。但在真实世界中,代码的健壮性更多地体现在它如何优雅地处理错误。本节,我们将学习如何使用 MockMvc 来验证两种最常见的失败场景:输入校验失败业务异常抛出

场景一:测试 Bean Validation 校验失败

我们的 UserControllersaveUser 方法上使用了 @Validated 注解,它会根据 UserEditDTO 中定义的规则(如 @NotBlank)进行输入校验。如果校验失败,我们的 GlobalExceptionHandler 会捕获 MethodArgumentNotValidException 并返回一个 HTTP 400 响应。现在,我们就来测试这个流程。

文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java (添加新方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("POST /users - 当用户名已存在时,应返回用户已存在的错误")
@SneakyThrows
void testSaveUser_whenUsernameExists_shouldReturnBusinessError() {
// --- GIVEN ---
// 1. 准备一个合法的 DTO
UserEditDTO userEditDTO = new UserEditDTO();
userEditDTO.setUsername("TestUser");
userEditDTO.setEmail("test@example.com");

// 2. 【关键】"打桩" Service 层,让它在被调用时直接抛出我们预设的业务异常
when(userService.saveUser(any(UserEditDTO.class))).thenThrow(new BusinessException(ResultCode.UserAlreadyExists));

// --- WHEN & THEN ---
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(jacksonObjectMapper.writeValueAsString(userEditDTO)))
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.code").value(ResultCode.UserAlreadyExists.getCode()));
}

场景二:测试 Service 层抛出的业务异常

Controller 的职责之一就是调用 Service。如果 Service 抛出了一个业务异常(BusinessException),Controller 并不直接处理,而是交由 GlobalExceptionHandler 来捕获并转换为统一的 JSON 响应。我们就来测试这个完整的“异常传递与处理”链路。

文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java (添加新方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("POST /users - 当用户名已存在时,应返回用户已存在的错误")
@SneakyThrows
void testSaveUser_whenUsernameExists_shouldReturnBusinessError() {
// --- GIVEN ---
// 1. 准备一个合法的 DTO
UserEditDTO userEditDTO = new UserEditDTO();
userEditDTO.setUsername("TestUser");
userEditDTO.setEmail("test@example.com");
userEditDTO.setPassword("123456");

// 2. 【关键】"打桩" Service 层,让它在被调用时直接抛出我们预设的业务异常
when(userService.saveUser(any(UserEditDTO.class))).thenThrow(new BusinessException(ResultCode.UserAlreadyExists));

// --- WHEN & THEN ---
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(jacksonObjectMapper.writeValueAsString(userEditDTO)))
.andExpect(jsonPath("$.code").value(ResultCode.UserAlreadyExists.getCode()));
}

摘要: 恭喜您!坚持学习到这里,您已经走完了从 Spring Boot 基础到核心实践的关键一步。我们一起从零开始,搭建项目、管理配置、实践 AOP、操作数据库、实现事务与缓存、调用外部 API,并最终为我们的代码构建了一套专业、自动化的测试安全网。您现在掌握的,不仅仅是 Spring Boot 的使用方法,更是一套符合现代软件工程标准的开发思想与流程。

我们所学的,是构建任何一个坚实系统的“地基”。UserService 虽然简单,但“麻雀虽小,五脏俱全”,它身上凝聚了我们对配置、分层、数据处理、接口设计和质量保障的全部心血。

但这仅仅是开始。一个真正强大的、企业级的分布式系统,还需要在更多维度上进行深化和扩展。接下来的学习路线,将为您揭开这幅宏伟蓝图的全貌。