单元测试与 MockMvc
这一课你将能做到
- 用 Mockito 写出不依赖容器的纯单元测试(Service 层),理解 @Mock 与 @InjectMocks
- 用 @WebMvcTest + MockMvc 测试 Controller 层:断言状态码、JSON 字段、异常响应
- 区分 @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:跑起来并看结果
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 同包结构下。
讲清楚要点
- 测试三层用对工具:Service 纯 Mockito(快、覆盖业务分支)、Controller @WebMvcTest(验证路由/参数/异常契约)、Repository @DataJpaTest(验证真 SQL)、关键链路 @SpringBootTest(端到端兜底)。全部 @SpringBootTest 会让测试套件慢到没人愿意跑。
- given-when-then 是可读性的骨架:准备(打桩)→ 执行 → 断言。一个用例只测一个行为;命名讲人话(rename_success / rename_taskNotFound),失败时读名字就知道坏了什么。
- Mock 的边界:mock 掉的是「不稳定的慢依赖」(数据库、外部接口),mock 不了逻辑本身——被测方法里的 if/else 才是测试价值所在。别把断言写成「验证 mock 返回了自己打的桩」(无意义的自嗨测试)。
- @MockBean 与 @Mock 的区别:@Mock 是 Mockito 局部对象(单元测试);@MockBean 是把 Spring 容器里的 Bean 替换成 mock(切片测试)。Boot 3.4 起 @MockBean 弃用,改用 @MockitoBean(Spring Framework 6.2 提供),写法几乎一致。
- 测试是重构的安全网:有了覆盖良好的测试,你才敢大胆改代码。反过来说,难写测试的代码(静态依赖、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 + 行为命名 |
| 用例之间共享可变状态(静态字段存数据) | 用例顺序依赖,偶发失败 | 每个用例独立准备数据 |
卡点急救
Failed to load ApplicationContext:切片测试报缺 Bean → 补 @MockBean/@Import;@SpringBootTest 报错 → 多半是主配置问题(数据源连不上、安全配置循环依赖),先让应用能正常启动。
- MockMvc 404:URL 与 Controller 映射不一致(含前缀);确认 @WebMvcTest 只导入了目标 Controller,别的 Controller 的路径当然 404。
- JSON 断言失败:先打印实际响应(
.andDo(print()))对照 $.data.title 路径——统一响应结构下多了一层 data 是最常见原因。
- 测试间数据互相污染:确认 @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 之类的重型工具硬测。
自测三问
- @Mock 和 @MockBean(3.4+ 为 @MockitoBean)分别用在什么场景?
- 一条 MockMvc 用例的三段式结构是什么?jsonPath 如何匹配统一响应里的 data 字段?
- 为什么 Service 层测试不该用 @SpringBootTest?
小结一句话
测试金字塔 + 切片注解让每层代码都有对口的验证工具——Service 走 Mockito、Controller 走 MockMvc、数据走 @DataJpaTest,mvnw test 一条命令守住重构底线。
延伸