CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
软件测试入门/课时/MockMvc 接口测试
RawBlame
课时目录12 节

软件测试入门

  1. 01测试金字塔
  2. 02JUnit 5 基础
  3. 03Mockito 与 Mock
  4. 04Spring Boot 测试
  5. 05MockMvc 接口测试
  6. 06集成测试
  7. 07TDD 与可维护性
  8. 08前端单元测试
  9. 09E2E 与 Playwright
  10. 10覆盖率与质量门禁
  11. 11CI 中的测试
  12. 12综合练习

MockMvc 接口测试

模拟 HTTP、JSON 断言、与 sb-test 对照。

35 分钟

MockMvc 接口测试

这一课你将能做到

  1. 用 MockMvc.perform 发送 GET/POST,断言状态码与 jsonPath 字段,把接口契约测到字段级
  2. 为判题接口「提交成功 / 非法语言 / 未登录」各写一条可读的用例,并验证统一错误体形状
  3. 说清 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 契约、状态码、JSONMockMvc(本课)
真 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

讲清楚要点

  1. 契约测的是「HTTP 外观」:状态码 + 关键字段优先于全量 JSON 字符串——字段顺序、新增字段不该绑死测试。
  2. Service 已单测,所以 MockMvc 用例应薄:stub 返回固定值,重点在映射、序列化与异常翻译,不重复断言业务 if。
  3. 400 / 401 / 404 必须有代表例:错误体形状是前端解析的依据,$.message 一变就是线上事故。
  4. POST 必须声明 contentType(APPLICATION_JSON):否则请求体读不到或 415,先排除这个低级变量再排查业务。
  5. 静态登录态用 mockStatic 重演:切片里 sa-token 没有 Web 上下文,替身是唯一稳定入口;andDo(print()) 是最快的调试望远镜。
  6. 字段名变更时测试应红:这是特性——它逼你同步契约,而不是让前端在运行时踩坑。

你可能会懵的地方

  • 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-Type415 / 体读不到显式 APPLICATION_JSON
用 MockMvc 代替所有 Service 测慢且难定位分支分层:单测厚、契约测薄

卡点急救

  1. 请求 415 Unsupported Media Type:POST 少了 contentType(MediaType.APPLICATION_JSON);检查 content() 里的 JSON 是否合法(text block 引号别打歪)。
  2. 响应体和 jsonPath 对不上:先 andDo(print());常见坑是错误体是 {"message": ...} 而你断言了 $.error,或数组/对象路径混用。
  3. 始终 401 且换不掉:确认 mockStatic 包住了整个 perform 调用;若升到全上下文,本站 CookieAuthFilter 会在真过滤器链里拦请求——切片测试优先,或按过滤器语义构造 Cookie。

面试常问

  • MockMvc 的工作原理是什么? 它不启动 Servlet 容器,而是用 MockHttpServletRequest/Response 模拟请求,直接驱动 DispatcherServlet 走完整处理链(路由、参数绑定、校验、异常解析、序列化),拿到 MockHttpServletResponse 断言。所以它比真端口快一个量级,但不覆盖网络层行为。

  • 为什么接口测试要断言错误响应,而不只测 200? 前端按错误体写解析逻辑,{"message": ...} 的形状是契约的一部分。统一异常翻译(本站 GlobalExceptionHandler)一旦回归,表现是全站错误提示失灵——只有错误路径用例能提前抓住。

  • 契约测试在你们项目里怎么落地? 以 CodeForge 判题接口为例:SubmissionContractTest 用 @WebMvcTest + @MockitoBean 锁「200 返回 pending 记录 / 400 统一错误体 / 401 未登录」三条代表例;业务分支下沉到 Mockito 单测;浏览器路径只留一条 E2E 冒烟。

自测三问

  1. 为什么 MockMvc「不占端口」也能测到 Controller?
  2. 为 POST /submissions 写三条用例,分别断言什么?
  3. 整段比对 JSON 与 jsonPath 字段断言,哪个适合长期维护?为什么?

小结一句话

MockMvc = 不启服务器的 HTTP 黑盒;配合 @MockitoBean 与 mockStatic,把状态码、字段与错误体契约锁到字段级。

延伸

  • Spring · MockMvc 测试框架
  • JsonPath 语法
  • 下一课:test-integration 走出 mock 的保护伞,用 Testcontainers 让 SQL 与事务接受真实检验。
加载本节练习…
← 上一课Spring Boot 测试下一课 →集成测试