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

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

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

学习

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

更多

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

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/单元测试与 MockMvc
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

单元测试与 MockMvc

@SpringBootTest、@MockBean、接口测试。

35 分钟

单元测试与 MockMvc

这一课你将能做到

  1. 用 Mockito 写出不依赖容器的纯单元测试(Service 层),理解 @Mock 与 @InjectMocks
  2. 用 @WebMvcTest + MockMvc 测试 Controller 层:断言状态码、JSON 字段、异常响应
  3. 区分 @WebMvcTest / @DataJpaTest / @SpringBootTest 三种切片的加载范围与适用场景

为什么要学

改了一行业务代码,接口还能用吗?手工验证要启动应用、登录、点一圈页面——三分钟变半小时,最后干脆不验证,bug 全流到线上。自动化测试把「验证」变成一条命令:mvnw test 跑完所有用例,绿了再提交。

本站后端每次发版前都全量跑测试。对求职来说,单元测试是中级工程师的隐形门槛——简历上「有单元测试习惯」比多背两个框架值钱。好消息:Spring Boot 的测试体验已经打磨得非常好,本课把最常用的三层写法一次配齐。

先建立图像

测试金字塔(越往下越多):

        ▲  少而慢                @SpringBootTest 全上下文(端到端)
       ▲▲
      ▲▲▲   中                @WebMvcTest(Web 层切片)
     ▲▲▲▲                    @DataJpaTest(数据层切片)
    ▲▲▲▲▲  多而快             纯单元测试 Mockito(Service/工具类)
名词人话
单元测试隔离地测一个类,依赖全部用假的(Mock)
集成测试拉起真实环境(容器+数据库),测整体协作
Mock「替身演员」:一个假对象,替真依赖回应调用
切片测试只装配某一层的 @SpringBootTest 精简版,秒级启动

核心心法:Service 测试用 Mock(快、聚焦逻辑),Controller 用 MockMvc(不真起服务器),Repository 用 @DataJpaTest(验证真 SQL)——每层用对工具,测试才快且稳。

跟着做

步骤 1:确认依赖与被测代码

spring-boot-starter-test(Initializr 默认自带)已包含全套工具:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

里面打包了:JUnit 5(测试框架)、Mockito(Mock 引擎)、AssertJ(流式断言)、MockMvc、JSONAssert。被测的 Service(沿用前面的代码):

@Service
public class TaskService {

    private final TaskMapper taskMapper;            // 依赖 Mapper(数据库)

    public TaskService(TaskMapper taskMapper) {
        this.taskMapper = taskMapper;
    }

    public Task rename(long id, String newTitle) {
        Task task = taskMapper.findById(id)
                .orElseThrow(() -> new BusinessException(ErrorCode.TASK_NOT_FOUND));
        taskMapper.updateTitle(id, newTitle);
        return new Task(task.id(), newTitle, task.priority(), task.status(), task.createdAt());
    }
}

步骤 2:Service 层纯单元测试(Mockito)

package com.example.taskflow.service;

import com.example.taskflow.exception.BusinessException;
import com.example.taskflow.exception.ErrorCode;
import com.example.taskflow.mapper.TaskMapper;
import com.example.taskflow.model.Task;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.time.LocalDateTime;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)      // 启用 Mockito 支持
class TaskServiceTest {

    @Mock                                 // 假的 TaskMapper:不会真的连库
    private TaskMapper taskMapper;

    @InjectMocks                          // 把上面的 mock 注入被测对象
    private TaskService taskService;

    @Test
    @DisplayName("改名成功:应更新标题并返回新对象")
    void rename_success() {
        // given:准备数据 + 设定 mock 行为(打桩)
        Task existing = new Task(1L, "旧标题", 2, "TODO", LocalDateTime.now());
        when(taskMapper.findById(1L)).thenReturn(Optional.of(existing));

        // when:执行被测方法
        Task result = taskService.rename(1L, "新标题");

        // then:断言结果 + 验证交互
        assertThat(result.title()).isEqualTo("新标题");
        assertThat(result.id()).isEqualTo(1L);
        verify(taskMapper).updateTitle(1L, "新标题");       // 确认真的调了更新
    }

