MockMvc 接口测试
这一课你将能做到
- 用
MockMvc.perform 发送 GET/POST,断言状态码与 jsonPath 字段,把接口契约测到字段级
- 为判题接口「提交成功 / 非法语言 / 未登录」各写一条可读的用例,并验证统一错误体形状
- 说清 MockMvc、纯 Mockito 单测、E2E 三者的分工边界
为什么要学
接口契约是前后端的合同:状态码、JSON 字段名、错误体形状。platform(Next.js)正是按这份合同写解析代码的——backend 悄悄把 message 改成 msg,前端整片红色报错。人话:MockMvc 是不启真实端口、不启浏览器的 HTTP 模拟器——请求打进 DispatcherServlet,断言响应长什么样。
它比 Playwright 快一个数量级,比纯 Service 单测更接近「前端实际会收到什么」。本站 GlobalExceptionHandler 把 BusinessException 按状态码翻译、未登录翻成 401——这些「HTTP 外观」逻辑只有 MockMvc 这一层能守护。Node 侧的近亲是 supertest(见 node-test),思想一致:对 HTTP 层做黑盒断言。
先建立图像
browser / curl ──HTTP──► Tomcat 真实端口 ──► Controller
MockMvc ──模拟HTTP──► DispatcherServlet ──► Controller
▲
不占 8080,不启网络栈
配合 @MockitoBean Service + mockStatic(StpUtil)
→ 只验证「Web 层翻译」:路由、校验、序列化、异常→状态码
| 场景 | 工具 |
|---|
| Service 业务分支 | JUnit + Mockito(第 3 课) |
| HTTP 契约、状态码、JSON | MockMvc(本课) |
| 真 DB + 真 SQL | @SpringBootTest + Testcontainers(第 6 课) |
| 真实浏览器路径 | Playwright(第 9 课) |
跟着做
步骤 1:判题接口的 happy path(POST + jsonPath)
沿用上一课的切片骨架:
@WebMvcTest(SubmissionController.class)
class SubmissionContractTest {
@Autowired MockMvc mockMvc;
@MockitoBean SubmissionService submissionService;
@Test
void 提交成功返回200与记录字段() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong).thenReturn(3L);
Submission s = new Submission();
s.setId(10L);
s.setUserId(3L);
s.setLessonId(5L);
s.setLanguage("java");
s.setCode("class A {}");
s.setStatus("pending");
when(submissionService.submit(3L, 5L, "java", "class A {}"))
.thenReturn(s);
mockMvc.perform(post("/submissions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lessonId":5,"language":"java","code":"class A {}"}
"""))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(10))
.andExpect(jsonPath("$.status").value("pending"))
.andExpect(jsonPath("$.userId").value(3));
}
}
}
应看到:200 + 字段级断言全绿。若 404 且 stub 已写,先查映射路径与 server.servlet.context-path。
步骤 2:业务异常 → 状态码与错误体(验证统一契约)
本站 BusinessException 默认映射 400,BusinessException.notFound 映射 404,错误体统一是 {"message": "..."}。契约测要锁住这套翻译:
@Test
void 非法语言返回400与统一错误体() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong).thenReturn(3L);
when(submissionService.submit(eq(3L), eq(5L), eq("brainfuck"), anyString()))
.thenThrow(new BusinessException("不支持的语言类型"));
mockMvc.perform(post("/submissions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lessonId":5,"language":"brainfuck","code":"x"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("不支持的语言类型"));
}
}
@Test
void 不存在的课时资源返回404() throws Exception {
when(submissionService.submit(any(), any(), any(), any()))
.thenThrow(BusinessException.notFound("课时不存在"));
mockMvc.perform(post("/submissions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lessonId":999,"language":"java","code":"x"}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("课时不存在"));
}
只测 200 不测 400/404,等于契约只守了一半——前端最需要的就是这份错误体。
步骤 3:未登录场景——让静态替身抛出真实异常
StpUtil.getLoginIdAsLong() 未登录时抛 sa-token 的 NotLoginException,被 GlobalExceptionHandler 翻成 401。切片里用静态替身重演这条路径:
@Test
void 未登录提交返回401() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong)
.thenThrow(new NotLoginException("token 无效", null, NotLoginException.NOT_TOKEN));
mockMvc.perform(post("/submissions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lessonId":5,"language":"java","code":"x"}
"""))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").value("未登录或登录已过期"));
}
}
(NotLoginException 构造参数随 sa-token 版本略有差异,以依赖版本源码为准;要点是「静态替身抛真异常 → 断言 401 契约」。)
步骤 4:列表接口与数组断言
@Test
void 提交列表返回数组() throws Exception {
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
stp.when(StpUtil::getLoginIdAsLong).thenReturn(3L);
Submission s = new Submission();
s.setId(10L);
s.setStatus("pending");
when(submissionService.listByUserAndLesson(3L, 5L)).thenReturn(List.of(s));
mockMvc.perform(get("/submissions").queryParam("lessonId", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].status").value("pending"));
}
}
hasSize 来自 Hamcrest(org.hamcrest.Matchers,starter-test 自带)。关键是断言关键字段,别整串比对 JSON 字符串。
步骤 5:调试手段与定向运行
mockMvc.perform(get("/submissions")).andDo(print());
cd backend
mvn test -Dtest=SubmissionContractTest
mvn -B verify # 提交前对齐 CI
讲清楚要点
- 契约测的是「HTTP 外观」:状态码 + 关键字段优先于全量 JSON 字符串——字段顺序、新增字段不该绑死测试。
- Service 已单测,所以 MockMvc 用例应薄:stub 返回固定值,重点在映射、序列化与异常翻译,不重复断言业务 if。
- 400 / 401 / 404 必须有代表例:错误体形状是前端解析的依据,
$.message 一变就是线上事故。
- POST 必须声明
contentType(APPLICATION_JSON):否则请求体读不到或 415,先排除这个低级变量再排查业务。
- 静态登录态用 mockStatic 重演:切片里 sa-token 没有 Web 上下文,替身是唯一稳定入口;
andDo(print()) 是最快的调试望远镜。
- 字段名变更时测试应红:这是特性——它逼你同步契约,而不是让前端在运行时踩坑。
你可能会懵的地方
-
MockMvc 和 @SpringBootTest(RANDOM_PORT) 差在哪?
MockMvc 不经真实网络栈(无 Tomcat、无端口),快且隔离;RANDOM_PORT 走完整 HTTP,能测编码、连接、过滤器链。日常契约测用 MockMvc,网络层行为再升级。
-
jsonPath("$.slug") 失败但 body 里明明有?
可能响应包了一层 data,或返回的是数组却用了对象路径。先 andDo(print()) 看真实结构,再改路径。
-
必须 @SpringBootTest 吗?
不必须。@WebMvcTest 往往更轻更快;只有需要真实过滤器链(如本站 CookieAuthFilter、SecurityHeadersFilter)或更多自定义配置时才升全上下文。
-
和 E2E 会不会重复?
不重复:MockMvc 不测前端路由、水合、真实跨端延迟。E2E 少而稳(第 9 课),MockMvc 多而快——「提交判题返回 pending」在 MockMvc 层锁死,E2E 只确认页面上徽章出现。
-
为什么登录态不用真调 login 接口?
那会把 CaptchaService、Redis、sa-token 全拖进来,切片退化成集成测。契约测关心「带登录 ID 的请求行为」,mockStatic 直接给出登录 ID 即可。
错法对照
| 错法 | 问题 | 正解 |
|---|
只 isOk() 不断言字段 | 改错字段名仍绿 | jsonPath 锁关键字段 |
| 整段 JSON 字符串相等 | 易碎、难维护 | 字段级断言 |
| 只测成功路径 | 契约一半没测 | 补 400 / 401 / 404 代表例 |
| POST 不设 Content-Type | 415 / 体读不到 | 显式 APPLICATION_JSON |
| 用 MockMvc 代替所有 Service 测 | 慢且难定位分支 | 分层:单测厚、契约测薄 |
卡点急救
- 请求 415 Unsupported Media Type:POST 少了
contentType(MediaType.APPLICATION_JSON);检查 content() 里的 JSON 是否合法(text block 引号别打歪)。
- 响应体和 jsonPath 对不上:先
andDo(print());常见坑是错误体是 {"message": ...} 而你断言了 $.error,或数组/对象路径混用。
- 始终 401 且换不掉:确认 mockStatic 包住了整个 perform 调用;若升到全上下文,本站
CookieAuthFilter 会在真过滤器链里拦请求——切片测试优先,或按过滤器语义构造 Cookie。
面试常问
-
MockMvc 的工作原理是什么?
它不启动 Servlet 容器,而是用 MockHttpServletRequest/Response 模拟请求,直接驱动 DispatcherServlet 走完整处理链(路由、参数绑定、校验、异常解析、序列化),拿到 MockHttpServletResponse 断言。所以它比真端口快一个量级,但不覆盖网络层行为。
-
为什么接口测试要断言错误响应,而不只测 200?
前端按错误体写解析逻辑,{"message": ...} 的形状是契约的一部分。统一异常翻译(本站 GlobalExceptionHandler)一旦回归,表现是全站错误提示失灵——只有错误路径用例能提前抓住。
-
契约测试在你们项目里怎么落地?
以 CodeForge 判题接口为例:SubmissionContractTest 用 @WebMvcTest + @MockitoBean 锁「200 返回 pending 记录 / 400 统一错误体 / 401 未登录」三条代表例;业务分支下沉到 Mockito 单测;浏览器路径只留一条 E2E 冒烟。
自测三问
- 为什么 MockMvc「不占端口」也能测到 Controller?
- 为
POST /submissions 写三条用例,分别断言什么?
- 整段比对 JSON 与 jsonPath 字段断言,哪个适合长期维护?为什么?
小结一句话
MockMvc = 不启服务器的 HTTP 黑盒;配合 @MockitoBean 与 mockStatic,把状态码、字段与错误体契约锁到字段级。
延伸