1. 为什么要给 Spring MVC 写单元测试(项目背后的核心思路)
先说结论:Spring MVC 的单元测试,难的不是写代码,而是搞清楚你到底在测什么。是测 Controller 的路由映射?还是测 Service 层的业务逻辑?还是测 Mapper 的 SQL?如果这三层混在一起写,那叫集成测试,不叫单元测试,跑起来又慢又脆,一改需求就碎一片。
我最早给某个内部管理系统写测试时就犯过这个错:直接 new 一个 Spring 上下文,把 Controller、Service、数据库全拉起来,一个测试类跑一次要十几秒,几十个用例下来小十分钟。后来有一次同事改了个数据库字段类型,跑测试时所有涉及查询的用例全挂,排查了半天才发现问题根本不在业务代码里,而是测试数据没同步更新。那次之后我彻底明白了一个道理:单元测试的价值在于快速反馈和精确定位,要做到这两点,就必须用 Mock 机制把被测对象以外的依赖全部替换掉。
Spring MVC 的项目里,一个请求从进入 DispatcherServlet 到最终返回 JSON,中间经历了路由匹配、参数解析、拦截器、Controller 方法调用、Service 调用、数据库访问等一连串环节。Unit 测试的颗粒度决定了你要在哪一层切断这链条。我的习惯是:Controller 层用 MockMvc 走真实 HTTP 链路,但 Service 层依赖全部 Mock;Service 层测试则完全不启动 Web 容器,只测纯粹的 Java 方法。两层分开,互相隔离,测试速度能控制在秒级以内。
这个项目适合谁呢?适合那些已经在用 Spring MVC 写接口,但从来没系统写过测试的开发者。也适合那些写测试只会启动整个应用然后用 Postman 手动点的人——不是说手动测不对,而是当接口数量超过几十个之后,手动回归的成本完全不可接受。本文所有的示例都基于 Spring MVC + JUnit 常见的组合方案,JDK 8+ 环境,Maven 构建,你拿过去改改包名就能用。
2. 测试环境准备与依赖配置(先搭好台子再唱戏)
2.1 Maven 依赖怎么加才不踩坑
Spring MVC 项目搞单元测试,核心依赖其实就是两类:Spring Test 框架和 JUnit。Mockito 作为 Mock 工具也是刚需。下面这套版本组合我实测比较稳定:
<properties> <junit.version>4.13.2</junit.version> <mockito.version>3.12.4</mockito.version> <spring.version>5.2.22.RELEASE</spring.version> </properties> <dependencies> <!-- Spring 测试模块 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-test</artifactId> <version>${spring.version}</version> <scope>test</scope> </dependency> <!-- JUnit --> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <!-- Mockito --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <!-- JSON 断言辅助,别小看这个库 --> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> <version>1.3</version> <scope>test</scope> </dependency> </dependencies>这里有几个容易踩的坑。第一个是 spring-test 版本必须和项目里的 Spring MVC 版本一致,否则可能出现类冲突或者方法签名对不上的诡异报错。我曾经见过一个项目 spring-core 是 5.1.x,但 spring-test 用的是 5.3.x,结果 MockMvc 构建时直接抛 NoSuchMethodError,排查了很久才发现是版本错位。第二个是如果项目用的是 JUnit 5,那依赖要换成 junit-jupiter-api 和 junit-jupiter-engine,注解也从 org.junit.Test 换成 org.junit.jupiter.api.Test。本文示例基于 JUnit 4,大多数老项目至今仍在用它,兼容性最省心。
2.2 测试目录与基础类怎么组织
Maven 工程的标准目录结构里,测试代码放在 src/test/java 下,测试资源放在 src/test/resources 下。我习惯按被测对象的层级建包,和 main 源码的包路径保持一致,这样查找用例非常直接:
src/test/java/com/xx/module/ ├── controller/ │ └── UserControllerTest.java ├── service/ │ └── UserServiceTest.java └── common/ └── BaseControllerTest.java看到 BaseControllerTest 了吗?这是我强烈建议你做的事情:把 Controller 测试的公共部分抽到一个抽象基类里。因为你的项目里 Controller 肯定不止一个,每个测试类都要创建 MockMvc、配置过滤器、设置 MockMvc 的默认行为,这些重复代码全抽出来,能省掉大量体力活。基类的样子大概是这样:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration({"classpath:spring/spring-mvc.xml", "classpath:spring/spring-service.xml"}) @WebAppConfiguration public abstract class BaseControllerTest { @Autowired protected WebApplicationContext wac; protected MockMvc mockMvc; @Before public void setUp() { mockMvc = MockMvcBuilders.webAppContextSetup(wac).build(); } }注意两个细节:@WebAppConfiguration 是必须的,它告诉 Spring 测试框架要加载 WebApplicationContext,没有它你注入的 WebApplicationContext 会是空的;@ContextConfiguration 加载的是项目的 Spring 配置文件,如果你项目里用的是 Java Config,那就改成 @Configuration 类的集合。这个基类的本质就是初始化一个真实的 Spring MVC 容器,但它不监听端口、不启动 Tomcat,所以执行速度比完整起应用快得多。
3. Controller 层测试的核心实现(MockMvc 的正确打开方式)
3.1 两种创建 MockMvc 的方式怎么选
MockMvc 有两种创建方式,区别很关键。第一种是上面基类里那种 webAppContextSetup,加载整个 Spring 上下文,好处是 Controller 里注入的 Service 都能被 Spring 容器管理,测试更接近真实环境;坏处是启动环境有开销,而且如果你只想测一个 Controller 的映射逻辑,却被上下文里所有 Bean 的初始化拖累,测试会变慢。
第二种是 standaloneSetup,只针对某一个 Controller 实例直接构建:
@RunWith(MockitoJUnitRunner.class) public class UserControllerStandaloneTest { @Mock private UserService userService; private MockMvc mockMvc; @Before public void setUp() { UserController controller = new UserController(userService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } }这种方式完全不加载 Spring 上下文,Controller 里依赖的 Service 用 @Mock 手动注入,速度极快,几毫秒就跑完。我的经验是:如果你的项目 Controller 层逻辑不复杂,Service 依赖也不多,standaloneSetup 完全够用,测试最快最稳;如果 Controller 里还牵扯了拦截器、异常处理器、过滤器这些 Web 层组件,那就老老实实用 webAppContextSetup,否则你测出来的东西和线上行为可能不一致。多数情况下我推荐先分立再集成,两套方案各留几个代表用例。
3.2 一个完整的 Controller 测试用例长什么样
拿最常见的用户查询接口举例,Controller 长这样:
@RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{id}") public Result<UserVO> getUser(@PathVariable Long id) { if (id == null || id <= 0) { throw new IllegalArgumentException("非法用户ID"); } UserVO user = userService.getUserById(id); return Result.success(user); } }对应的测试用例:
public class UserControllerTest extends BaseControllerTest { @Autowired private UserService userService; @Before public void initMock() { // 关键:把真实Service里的方法替换成Mock逻辑 when(userService.getUserById(1L)).thenReturn(buildUserVO()); when(userService.getUserById(100L)).thenThrow(new RuntimeException("用户不存在")); } @Test public void testGetUserSuccess() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/api/user/1")) .andExpect(MockMvcResultMatchers.status().isOk()) .andExpect(MockMvcResultMatchers.jsonPath("$.code").value(200)) .andExpect(MockMvcResultMatchers.jsonPath("$.data.name").value("测试用户")) .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON_UTF8)) .andReturn(); } @Test public void testGetUserInvalidId() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get("/api/user/0")) .andExpect(MockMvcResultMatchers.status().isBadRequest()); } }这段代码里有几个非常容易踩的细节。第一个是 mockMvc.perform 内部用的是 MockHttpServletRequestBuilder,它构建请求时一定要写清楚 HTTP 方法和路径,路径不需要带上下文路径,除非你在 webAppContextSetup 里配置了 contextPath。第二个是 jsonPath 断言,如果返回值里嵌了多层数据结构,比如 data.list[0].name,路径写法和 XPath 类似,用点在层级间跳转,数组下标从 0 开始。第三个是最隐蔽的:当你用 @Autowired 注入真实 Service 再用 when() 打桩时,实际上 Mockito 没法直接 mock 一个 Spring 注入的 Bean,除非这个 Bean 本身就是 Mockito 的 mock 对象。后面第 5 节我会专门讲这个问题怎么处理。
3.3 参数校验与异常场景怎么测
Controller 测试最容易忽略的是异常场景。很多人只写了正常返回的用例,觉得 200 走通了就万事大吉。实际上接口的健壮性全藏在异常路径里。比如前面那个 getUser,id <= 0 时接口应该返回 400 还是 404?这是个很容易含糊的点。我在项目里定过规矩:参数非法返回 4xx,资源不存在返回 404,业务逻辑异常返回 500,这个规则必须由全局异常处理器统一兜底。测试的时候就把这些规则写成用例固化下来,以后谁改乱了谁负责。
参数校验测试还有个高级用法,用 @RestControllerAdvice 配合 @Valid 注解。比如接口签名是:
@PostMapping("/create") public Result createUser(@Valid @RequestBody UserCreateDTO dto) { ... }DTO 里加了 @NotBlank、@Min、@NotNull 这些校验注解,测试时就要故意构造不合法的请求体:
@Test public void testCreateUserValidationFail() throws Exception { String invalidJson = "{\"name\":\"\",\"age\":15}"; mockMvc.perform(MockMvcRequestBuilders.post("/api/user/create") .contentType(MediaType.APPLICATION_JSON_UTF8) .content(invalidJson)) .andExpect(MockMvcResultMatchers.status().isBadRequest()); }这里容易掉进去的坑是:如果你的项目忘了配置 MethodArgumentNotValidException 的异常处理器,Spring 默认会返回 500 而不是 400。很多新手写测试时发现期望 400 结果返回 500,第一反应是测试写法错了,实际上是对应异常没有配置全局处理器。遇到这种用例失败,先去排查 ControllerAdvice 里有没有处理该类异常,而不是急着改测试断言。
4. Service 层与 DAO 层的测试配合(把 Mockito 用顺手)
4.1 Mockito 的三个高频用法
Service 层是单元测试的重头戏,因为真正的业务逻辑都堆在这层。写 Service 测试时,DAO(或者 Mapper)依赖几乎必然要 Mock。Mockito 的高频用法其实就三个:打桩、校验、捕获参数。
打桩用 when().thenReturn(),这是最基础的,不再赘述。值得强调的是打桩的匹配规则,如果方法参数是对象,建议直接用 eq() 做精确匹配,不要用 any() 大范围匹配,否则可能测出假阳性。看个例子:
when(userDao.queryByName(eq("张三"))).thenReturn(user);如果你写的是 when(userDao.queryByName(anyString())).thenReturn(user),那么业务代码里传任何字符串都能拿到返回结果,一旦真实代码里对参数做了拼接、转义这类处理,测试就完全失真是。校验用法主要是 verify(),验证某个方法是否按预期被调用了多少次:
verify(userDao, times(1)).insert(any(User.class)); verify(userDao, never()).update(any(User.class));捕获参数比较进阶,适合那种需要断言"你传给下层方法的值是不是想要的"的场景。比如创建用户时,Service 内部会往 DTO 里塞一个创建时间,你想确认这个值格式对不对,用 ArgumentCaptor:
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class); verify(userDao).insert(captor.capture()); User captured = captor.getValue(); assertEquals(LocalDate.now(), captured.getCreateDate());4.2 Service 层测试的完整套路
理论说再多不如直接看代码。假设 UserService 里有一个创建用户并发送消息的业务方法:
@Service public class UserService { private final UserDao userDao; private final MessageClient messageClient; public UserService(UserDao userDao, MessageClient messageClient) { this.userDao = userDao; this.messageClient = messageClient; } @Transactional public Long createUser(UserCreateDTO dto) { User user = new User(); user.setName(dto.getName()); user.setAge(dto.getAge()); user.setCreateDate(LocalDateTime.now()); userDao.insert(user); messageClient.sendWelcomeMessage(user.getId()); return user.getId(); } }对应测试:
@RunWith(MockitoJUnitRunner.class) public class UserServiceTest { @Mock private UserDao userDao; @Mock private MessageClient messageClient; @InjectMocks private UserService userService; @Test public void testCreateUser_success() { UserCreateDTO dto = new UserCreateDTO(); dto.setName("小王"); dto.setAge(20); when(userDao.insert(any(User.class))).thenAnswer(invocation -> { User user = invocation.getArgument(0); return user.getId(); }); Long userId = userService.createUser(dto); assertNotNull(userId); ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class); verify(userDao).insert(captor.capture()); assertEquals("小王", captor.getValue().getName()); assertNotNull(captor.getValue().getCreateDate()); verify(messageClient).sendWelcomeMessage(userId); } }看两个要点。第一,@InjectMocks 会把 @Mock 生成的 mock 对象按类型注入到 UserService 实例里,前提是 UserService 的构造器或 setter 能接收到这些依赖,如果用构造器注入,字段名和参数名对不上也没关系,按类型匹配。第二,insert 方法如果返回的是主键,用 thenAnswer 可以捕获调用参数并返回一个动态值,这比写死 when(userDao.insert(...)).thenReturn(1L) 更贴近真实场景,特别是当 Service 后续逻辑依赖返回值时。
4.3 事务回滚与数据库真实联调怎么测
纯 Mock 测试只能验证逻辑分支,不能验证 SQL 语句本身对不对。如果你有 MyBatis 或 JPA 的 XML 映射文件,SQL 的语法错误、字段名拼写错误这类问题,Mock 是完全测不出来的。这时候就得在测试里搭一个真实的数据库连接。我的做法是用内存数据库,配合 Spring 的事务测试支持:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {MybatisTestConfig.class}) @Transactional public class UserDaoTest { @Autowired private UserDao userDao; @Test public void testInsertAndQuery() { User user = new User(); user.setName("测试用户"); user.setAge(20); userDao.insert(user); User queried = userDao.selectById(user.getId()); assertEquals("测试用户", queried.getName()); } }这里最关键的是 @Transactional 注解,它会为每个测试方法开启事务,方法结束回滚,保证测试数据不会污染主库。我在项目里踩过很深的坑是:忘了加 @Transactional,测试跑完数据库里攒了一堆垃圾数据,第二次跑直接主键冲突挂掉。更隐蔽的问题是,如果测试里调用的 Service 方法内部自己加了 @Transactional,并且你直接调用它,事务可能已经被该方法提交了,外层测试事务包裹不住。这种情况下要改用测试类里手动 new 出来的 Service 实例,或者用 TransactionTemplate 控制粒度。
真实数据库测试虽慢,但在 DAO 层是值得的。SQL 写错在测试阶段发现,成本是最低的;等上了生产环境才发现,那就是事故。
5. 常见问题与排查技巧实录(这部分最值钱)
5.1 测试方法里怎么给 Spring 容器里的 Bean 打桩
这是我在实际项目中被问得最多的一个问题。前面提到过,如果 Controller 里的 UserService 是 @Autowired 注入的真实 Bean,你在测试方法里写 when(userService.getUserById(1L)).thenReturn(...),运行时会发现根本不起作用,返回的永远是 null。
原因是 Mockito 的 when() 只能对 mock 对象生效,对真实对象调用该方法不会走打桩逻辑。正确的处理方式有三种,按推荐程度排序。
第一种,在测试类上用 @Mock 声明 UserService,然后手动把它塞进 Controller。这适用于 standaloneSetup 场景。
第二种,用 @MockBean 注解,这是 Spring Boot 测试框架提供的:
@SpringBootTest public class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test public void testGetUser() throws Exception { when(userService.getUserById(1L)).thenReturn(userVO); // ... 正常断言 } }@MockBean 会把一个 mock 对象注入到 Spring 容器中,替换掉原有的真实 Bean,这样 Controller 里 @Autowired 拿到的就是 mock 对象,打桩立刻生效。注意如果你的项目是单纯的 Spring MVC(非 Spring Boot),@MockBean 是没法用的,这是 Boot 的独家能力。
第三种,也是最通用的:把 Service 层替换成基于 Mockito 的静态工厂方法,在 @Before 里手动替换。比如用反射把 Controller 里的私有 Service 字段替换成 mock 对象。这套操作虽然麻烦,但兼容所有环境。我的建议是能上 @MockBean 就上 @MockBean,省事且可读性强。
5.2 测试中文出现乱码怎么处理
MockMvc 测试返回 JSON 时,如果接口返回的数据包含中文,断言 jsonPath 时容易遇到乱码问题。最典型的场景是 .andExpect(content().string(containsString("用户"))) 失败,但用浏览器访问接口明明是正确的。原因是 MockMvc 使用的 HttpServletResponse 编码默认为 ISO-8859-1,而 Spring 返回 JSON 时通过 ResponseBodyAdvice 或 HttpMessageConverter 写出的数据,编码可能没有跟着字符集走。
解决办法有几个层面。第一是在配置 MockMvc 时显式声明解码字符集:
mockMvc = MockMvcBuilders.webAppContextSetup(wac) .characterEncoding("UTF-8") .build();或者在断言前用 MvcResult 拿到响应内容再手动解码:
MvcResult result = mockMvc.perform(get("/api/user/1")) .andExpect(status().isOk()) .andReturn(); String content = result.getResponse().getContentAsString(StandardCharsets.UTF_8);第二个办法是给 Controller 配置 produces 属性,确保 Content-Type 里带上了 charset:
@GetMapping(value = "/{id}", produces = "application/json;charset=UTF-8")我个人的经验是:先检查配置里字符集,再检查返回头,不要一上来就怀疑断言写法。MockMvc 明明显示乱码但浏览器正常,基本就是测试容器编码没有匹配,不是接口的真问题。
5.3 测试跑得越来越慢怎么办
很多人写测试写到后期会抱怨:怎么跑一次全量测试要五分钟?一排查发现是测试类里每个 @Before 都在加载完整的 Spring 上下文,而 Sprint 上下文在同一个测试类中确实只加载一次,但不同测试类之间不共享。你有二十个 Controller 测试类,每个类都 new 一遍 ApplicationContext,自然慢炸。
解决思路是缓存上下文。Spring 测试框架本身有上下文缓存机制:只要 @ContextConfiguration 里指定的配置资源完全一致,多个测试类之间会共享同一个上下文实例。所以请务必保证 BaseControllerTest 里的配置统一,不要有的类多引一个 XML,有的类少引一个。一旦配置不一致,每多一套配置就是多一次上下文初始化。
还有一个很常见的性能问题是测试里用了 Thread.sleep。有些开发者为了等异步任务完成,直接在测试里 sleep 两秒,这种做法在单测里基本是禁忌。正确的思路是用 Awaitility 这类库做条件等待,或者把异步任务改成同步调用后再断言。实在绕不开异步,就设计一个可控的线程池,测试时用直接执行模式,毫秒级完成。
5.4 常见问题速查表
| 现象 | 大概率原因 | 处理建议 |
|---|---|---|
| mockMvc.perform 报 404 | 请求路径写错或 Controller 未注册 | 检查 @RequestMapping 路径,确保思路是配置上下文中确实加载了该 Controller |
| jsonPath 断言找不到节点 | 返回的不是 JSON 或路径写错 | 先用返回的 content 打印出来核对层级 |
| when().thenReturn() 不生效 | 目标方法不是 mock 对象 | 改用 @MockBean 或手动注入 mock |
| 测试数据污染数据库 | 缺少事务回滚 | 测试类加 @Transactional |
| 中文乱码 | 编码不一致 | MockMvc 设置 characterEncoding / 检查 produces 配置 |
| 测试类共享上下文后 Bean 冲突 | 上下文配置不一致 | 统一 @ContextConfiguration 资源 |
| spring-test 版本不一致导致 NoSuchMethodError | 版本错位 | 统一 Spring 家族版本 |
5.5 覆盖率高不代表测试写得好
说起测试,很多团队会拿覆盖率当指标考核。我的看法是:覆盖率能说明代码被测试碰过,但说明不了测得好。我曾经在一个模块里跑出过 90% 的行覆盖率,感觉自己简直太稳了,结果换个同事重构,一跑用例全崩。后来逐行看才发现,测试里大量用了 any() 宽松匹配,业务逻辑里关键的 if/else 分支根本没被真正打到。
写单元测试的重心应该放在关键分支和异常路径上。判断分支有没有测到,可以故意把实现里的某个条件反转,跑一下测试,如果测试照样能过,说明这个分支没测到位。这个技巧不算学术,但非常实用。每次代码评审时我会重点看两个地方:新增的 if/else 有没有对应的测试用例,以及新增的异常抛出点有没有对应的失败用例。这两点达标,即使覆盖率只有 60%,我心里反而比虚高的 90% 更踏实。
6. 一些实际操作中的体会(写在最后的小建议)
用 MockMvc 测 Controller、用 Mockito 测 Service、用内存数据库带事务回滚测 DAO,这是我目前在三层架构项目里最顺手的一套组合拳。分开测最大的好处是排查问题快:接口报错,先跑 Controller 测试看路由和参数解析有没有问题,再跑 Service 测试看业务逻辑有没有问题,哪里挂了修哪里,五分钟内定位,基本不用打断点。
最后分享两个我踩过几次坑后沉淀的习惯。第一个是把测试当代码一样对待,命名要表达意图,不要用 test1、test2 这种名字。testGetUserSuccess、testGetUserInvalidId 这种命名一眼就能看出用例在验证什么,项目换人接手维护成本会低很多。第二个是不要把 Mock 当万能药,能用简单的真实依赖就跑真实依赖,现在很多公司代码里 JPA 或者 MyBatis 的实体映射复杂,过度 Mock 会让测试脱离现实,给出假的绿灯。根据我个人经验,最理想的测试金字塔是:底层 DAO 用真实内存库覆盖核心 SQL,Service 层以 Mock 为主确保业务逻辑,Controller 层重点测 Web 映射参数和异常,三者各有重心,缺哪个都别扭。
如果你现在手头的项目还是那种"测试代码比业务代码还难维护"的状态,不妨从最小的一个简化模块开始,把这套思路先跑通。等尝到快速反馈的甜头,后面自然就停不下来了。