    @Test
    @DisplayName("任务不存在:应抛出业务异常")
    void rename_taskNotFound() {
        when(taskMapper.findById(anyLong())).thenReturn(Optional.empty());

        assertThatThrownBy(() -> taskService.rename(99L, "任意"))
                .isInstanceOf(BusinessException.class)
                .hasMessage(ErrorCode.TASK_NOT_FOUND.message());

        verify(taskMapper, never()).updateTitle(anyLong(), anyString());  // 不该触发更新
    }
}

三个必须掌握的 API:when(...).thenReturn(...) 打桩(调用时返回假数据)、verify(...) 验证交互(调没调、调几次)、AssertJ 的 assertThat 流式断言。整个测试不启动 Spring、不连数据库,毫秒级完成——所以可以写很多条。

步骤 3:Controller 层切片测试(@WebMvcTest + MockMvc)

package com.example.taskflow.controller;

import com.example.taskflow.exception.BusinessException;
import com.example.taskflow.exception.ErrorCode;
import com.example.taskflow.service.TaskService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;   // Boot 3.4+ 改用 org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(TaskController.class)        // 只装配 Web 层 + 该 Controller
class TaskControllerTest {

    @Autowired
    private MockMvc mockMvc;             // 不起真服务器的「模拟 HTTP 客户端」

    @MockBean                            // 用 mock 替换容器里的 TaskService
    private TaskService taskService;

    @Test
    void detail_shouldReturnTask() throws Exception {
        Task task = new Task(1L, "学习测试", 1, "TODO", LocalDateTime.now());
        when(taskService.requireById(1L)).thenReturn(task);

        mockMvc.perform(get("/api/tasks/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(0))
                .andExpect(jsonPath("$.data.title").value("学习测试"));
    }

    @Test
    void detail_shouldReturnBusinessError_whenNotFound() throws Exception {
        when(taskService.requireById(1L))
                .thenThrow(new BusinessException(ErrorCode.TASK_NOT_FOUND));

        mockMvc.perform(get("/api/tasks/1"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(40401));
    }

    @Test
    void create_shouldValidateAndReject() throws Exception {
        String badBody = "{\"title\":\"\",\"priority\":99}";

        mockMvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(badBody))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(40000));
    }
}

一条用例 = 「构造请求 → perform → 一串 andExpect 断言」。第三个用例顺带验证了第 8 课的全局异常处理与参数校验——接口的「错误契约」第一次被测试钉死。

步骤 4:数据层切片测试(@DataJpaTest)

package com.example.taskflow.repository;

import com.example.taskflow.entity.Task;
import com.example.taskflow.repository.TaskRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest                                   // 内嵌库 + 只装配 JPA 组件;方法结束自动回滚
class TaskRepositoryTest {

    @Autowired
    private TaskRepository taskRepository;

