MockMvc测试
本章聚焦 Spring MVC 无需启动 Servlet 容器的测试机制。
MockMvc模拟完整的 HTTP 请求处理链路——从 DispatcherServlet 接收请求、HandlerMapping 查找处理器、Controller 执行业务逻辑、到最终生成响应——全部在 JVM 内完成。它是单元测试与集成测试 Controller 层的标准工具,也是 985 高校学生必须掌握的工程实践能力。
定义与作用
MockMvc 是 Spring Test 模块提供的核心测试类,它构建了一个模拟的 Spring MVC 运行时环境:
- 真实的
DispatcherServlet被实例化并初始化 - 真实的 Controller、拦截器、异常处理器全部参与请求处理
- 但底层没有真正的 HTTP 网络层和 Servlet 容器(如 Tomcat)
这意味着测试运行极快(毫秒级),且可以精确验证:请求映射是否正确、参数绑定是否成功、响应状态码/内容是否符合预期、JSON 结构是否正确。
生活类比:飞行模拟器
想象飞翔科技培训飞行员:
- 启动真实 Tomcat 测试:每次训练都开一架真飞机上天,成本高、风险大、准备时间长
- MockMvc 测试:在地面飞行模拟器里训练。驾驶舱、仪表盘、操纵杆都是真实的(真实的 DispatcherServlet、Controller),但飞机没有真的离开地面(没有网络层和 Tomcat)。学员可以反复练习起降、应对各种故障,安全且高效
核心原理
MockMvc 测试架构
关键特征:
- 真实组件:Controller、Service(如果注入)、拦截器、异常处理器都是真实的 Spring Bean
- 模拟请求:
MockHttpServletRequest模拟 HTTP 请求,支持设置 URL、Method、Header、Body、Cookie 等 - 模拟响应:
MockHttpServletResponse捕获生成的响应,包括状态码、Header、Body - 无网络层:请求不经过 TCP/IP、不经过 Tomcat 线程池,直接在 JVM 内方法调用
适用位置与常用属性
测试类注解
| 注解 | 作用 | 说明 |
|---|---|---|
@SpringBootTest | 加载完整 Spring 应用上下文 | 用于集成测试,Controller、Service、Repository 全部真实 |
@AutoConfigureMockMvc | 自动配置 MockMvc 实例 | 与 @SpringBootTest 配合使用,自动注入 MockMvc |
@WebMvcTest(Controller.class) | 只加载 Web 层 | 轻量级,只实例化指定的 Controller 和 MVC 基础设施,不加载 Service/Repository |
MockMvc 常用 API
| 类/方法 | 作用 |
|---|---|
MockMvcRequestBuilders.get("/url") | 构造 GET 请求 |
MockMvcRequestBuilders.post("/url") | 构造 POST 请求 |
MockMvcRequestBuilders.put("/url") | 构造 PUT 请求 |
MockMvcRequestBuilders.delete("/url") | 构造 DELETE 请求 |
MockMvcResultMatchers.status().isOk() | 断言状态码 200 |
MockMvcResultMatchers.status().isCreated() | 断言状态码 201 |
MockMvcResultMatchers.status().isBadRequest() | 断言状态码 400 |
MockMvcResultMatchers.content().string(...) | 断言响应体字符串 |
MockMvcResultMatchers.content().json(...) | 断言响应体 JSON |
MockMvcResultMatchers.jsonPath("$.name", is("张三")) | 断言 JSON 字段值 |
MockMvcResultMatchers.header().string("Location", ...) | 断言响应头 |
完整示例
场景
飞翔科技员工管理系统的"新增员工"和"查询员工"接口需要自动化测试。CTO 大翔要求核心接口必须有测试覆盖,架构师白歌使用 MockMvc 编写无需启动 Tomcat 的快速测试,运维李眉将测试集成到 CI 流水线。
被测试的 Controller
package com.feixiang.web.controller;
import com.feixiang.web.entity.EmployeeForm;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.Map;
@RestController
@RequestMapping("/employees")
public class EmployeeController {
@GetMapping("/{id}")
public Object getEmployee(@PathVariable Long id) {
// 模拟查询
return Map.of(
"id", id,
"name", "张三",
"department", "研发部",
"age", 25
);
}
@PostMapping
public Object createEmployee(@Valid @RequestBody EmployeeForm form) {
return Map.of(
"id", 1001,
"name", form.getName(),
"message", "创建成功"
);
}
}
MockMvc 测试类
package com.feixiang.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.feixiang.web.entity.EmployeeForm;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;
@SpringBootTest
@AutoConfigureMockMvc
public class EmployeeControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void testGetEmployee() throws Exception {
mockMvc.perform(get("/employees/1")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.department").value("研发部"))
.andExpect(jsonPath("$.age").value(25));
}
@Test
void testCreateEmployeeSuccess() throws Exception {
EmployeeForm form = new EmployeeForm();
form.setName("李四");
form.setAge(30);
form.setEmail("lisi@feixiang.com");
form.setPhone("13800138001");
String json = objectMapper.writeValueAsString(form);
mockMvc.perform(post("/employees")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1001))
.andExpect(jsonPath("$.name").value("李四"))
.andExpect(jsonPath("$.message").value("创建成功"));
}
@Test
void testCreateEmployeeValidationFail() throws Exception {
EmployeeForm form = new EmployeeForm();
form.setName(""); // @NotBlank 会失败
form.setAge(16); // @Min(18) 会失败
form.setEmail("bad"); // @Email 会失败
String json = objectMapper.writeValueAsString(form);
mockMvc.perform(post("/employees")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest()); // 400
}
}
GET 测试详解
@Test
void testGetEmployeeWithHeaders() throws Exception {
mockMvc.perform(get("/employees/1")
.header("X-Request-Id", "req-2024001")
.param("detail", "full")
.accept(MediaType.APPLICATION_JSON))
.andDo(result -> {
// 打印请求和响应详情,用于调试
System.out.println("Response: " + result.getResponse().getContentAsString());
})
.andExpect(status().isOk())
.andExpect(header().exists("Content-Type"))
.andExpect(jsonPath("$", hasKey("id")))
.andExpect(jsonPath("$", hasKey("name")))
.andExpect(jsonPath("$.age", greaterThanOrEqualTo(18)));
}
POST 测试详解(含重定向验证)
@Test
void testCreateWithRedirect() throws Exception {
// 假设 Controller 返回 redirect:/employees/{id}
mockMvc.perform(post("/employees")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"王五\",\"age\":28,\"email\":\"wangwu@feixiang.com\",\"phone\":\"13900139000\"}"))
.andExpect(status().isFound()) // 302 重定向
.andExpect(header().string("Location", containsString("/employees/")));
}
易错场景与面试考点
误区一:@WebMvcTest 和 @SpringBootTest 混用
错误代码:
@SpringBootTest
@WebMvcTest(EmployeeController.class) // ❌ 冲突!
@AutoConfigureMockMvc
public class EmployeeControllerTest {
问题:@WebMvcTest 已经包含了 MockMvc 的自动配置,且会切片加载上下文;与 @SpringBootTest 同时使用时,Spring Test 上下文加载会冲突或行为未定义。
纠正:二选一:
- 需要测试完整链路(含 Service、Repository)→ 用
@SpringBootTest + @AutoConfigureMockMvc - 只测试 Web 层(Controller 隔离测试)→ 用
@WebMvcTest
误区二:忘记设置 contentType
错误代码:
mockMvc.perform(post("/employees")
.content(json)) // ❌ 没有设置 contentType
问题:Spring MVC 的 @RequestBody 依赖 Content-Type 头来选择 HttpMessageConverter。如果不设置 contentType(MediaType.APPLICATION_JSON),Spring MVC 可能无法正确解析 JSON,导致参数绑定失败或返回 415 Unsupported Media Type。
纠正:
mockMvc.perform(post("/employees")
.contentType(MediaType.APPLICATION_JSON) // ✅ 必须设置
.content(json))
误区三:jsonPath 断言失败时信息不清晰
问题:jsonPath("$.name").value("张三") 失败时,错误信息可能难以阅读。
最佳实践:使用 Hamcrest 匹配器增强可读性:
.andExpect(jsonPath("$.name", is("张三")))
.andExpect(jsonPath("$.age", greaterThan(18)))
.andExpect(jsonPath("$.department", containsString("研发")))
.andExpect(jsonPath("$.tags", hasSize(3)))
面试高频:MockMvc 是单元测试还是集成测试?
标准回答:MockMvc 本身是一个集成测试工具,因为它加载了真实的 Spring MVC 组件(DispatcherServlet、HandlerMapping、Controller)。但它比完整的端到端测试更轻量,因为不涉及真实的 Servlet 容器和网络层。按照测试金字塔:
- 单元测试:只测 Controller 方法本身,Mock 掉 Service(使用 Mockito)
- MockMvc 测试:测 Controller + Spring MVC 基础设施,Service 可以是真实的或 Mock 的
- 端到端测试:启动真实 Tomcat,通过 HTTP 客户端调用
MockMvc 位于单元测试和端到端测试之间,常被称为切片测试或轻量级集成测试。
面试高频:@WebMvcTest 和 @SpringBootTest 的区别
| 维度 | @WebMvcTest | @SpringBootTest |
|---|---|---|
| 加载范围 | 只加载 MVC 层(Controller、Filter、WebMvcConfigurer) | 加载完整 Spring 上下文 |
| Service/Repository | ❌ 不加载 | ✅ 加载 |
| MockMvc | ✅ 自动可用 | 需要加 @AutoConfigureMockMvc |
| 启动速度 | 快 | 慢 |
| 适用场景 | Controller 隔离测试 | 端到端 Web 测试 |
小结
MockMvc 是 Spring Test 提供的模拟 MVC 运行时测试工具。它在不启动 Servlet 容器的情况下,通过真实的 DispatcherServlet 处理模拟的 HTTP 请求,验证 Controller 的请求映射、参数绑定、响应生成是否符合预期。配合 jsonPath 断言可以精确校验 JSON 响应结构,是 Spring MVC 项目自动化测试的核心工具。
本章与全局的关系:本章讲解了 Spring MVC 层的测试方法。MockMvc 可以测试本教程中讲解的所有注解和机制——@RequestMapping、参数绑定、数据校验、RedirectAttributes、异常处理等。掌握 MockMvc 是保障 Spring MVC 项目质量的关键工程能力。