Spring MVC 测试实战——MockMvc 深度使用、完整请求响应验证
Spring MVC 测试实战——MockMvc 深度使用、完整请求响应验证
适读人群:用 MockMvc 测试 Controller 但写得很基础、不知道如何验证复杂响应的工程师 | 阅读时长:约13分钟 | 核心价值:系统掌握 MockMvc 的完整用法,让 Controller 测试覆盖所有关键场景
那个让接口文档和实际行为不一致的问题
我曾参与过一个 API 项目,出了一个很经典的 bug:接口文档里说 POST /orders 失败时返回 {"code": "INVALID_REQUEST", "message": "..."},但代码里实际上返回的是 {"error": "invalid_request", "msg": "..."}。字段名不同、枚举值大小写不一致。
前端对接的时候直接懵了,花了半天排查才发现。
追根溯源:Controller 层根本没有测试。Service 层有测试,数据层有测试,但最关键的"请求进来→响应出去"这个过程,完全靠手工测试,没有自动化覆盖。
MockMvc 就是解决这个问题的:在不启动真实 HTTP 服务的情况下,完整测试 Controller 的行为,包括请求处理、参数校验、响应格式、HTTP 状态码。
MockMvc 的两种初始化方式
方式一:独立(Standalone)模式
只加载指定的 Controller,不启动 Spring 上下文,最轻量:
class UserControllerStandaloneTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
UserService mockUserService = mock(UserService.class);
UserController userController = new UserController(mockUserService);
mockMvc = MockMvcBuilders.standaloneSetup(userController)
.setControllerAdvice(new GlobalExceptionHandler()) // 注册全局异常处理
.addFilters(new RequestLoggingFilter()) // 注册 Filter
.build();
}
}方式二:WebApplicationContext 模式(配合 @WebMvcTest)
@WebMvcTest(UserController.class)
class UserControllerWebMvcTest {
@Autowired
private MockMvc mockMvc; // Spring 自动配置
@MockBean
private UserService userService;
@MockBean
private TokenValidator tokenValidator;
}推荐用 @WebMvcTest,因为会自动加载完整的 MVC 配置(消息转换器、拦截器、异常处理器等),更接近真实环境。
完整的 GET 请求测试
@Test
void testGetUserById_success() throws Exception {
// Arrange
UserVO user = UserVO.builder()
.id(1L)
.username("zhangsan")
.email("zhangsan@test.com")
.memberLevel("VIP")
.createTime("2024-01-15T10:00:00")
.build();
when(userService.getUser(1L)).thenReturn(Optional.of(user));
// Act & Assert
mockMvc.perform(get("/api/v1/users/1")
.header("Authorization", "Bearer valid-token")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// 验证响应头
.andExpect(header().string("Content-Type", containsString("application/json")))
// 验证根字段
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.username").value("zhangsan"))
.andExpect(jsonPath("$.email").value("zhangsan@test.com"))
.andExpect(jsonPath("$.memberLevel").value("VIP"))
// 验证日期格式
.andExpect(jsonPath("$.createTime").value("2024-01-15T10:00:00"))
// 验证某个字段存在
.andExpect(jsonPath("$.id").exists())
// 验证某个字段不存在(密码不应该返回)
.andExpect(jsonPath("$.password").doesNotExist())
// 打印请求响应(调试用,上线后可以去掉)
.andDo(print());
}
@Test
void testGetUserById_notFound() throws Exception {
when(userService.getUser(999L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/v1/users/999")
.header("Authorization", "Bearer valid-token"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("USER_NOT_FOUND"))
.andExpect(jsonPath("$.message").value(containsString("999")));
}完整的 POST 请求测试
@Autowired
private ObjectMapper objectMapper;
@Test
void testCreateUser_success() throws Exception {
CreateUserRequest request = CreateUserRequest.builder()
.username("newuser")
.email("newuser@test.com")
.password("Password123!")
.build();
UserVO createdUser = UserVO.builder()
.id(100L)
.username("newuser")
.email("newuser@test.com")
.build();
when(userService.createUser(any(CreateUserRequest.class))).thenReturn(createdUser);
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated()) // 201 Created
.andExpect(header().exists("Location")) // 返回资源 URL
.andExpect(header().string("Location", containsString("/api/v1/users/100")))
.andExpect(jsonPath("$.id").value(100))
.andExpect(jsonPath("$.username").value("newuser"));
}
@Test
void testCreateUser_invalidEmail() throws Exception {
CreateUserRequest request = CreateUserRequest.builder()
.username("newuser")
.email("not-an-email") // 非法邮箱
.password("Password123!")
.build();
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[0].field").value("email"))
.andExpect(jsonPath("$.errors[0].message").value(containsString("邮箱")));
}
@Test
void testCreateUser_missingRequiredFields() throws Exception {
// 空请求体
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors.length()").value(greaterThanOrEqualTo(3)));
}验证列表响应
@Test
void testListUsers_withPagination() throws Exception {
List<UserVO> users = List.of(
UserVO.builder().id(1L).username("user1").build(),
UserVO.builder().id(2L).username("user2").build(),
UserVO.builder().id(3L).username("user3").build()
);
PageResult<UserVO> pageResult = PageResult.of(users, 3, 1, 10);
when(userService.listUsers(any(UserQueryRequest.class))).thenReturn(pageResult);
mockMvc.perform(get("/api/v1/users")
.param("page", "1")
.param("size", "10")
.param("keyword", "user"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(3))
.andExpect(jsonPath("$.page").value(1))
.andExpect(jsonPath("$.size").value(10))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[0].id").value(1))
.andExpect(jsonPath("$.data[0].username").value("user1"))
.andExpect(jsonPath("$.data[2].id").value(3));
}文件上传测试
@Test
void testUploadAvatar_success() throws Exception {
MockMultipartFile avatarFile = new MockMultipartFile(
"file", // 参数名
"avatar.jpg", // 原始文件名
MediaType.IMAGE_JPEG_VALUE,
"fake-image-content".getBytes()
);
String uploadedUrl = "https://cdn.example.com/avatars/user1.jpg";
when(userService.uploadAvatar(eq(1L), any())).thenReturn(uploadedUrl);
mockMvc.perform(multipart("/api/v1/users/1/avatar")
.file(avatarFile)
.header("Authorization", "Bearer valid-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.avatarUrl").value(uploadedUrl));
}
@Test
void testUploadAvatar_fileTooLarge() throws Exception {
byte[] largeContent = new byte[10 * 1024 * 1024 + 1]; // 超过 10MB
MockMultipartFile largeFile = new MockMultipartFile(
"file", "large.jpg", MediaType.IMAGE_JPEG_VALUE, largeContent
);
mockMvc.perform(multipart("/api/v1/users/1/avatar").file(largeFile))
.andExpect(status().isPayloadTooLarge()) // 413
.andExpect(jsonPath("$.code").value("FILE_TOO_LARGE"));
}踩坑实录三则
踩坑一:jsonPath 断言 Long 类型时,数字比较失败
现象:
.andExpect(jsonPath("$.id").value(1L)) // 失败!原因:JSON 里的数字默认被解析为 Integer,而 1L 是 Long 类型,类型不匹配。
解法:
// 方式1:用 int 值
.andExpect(jsonPath("$.id").value(1))
// 方式2:用字符串转换
.andExpect(jsonPath("$.id").value(is(1)))
// 方式3:明确类型转换
.andExpect(jsonPath("$.id", is(1)))踩坑二:@WebMvcTest 里 Spring Security 的自动配置导致所有接口 401
现象:@WebMvcTest 测试里,所有 mockMvc.perform(...) 都返回 401。
原因:@WebMvcTest 会自动加载 SecurityAutoConfiguration,启用 Spring Security 的默认配置(HTTP Basic 认证),所有未认证请求都被拦截。
解法:
@WebMvcTest(value = UserController.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
class UserControllerTest { ... }
// 或者
@WebMvcTest(UserController.class)
@WithMockUser(username = "testuser", roles = {"USER"})
class UserControllerTest { ... }踩坑三:andDo(print()) 输出了请求体但响应体是空的
现象:调试时 andDo(print()) 输出的响应体是空的,但实际接口调用有内容。
原因:响应体的流已经被之前的 andExpect 消费了,print() 拿不到了。
解法:把 andDo(print()) 放在所有 andExpect 之前:
mockMvc.perform(get("/api/users/1"))
.andDo(print()) // 放最前面
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1));结果验证辅助工具
// 使用 ResultActions 保存结果,做多次验证
ResultActions result = mockMvc.perform(get("/api/users/1"));
MvcResult mvcResult = result
.andExpect(status().isOk())
.andReturn();
// 从 MvcResult 里提取响应体做更复杂的验证
String responseBody = mvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8);
UserVO user = objectMapper.readValue(responseBody, UserVO.class);
assertThat(user.getCreateTime()).isBefore(LocalDateTime.now());
assertThat(user.getUsername()).matches("[a-z_]+");MockMvc 是测试 Controller 最高效的工具,配合 AssertJ 和 JSONPath,可以精确验证每一个响应字段。如果你的 Controller 层还没有自动化测试,从这里开始是最容易上手的。