    @Test
    void findByStatus_returnsOnlyMatching() {
        taskRepository.save(new Task("任务A", 1));
        taskRepository.save(new Task("任务B", 2));

        List<Task> all = taskRepository.findAll();
        assertThat(all).hasSize(2);
        assertThat(all).extracting(Task::getTitle)
                .containsExactlyInAnyOrder("任务A", "任务B");
    }
}

@DataJpaTest 默认换上内嵌 H2、每个测试方法结束自动回滚——用例之间零污染,可以放心乱写数据。注意:MyBatis 的 Mapper 不归 JPA 管,测 Mapper 用 @MybatisTest(mybatis-spring-boot-starter-test 提供)或 @SpringBootTest。

步骤 5:全链路集成测试(@SpringBootTest)

package com.example.taskflow;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.*;

import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)  // 起真服务,随机端口
class AuthFlowIntegrationTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate rest;              // 真发 HTTP 请求的客户端

    @Test
    void login_thenAccessProtectedApi() {
        // ① 登录
        ResponseEntity<Map> loginResp = rest.postForEntity(
                "http://localhost:" + port + "/api/auth/login",
                Map.of("username", "alice", "password", "alice123"), Map.class);
        assertThat(loginResp.getStatusCode()).isEqualTo(HttpStatus.OK);

        String token = (String) ((Map<?, ?>) loginResp.getBody().get("data")).get("token");

        // ② 带 token 访问受保护接口
        HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(token);
        ResponseEntity<String> resp = rest.exchange(
                "http://localhost:" + port + "/api/tasks",
                HttpMethod.GET, new HttpEntity<>(headers), String.class);

        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
    }

    @Test
    void accessProtectedApi_withoutToken_shouldBe401() {
        ResponseEntity<String> resp = rest.getForEntity(
                "http://localhost:" + port + "/api/tasks", String.class);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }
}

这条链路验证了第 13 课的 JWT 全流程——上一课手工 curl 做的事,从此固化成代码。集成测试慢,只放关键链路。

步骤 6:跑起来并看结果

mvnw test

输出(节选):

TaskServiceTest > rename_success PASSED
TaskControllerTest > create_shouldValidateAndReject PASSED
AuthFlowIntegrationTest > login_thenAccessProtectedApi PASSED

Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

IDEA 里点类/方法旁的绿色箭头可单独运行;写法约定:类名 XxxTest 放 src/test/java 同包结构下。

讲清楚要点

  1. 测试三层用对工具:Service 纯 Mockito(快、覆盖业务分支)、Controller @WebMvcTest(验证路由/参数/异常契约)、Repository @DataJpaTest(验证真 SQL)、关键链路 @SpringBootTest(端到端兜底)。全部 @SpringBootTest 会让测试套件慢到没人愿意跑。
  2. given-when-then 是可读性的骨架:准备(打桩)→ 执行 → 断言。一个用例只测一个行为;命名讲人话(rename_success / rename_taskNotFound),失败时读名字就知道坏了什么。
  3. Mock 的边界:mock 掉的是「不稳定的慢依赖」(数据库、外部接口),mock 不了逻辑本身——被测方法里的 if/else 才是测试价值所在。别把断言写成「验证 mock 返回了自己打的桩」(无意义的自嗨测试)。
  4. @MockBean 与 @Mock 的区别:@Mock 是 Mockito 局部对象(单元测试);@MockBean 是把 Spring 容器里的 Bean 替换成 mock(切片测试)。Boot 3.4 起 @MockBean 弃用,改用 @MockitoBean(Spring Framework 6.2 提供),写法几乎一致。
  5. 测试是重构的安全网:有了覆盖良好的测试,你才敢大胆改代码。反过来说,难写测试的代码(静态依赖、new 硬编码、巨型方法)通常也是设计有问题的代码——测试反过来推动解耦。

你可能会懵的地方

  • 单元测试和集成测试到底怎么分? 看「真实度」:依赖全是 mock 是单元测试;拉起容器/数据库验证协作是集成测试。工程配比:单元测试多而快(秒级全跑完),集成测试少而准(只测关键链路)。

  • 测试方法要不要加 @Transactional? @DataJpaTest/@SpringBootTest 中给测试方法加 @Transactional 可自动回滚、保护数据库;但注意事务内行为可能与生产不同(如 REQUIRES_NEW 的传播不受影响、JPA 脏检查时机差异)。集成链路测试建议真实提交 + 用唯一数据隔离。

  • 为什么我的 @WebMvcTest 一直报 Bean 找不到? 切片只装 Web 层:Service/Repository 不在容器里。Controller 依赖的 Bean 一律 @MockBean(或新 @MockitoBean)补齐;自定义的过滤器、Advice 需要显式 import(@Import({JwtAuthFilter.class, GlobalExceptionHandler.class}))。

  • 覆盖率多少才够? 工具只是参考(JaCoCo),别追百分比。原则:核心业务分支全覆盖、修复过的 bug 必须留下回归用例、边界值(空/最大/非法)必测。80% 全绿但核心分支漏测,不如 60% 覆盖但全在刀刃上。

  • 测试用数据库怎么隔离? 三板斧:@DataJpaTest 自动回滚;@ActiveProfiles("test") 指向独立测试库;集成测试用 Testcontainers 拉一次性 Docker 数据库(进阶)。本站 CI 里跑的就是容器化测试库。

