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 Test Spring世界的“翻译官” 它是一座桥梁,让 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 集成测试(模式二) 目标:快、准、狠地测试单个类 这种模式完全不涉及 Spring 容器,是我们测试 Service 层和工具类的首选。
核心工具
@ExtendWith(MockitoExtension.class)
:告诉 JUnit 5:“这场测试由 Mockito 负责!”@InjectMocks
:标记我们要测试的“发动机”(例如 UserServiceImpl
)。@Mock
:标记需要被模拟的“变速箱”和其他依赖(例如 UserMapper
)。特征
不启动 Spring 容器。 执行速度极快,以毫秒计。 测试代码中绝对不会 出现 @SpringBootTest
或 @Autowired
。 目标:测试组件间的真实协作 当我们需要测试像 Controller、数据库交互这类依赖 Spring 框架功能的场景时,就需要启动一个真实的 Spring 容器。
核心工具
@SpringBootTest
或测试切片(如 @WebMvcTest
):告诉 Spring:“请为我启动一个测试用的应用环境!”@Autowired
:从 Spring 容器中获取真实 的 Bean 实例。@MockBean
:当我们需要在 Spring 容器中,用一个“假的”Bean 替换掉一个“真的”Bean 时使用。特征
启动一个真实的 Spring 容器。 执行速度相对较慢。 用于测试跨层调用或框架集成点。
6.2. [核心实践] 纯单元测试:快如闪电的业务逻辑验证 我们实践的第一个、也是最重要的测试类型,就是纯单元测试。它的核心是隔离 ——把我们的“发动机”(UserServiceImpl
)单独拿出来,用一个假的“变速箱”(UserMapper
)来配合,以此验证“发动机”本身的逻辑是否正确。
核心工具 : 本节我们将只使用 @ExtendWith(MockitoExtension.class)
, @InjectMocks
, 和 @Mock
。请注意,全程不会 出现 @SpringBootTest
。
6.2.1. 场景设定:测试 UserServiceImpl
被测试对象 : UserServiceImpl
被模拟的依赖 : UserMapper
被测试方法 : findUserById(Long id)
核心验证逻辑 :当 UserMapper
返回一个 User
实体时,UserServiceImpl
能否正确地将其转换为 UserVO
? 当 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) @DisplayName("用户服务纯单元测试") class UserServiceImplTest { @InjectMocks private UserServiceImpl userService; @Mock private UserMapper userMapper; @Test @DisplayName("当用户存在时,根据ID应能成功查询到用户信息") void testFindUserById_whenUserExists () { User mockUser = new User (); mockUser.setId(1L ); mockUser.setUsername("testuser" ); mockUser.setNickname("测试用户" ); mockUser.setStatus(1 ); when (userMapper.selectById(1L )).thenReturn(mockUser); UserVO resultVO = userService.findUserById(1L ); assertThat(resultVO).isNotNull(); assertThat(resultVO.getId()).isEqualTo(1L ); assertThat(resultVO.getUserName()).isEqualTo("testuser" ); assertThat(resultVO.getNickname()).isEqualTo("测试用户" ); } @Test @DisplayName("当用户不存在时,根据ID查询应返回null") void testFindUserById_whenUserNotExists () { when (userMapper.selectById(99L )).thenReturn(null ); UserVO resultVO = userService.findUserById(99L ); 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 void deleteUserById (Long id) ;@Override public void deleteUserById (Long id) { 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 import static org.mockito.Mockito.times;import static org.mockito.Mockito.verify; @Test @DisplayName("删除用户时,应正确调用Mapper的deleteById方法") void testDeleteUser_shouldCallMapperCorrectly () { Long userIdToDelete = 1L ; userService.deleteUserById(userIdToDelete); verify(userMapper, times(1 )).deleteById(userIdToDelete); }
verify
的更多用法verify
是一个非常灵活的工具,它还有很多强大的验证模式:
核心结论 :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) { User existingUser = userMapper.selectOne( new QueryWrapper <User>().lambda().eq(User::getUsername, dto.getUsername())); if (existingUser != null ) { throw new BusinessException (ResultCode.UserAlreadyExists); } User user = Convert.convert(User.class, dto); notificationService.sendWelcomeEmailAsync(dto.getUsername()); user.setStatus(1 ); user.setCreateTime(LocalDateTime.now()); userMapper.insert(user); return user.getId(); }
测试“成功路径”:verify
与 ArgumentCaptor
的组合拳 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) @DisplayName("用户服务纯单元测试") class UserServiceImplTest { @InjectMocks private UserServiceImpl userService; @Mock private UserMapper userMapper; @Mock private NotificationService notificationService; @Test @DisplayName("保存用户成功路径:应设置默认值、调用邮件服务和插入方法") void testSaveUser_happyPath () { UserEditDTO newUserDTO = new UserEditDTO (); newUserDTO.setUsername("newUser" ); newUserDTO.setEmail("newUser@example.com" ); when (userMapper.selectOne(any())).thenReturn(null ); ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class); userService.saveUser(newUserDTO); verify(notificationService, times(1 )).sendWelcomeEmailAsync("newUser" ); verify(userMapper, times(1 )).insert(userArgumentCaptor.capture()); User capturedUser = userArgumentCaptor.getValue(); 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 import static org.assertj.core.api.Assertions.assertThatThrownBy; @Test @DisplayName("保存用户失败路径:当用户名已存在时,应抛出业务异常") void testSaveUser_whenUserExists_shouldThrowException () { UserEditDTO newUserDTO = new UserEditDTO (); newUserDTO.setUsername("existingUser" ); when (userMapper.selectOne(any())).thenReturn(new User ()); assertThatThrownBy(() -> { userService.saveUser(newUserDTO); }) .isInstanceOf(BusinessException.class) .hasMessage(ResultCode.UserAlreadyExists.getMessage()); 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
模块的测试环境创建一个专门的、轻量级的启动类。
在 demo-system
模块的 src/test/java
目录下,创建一个与主代码平行的包,例如 com.example.demosystem
。 在这个包里创建一个新的 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;@SpringBootApplication @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(controllers = UserController.class) @DisplayName("用户控制器Web层测试") class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @MockBean private CacheManager manager; @Test @DisplayName("GET /users/{id} - 当用户存在时,应返回成功和正确的用户信息") void testGetUserById_whenUserExists () throws Exception { UserVO mockUserVO = new UserVO (); mockUserVO.setId(1L ); mockUserVO.setName("testuser" ); when (userService.findUserById(1L )).thenReturn(mockUserVO); mockMvc.perform(get("/users/1" )) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.code" ).value(200 )) .andExpect(jsonPath("$.data.id" ).value(1L )) .andExpect(jsonPath("$.data.user_name" ).value("testuser" )); } @Test @DisplayName("GET /users/{id} - 当用户不存在时,应返回错误信息") void testGetUserById_whenUserNotExists () throws Exception { when (userService.findUserById(99L )).thenReturn(null ); mockMvc.perform(get("/users/99" )) .andExpect(status().isOk()) .andExpect(jsonPath("$.code" ).value(ResultCode.ERROR.getCode())) .andExpect(jsonPath("$.message" ).value("用户不存在" )) .andExpect(jsonPath("$.data" ).isEmpty()); } }
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.*;@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 { UserEditDTO newUserDTO = new UserEditDTO (); newUserDTO.setUsername("newUser" ); newUserDTO.setEmail("newUser@example.com" ); newUserDTO.setPassword("password123" ); when (userService.saveUser(any(UserEditDTO.class))).thenReturn(100L ); mockMvc.perform(post("/users" ) .contentType(MediaType.APPLICATION_JSON) .content(jacksonObjectMapper.writeValueAsString(newUserDTO))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.code" ).value(200 )) .andExpect(jsonPath("$.data" ).value(100L )); } }
核心结论 : 通过这个测试,我们掌握了模拟带有请求体的 POST
请求的关键步骤:
使用 @Autowired
的 ObjectMapper
来将 Java 对象转换为 JSON 字符串。 在 perform()
中使用 .contentType()
来声明请求体格式。 使用 .content()
来承载 JSON 字符串作为请求体。 使用 status().isCreated()
来断言 RESTful 风格的创建成功状态码。 至此,您已经掌握了测试 Controller
中最核心的“读”(GET
)和“写”(POST
)操作的能力。
6.3.5. [进阶] 深入 MockMvc:测试校验失败与异常处理 到目前为止,我们测试的都是“成功路径”(Happy Path)。但在真实世界中,代码的健壮性更多地体现在它如何优雅地处理错误。本节,我们将学习如何使用 MockMvc
来验证两种最常见的失败场景:输入校验失败 和业务异常抛出 。
场景一:测试 Bean Validation 校验失败 我们的 UserController
在 saveUser
方法上使用了 @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 () { UserEditDTO userEditDTO = new UserEditDTO (); userEditDTO.setUsername("TestUser" ); userEditDTO.setEmail("test@example.com" ); when (userService.saveUser(any(UserEditDTO.class))).thenThrow(new BusinessException (ResultCode.UserAlreadyExists)); 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 () { UserEditDTO userEditDTO = new UserEditDTO (); userEditDTO.setUsername("TestUser" ); userEditDTO.setEmail("test@example.com" ); userEditDTO.setPassword("123456" ); when (userService.saveUser(any(UserEditDTO.class))).thenThrow(new BusinessException (ResultCode.UserAlreadyExists)); 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
虽然简单,但“麻雀虽小,五脏俱全”,它身上凝聚了我们对配置、分层、数据处理、接口设计和质量保障的全部心血。
但这仅仅是开始。一个真正强大的、企业级的分布式系统,还需要在更多维度上进行深化和扩展。接下来的学习路线,将为您揭开这幅宏伟蓝图的全貌。