错法对照

错法问题正解
所有测试都用 @SpringBootTest套件慢到没人跑分层切片:单元/切片/集成各司其职
断言只写 assertThat(x).isNotNull()什么都测不出来断言具体字段值与交互行为
Mock 对象后再 mock 它的方法返回它自己自嗨测试,无验证价值断言业务结果,桩数据与断言呼应
测试类不写注释、方法名 test1/test2失败时无法定位@DisplayName + 行为命名
用例之间共享可变状态(静态字段存数据)用例顺序依赖,偶发失败每个用例独立准备数据

卡点急救

  1. Failed to load ApplicationContext:切片测试报缺 Bean → 补 @MockBean/@Import;@SpringBootTest 报错 → 多半是主配置问题(数据源连不上、安全配置循环依赖),先让应用能正常启动。
  2. MockMvc 404:URL 与 Controller 映射不一致(含前缀);确认 @WebMvcTest 只导入了目标 Controller,别的 Controller 的路径当然 404。
  3. JSON 断言失败:先打印实际响应(.andDo(print()))对照 $.data.title 路径——统一响应结构下多了一层 data 是最常见原因。
  4. 测试间数据互相污染:确认 @DataJpaTest 自动回滚生效(别在测试里手动开新事务提交);集成测试改用唯一测试数据(UUID 后缀)+ @AfterAll 清理。

面试常问

  • Spring Boot 测试切片注解的区别? @SpringBootTest 全量上下文(可配 RANDOM_PORT 起 web);@WebMvcTest 只装配 Spring MVC 组件;@DataJpaTest 只装配 JPA/数据源并自动回滚;@JsonTest 只测序列化。范围越小启动越快、隔离越清晰。

  • Mock 和 Stub 的区别?Mockito 的 verify 什么时候用? Stub 定「输入 → 假输出」(when/thenReturn);Mock 关注「交互是否发生」(verify 调用次数/参数)。有返回依赖用 stub,副作用类操作(发通知、写库)用 verify。

  • 怎么保证测试不受执行顺序影响? 用例间不共享可变状态;数据准备各自独立;依赖自动回滚或唯一标识隔离;JUnit 默认方法顺序不确定,不要依赖它。

  • 写单测遇到 private 方法/静态依赖怎么处理? private 方法通过 public 入口间接覆盖(它不是独立单元);静态依赖(如时间)抽成可注入的 Clock/接口;「测不了」通常是设计信号,优先重构而不是用 PowerMock 之类的重型工具硬测。

自测三问

  1. @Mock 和 @MockBean(3.4+ 为 @MockitoBean)分别用在什么场景?
  2. 一条 MockMvc 用例的三段式结构是什么?jsonPath 如何匹配统一响应里的 data 字段?
  3. 为什么 Service 层测试不该用 @SpringBootTest?

小结一句话

测试金字塔 + 切片注解让每层代码都有对口的验证工具——Service 走 Mockito、Controller 走 MockMvc、数据走 @DataJpaTest,mvnw test 一条命令守住重构底线。

延伸

  • Spring Boot 官方文档:Testing
  • Mockito 官方文档
  • 下一课:Actuator 与健康检查——让应用学会「汇报自己的身体状况」。
加载本节练习…
← 上一课JWT 与 API 鉴权下一课 →Actuator 与健康检查