
先聊个很现实的场景项目跑得好好的接口也没报错但某天产品提了个需求要改“用户余额计算”这块逻辑。你打开Service层一看几百行代码蜷在那里改吧怕把别的流程带崩不改吧需求又推不掉。这时候你才想起来当初要是写了单元测试现在直接跑一遍测试就知道改动会不会炸。这就是我今天想聊透的事——Spring Boot项目里单元测试到底该怎么写。这篇文章不是给你堆概念而是从搭建环境开始一步步把 Repository、Service、Controller 三层的测试代码写出来把 Mock、断言、MockMvc 这些工具用明白最后把我在实战中踩过的坑一并交代清楚。不管你是刚入行的后端还是写了两三年 CRUD 但一直没系统写过测试的同学照着这篇文章走完至少能给自己的项目搭一套能落地的测试方案。1. 动手写测试前先把这几个概念盘清楚1.1 测试不是孤立存在的先看懂 Spring Boot 四层架构提到单元测试很多人第一反应是“这不就是写Test方法嘛”真上手才发现不知道测什么、怎么测。问题就出在没理解项目的分层结构。一个标准的 Spring Boot 项目请求进来之后走的链路是固定的Controller接收请求、参数校验、返回响应→ Service业务逻辑、事务控制→ Repository/Mapper数据持久化→ 数据库。再加上 DTO、VO、Entity 这些数据载体以及 Config、Common 这些基础设施差不多就是一个完整项目的骨架了。这套分层对应到目录上就是最常见的“controller / service / mapper/repository / entity / dto”结构。我把这套结构称为 Spring Boot 四层架构理解它不是为了面试而是因为每一层的测试侧重点完全不同。你在 Controller 层测的是“参数校验、状态码、响应结构”在 Service 层测的是“业务规则、异常分支、事务行为”在 Repository 层测的是“SQL 映射、数据读写”。如果这三层混在一起测必然导致测试启动慢、依赖多、结果不稳定。所以先记住一个原则写测试之前先搞清楚你现在写的这段代码属于哪一层再决定用什么样的测试手段。1.2 单元测试、集成测试、切片测试别混为一谈很多初学者上来就写 SpringBootTest这种写法不是不对而是用错了场景。SpringBootTest 会启动完整的 Spring 容器把 Controller、Service、Repository、数据源全部加载一遍跑一个测试可能要好几秒而且只要有一层环境有问题整个测试全挂。这属于集成测试的范畴适合验证模块之间的协作用在单测场景里就太重了。真正适合日常大部分场景的是“单元测试”和“切片测试”。单元测试是 Mockito 的舞台只关注某一个类的行为其他依赖全部 mock 掉毫秒级跑完。切片测试是 Spring Boot 给的特权比如只加载 Controller 层的 WebMvcTest或者只加载 Repository 层的 DataJpaTest不启动整个容器只装配当前层需要的 Bean。三种测试的定位我列了个表写代码前先对着看测试类型加载范围启动速度典型场景单元测试单个类依赖全 Mock极快Service 业务规则、工具类逻辑切片测试仅当前层相关 Bean快Controller 接口、Repository SQL集成测试完整 Spring 容器慢全链路流程、跨模块协作这几种不是选一个就完事真实项目里是混着用的。我的习惯是 Service 层写纯单元测试Controller 层写 WebMvcTest 切片测试关键核心链路再补两三个 SpringBootTest 集成测试兜底。这样既能保证覆盖面又不用每次都等容器起半天。2. 环境搭建和依赖引入这些配置不弄好后面全是坑2.1 引入测试依赖在 Spring Boot 项目里引入测试依赖特别简单因为官方已经把该打包的东西都打包好了。在 pom.xml 里加上这个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这一个依赖里其实装了好几个东西JUnit 5Java 生态最主流的测试框架、Mockito做 mock 的、AssertJ写断言的、Hamcrest老牌匹配器、JSONAssert测 JSON 响应的等等。换句话说你日常写单测需要的工具这一个依赖基本全包了。不过要注意一点如果你的项目里还有其他历史遗留的测试依赖比如 JUnit 4 的 junit:junit建议显式排除掉否则会出现 JUnit 4 和 JUnit 5 的注解冲突。我见过很多次这种情况测试类上写了 Test结果导入的是 org.junit.Test 而不是 org.junit.jupiter.api.Test导致 Spring Boot 的后置处理完全不生效排查半天。所以务必在主依赖里看清楚最好在 idea 里检查一下依赖树mvn dependency:tree -Dincludesjunit:junit如果发现还有 JUnit 4可以在 spring-boot-starter-test 里加 exclusions 排除。2.2 目录规范与命名约定Spring Boot 的 Maven 项目里测试代码统一放在 src/test/java 目录下包名和主代码保持一致。比如主代码里有 com.example.demo.service.UserService测试代码就放在 com.example.demo.service.UserServiceTest。这样做的最大好处是IDE 里按快捷键可以在主代码和测试代码之间来回跳转而且包名一致意味着测试类可以访问主类的包级私有成员。测试资源文件放在 src/test/resources 目录下。这里要给个提示放在 src/test/resources 里的 application.yml 会覆盖主目录的同名配置文件也就是说测试环境可以指定自己的数据库配置、日志级别。比如你不想让测试连真实的 MySQL就可以在测试资源里单独配一个 H2 内存库的数据源。再来说命名规范。类名统一用 被测类名 Test 结尾比如 UserServiceTest、UserControllerTest。方法名我建议用这种格式should_期望行为_when_条件。举个例子Test void should_throwException_when_emailAlreadyExists() { // ... }一个方法名就是一条业务规则测试挂了看名字就知道哪个分支出了问题比写“test1”“test2”这种不知道强多少倍。再加上 DisplayName 注解给用例起个中文名字比如“邮箱重复时抛出异常”这样测试报告给产品看都行。3. 核心注解逐层拆解搞清楚它们你就懂了一半3.1 JUnit 5 的生命周期注解JUnit 5 的基础注解只有那么几个但用好它们能省很多事。BeforeAll 标注的方法在整个测试类之前运行一次必须是 static 方法适合做全局初始化。BeforeEach 标注的方法在每个测试方法之前运行适合准备测试数据。对应的还有 AfterEach 和 AfterAll做清理工作。打个比方BeforeEach 就像你每次做饭前洗锅AfterEach 就像吃完饭刷碗。如果你有多个测试方法都用同一个测试对象与其在每个方法里重复创建不如抽到 BeforeEach 里。但注意一个细节如果你用的是 Mockito 的 InjectMocks配合 BeforeEach 手动 new 一个被测对象往往比一直依赖注解更可控后者在后面会讲。还有 ParameterizedTest 这个注解做参数化测试特别香。比如测试一个校验方法需要覆盖空字符串、null、超长字符串等好几组数据用普通写法要写好几个方法用参数化一个方法就搞定ParameterizedTest ValueSource(strings {, , null}) void should_returnFalse_when_nameIsBlank(String name) { assertFalse(StringUtils.hasText(name)); }再配合 CsvSource 可以传多组参数非常灵活。这些属于 JUnit 5 自带能力不用额外引包。3.2 Spring Boot 的测试切片注解Spring Boot 提供了一组“切片”注解用来只加载某一部分配置。我这里只列三个最常用的WebMvcTest 只加载 Controller 层相关配置和 Spring MVC 基础组件用来写接口测试DataJpaTest 只加载 JPA/Hibernate 相关配置用来写 Repository 测试还有如果想测 MyBatis可以用 MybatisTest这个是 MyBatis 社区提供的。切片测试最大的优势是快因为它不会把整个应用上下文都拉起来。比如 WebMvcTest 默认不会扫描 Service、Repository也不会连接数据库所以你测试 Controller 时不用担心数据库连不上导致测试挂掉。这也是我强烈建议替换 SpringBootTest 的原因。使用切片测试时有个配套动作如果测试类里依赖了其他 Bean通过 MockBean 或 MockitoBean 把它 mock 掉。比如测试 UserController 时UserService 就被 mock 掉专注测 Controller 自己的逻辑。3.3 Mock、MockBean、SpyBean 到底怎么选这是新手最容易绕晕的地方。我尽量说人话。Mock 是 Mockito 原生的用在纯单元测试里声明一个 mock 对象不经过 Spring 容器。InjectMocks 也是 Mockito 的它会把已经声明好的 Mock 对象自动注入到被测对象里。这两个组合是 Service 层单元测试的标准配方。MockBean 是 Spring Boot 封装的它会把这个 mock 对象注册到 Spring 容器里替换掉原来那个真实的 Bean。它用在需要 Spring 容器参与的场景比如 WebMvcTest 里把 Service mock 掉。但注意Spring Boot 从 3.4 开始推荐用 MockitoBean 取代 MockBean后者在后续版本可能会被移除。如果你是新建的 Spring Boot 3.x 项目直接用 MockitoBean 就行。SpyBean 和 MockBean 的逻辑类似区别在于前者会“保留真实方法的行为”默认走真实逻辑只有在被 stub 的方法上才走 mock 逻辑。一般用在不方便整体 mock、只想拦截某个方法调用的场景实际用得不多。这里有一个关键点Mock 对象默认对没有 stub 的方法返回“空值”对象返回 null集合返回空集合boolean 返回 false。所以如果你在测试中发现“明明调用了方法却拿到 null 然后空指针”大概率是忘了写 when(...).thenReturn(...)。4. 分层单元测试实操从 Repository 到 Service 再到 Controller4.1 Repository 层测试验证 SQL 和映射是否正确很多人觉得 Repository 层就几个接口方法没啥好测的。但恰恰相反只要涉及自定义 SQLQuery 注解写的 JPQL 或原生 SQL、复杂的派生查询方法名、批量更新操作就非常容易出现低级错误。比如字段名拼错、参数顺序不对、返回类型映射失败这些问题编译期不会报错但一跑必炸。写 Repository 测试最方便的姿势是用 DataJpaTest。这个注解的回退方案是自动配置一个嵌入式内存数据库默认是 H2并且每次测试结束自动回滚事务不会污染真实数据。假设我有这样一个 Repositorypublic interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByEmail(String email); Query(select u from User u where u.status :status and u.createdAt :time) ListUser findInactiveUsers(Param(status) String status, Param(time) LocalDateTime time); }测试代码可以这样写DataJpaTest class UserRepositoryTest { Autowired private UserRepository userRepository; Test DisplayName(通过邮箱查找用户存在则返回) void should_findUser_when_emailExists() { User user new User(); user.setEmail(testexample.com); user.setStatus(ACTIVE); userRepository.save(user); OptionalUser result userRepository.findByEmail(testexample.com); assertThat(result).isPresent(); assertThat(result.get().getEmail()).isEqualTo(testexample.com); } }如果你用的不是 JPA 而是 MyBatis那测试的思路类似不过换成 MybatisTest并且通常需要准备 Mapper XML 加上内存库支持。这里特别提醒一句Repository 测试尽量不要用 SpringBootTest因为一旦加载了完整容器测试就变成了集成测试你根本分不清是 SQL 的问题还是别的 Bean 的问题。4.2 Service 层测试业务规则的重头戏Service 层是单元测试的主战场因为业务规则、异常判断、事务边界都在这一层。这里用纯 Mockito 就够了不需要启动 Spring 容器。核心思路是把 Repository 等外部依赖全部 mock 掉只验证 Service 自己的逻辑。假设有个用户注册的场景注册时如果邮箱已经存在直接抛出业务异常否则保存用户并返回用户 ID。代码长这样Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } public Long register(String email, String name) { if (userRepository.findByEmail(email).isPresent()) { throw new BusinessException(邮箱已被注册); } User user new User(); user.setEmail(email); user.setName(name); return userRepository.save(user).getId(); } }对应的单元测试ExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserRepository userRepository; InjectMocks private UserService userService; Test DisplayName(邮箱重复时抛出业务异常) void should_throwException_when_emailAlreadyExists() { given(userRepository.findByEmail(testexample.com)) .willReturn(Optional.of(new User())); assertThatThrownBy(() - userService.register(testexample.com, 张三)) .isInstanceOf(BusinessException.class) .hasMessage(邮箱已被注册); verify(userRepository, never()).save(any(User.class)); } Test DisplayName(注册成功时保存用户并返回ID) void should_saveUser_when_emailNotExists() { given(userRepository.findByEmail(testexample.com)) .willReturn(Optional.empty()); User saved new User(); saved.setId(100L); given(userRepository.save(any(User.class))) .willReturn(saved); Long userId userService.register(testexample.com, 张三); assertThat(userId).isEqualTo(100L); verify(userRepository).save(argThat(user - user.getEmail().equals(testexample.com) user.getName().equals(张三) )); } }这段代码里有两个非常值得学的点。第一第一个用例里 verify(userRepository, never()).save(...) 是在验证“邮箱重复时不应该调用保存方法”这比单纯查返回值更能说明业务规则。第二第二个用例里 argThat(...) 是在校验传给 save 方法的对象内容这样能确认 Service 正确地把入参传给了 Repository。如果你遇到更复杂的逻辑——比如多次调用同一个 Repository 方法、有 if-else 嵌套、有事务回滚我的经验是先把分支列出来给每个分支写一个用例。宁可用例多一点也不要为了省事把一个方法塞进一个大用例里全测了否则出错时你根本不知道是哪一步坏了。4.3 Controller 层测试用 MockMvc 验证 HTTP 行为Controller 层的测试主要关心三件事请求参数能不能正确绑定、返回的状态码对不对、响应 JSON 的结构是否符合预期。这里用 WebMvcTest 加 MockMvc 是最优方案不需要启动 Web 服务器也不需要调真实 Service。假设有个新增用户的接口RestController RequestMapping(/api/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } PostMapping public ResultLong createUser(RequestBody Valid CreateUserRequest request) { Long id userService.register(request.getEmail(), request.getName()); return Result.success(id); } }测试代码WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockitoBean private UserService userService; Test DisplayName(创建用户返回200和用户ID) void should_returnUserId_when_createUser() throws Exception { given(userService.register(testexample.com, 张三)) .willReturn(100L); mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content({\email\:\testexample.com\,\name\:\张三\})) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(0)) .andExpect(jsonPath($.data).value(100)); } Test DisplayName(参数不合法时返回400) void should_return400_when_requestInvalid() throws Exception { mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content({\email\:\\,\name\:\\})) .andExpect(status().isBadRequest()); } }MockMvc 的链式断言非常直观andExpect 里写什么就是在验证 HTTP 响应的什么维度。jsonPath 这种语法跟 JSONPath 一致$.code 就是取最外层的 code 字段。我用下来最顺手的是这一套组合基本能满足 90% 的接口测试需求。另外有一个很多人会忽略的场景Spring Boot Actuator 暴露了很多内部端点如果不做权限控制等于把项目内部状态都暴露了。Controller 测试完全可以把这个安全配置也覆盖进去比如断言未授权访问敏感端点时返回 401 或 403授权后才能看到关键信息这样安全需求不容易在后续改动里被悄悄破坏。5. 进阶场景文件上传、异步任务、外部依赖5.1 文件上传接口的测试文件上传是一个典型的“看起来麻烦、测起来更麻烦”的接口。好在 Spring 的 MockMvc 提供了 MockMultipartFile可以把虚拟文件直接塞进请求里。如果接口还需要同时传其他参数注意区分 RequestPart 和 RequestParam文件一般用 RequestPart简单参数可以用 RequestParam 或者放在同一个 multipart 请求里。下面是一个同时带文件和参数的接口测试示例Test DisplayName(上传文件并携带参数返回文件URL) void should_uploadFile_when_requestWithFileAndParam() throws Exception { MockMultipartFile file new MockMultipartFile( file, test.txt, MediaType.TEXT_PLAIN_VALUE, hello world.getBytes(StandardCharsets.UTF_8) ); mockMvc.perform(multipart(/api/files/upload) .file(file) .param(description, 测试文件)) .andExpect(status().isOk()) .andExpect(jsonPath($.data.url).isNotEmpty()); }如果文件上传逻辑里包含“文件类型校验”“大小限制”可以在测试里构造不同类型、不同大小的文件来验证这些过滤逻辑。写测试文件时大小可以用字节数组控制比如 new byte[5 * 1024 * 1024] 就是一个 5MB 的虚拟文件非常好用。5.2 异步、定时任务和 WebSocket 的测试思路WebSocket 推送、定时任务、异步线程池这类代码直接写单测会有一个麻烦你不确定异步结果什么时候执行完断言一写就各种随机挂。我的建议是分层处理底层业务逻辑比如真正发送消息的方法、真正执行的定时任务里的核心方法照常写同步单元测试把异步调度这层薄壳剥掉如果非要测异步行为用 Awaitility 这个库它能轮询等待某个条件成立Awaitility Test void should_sendMessage_when_websocketConnected() { webSocketService.pushMessage(topic, hello); await().atMost(Duration.ofSeconds(5)) .untilAsserted(() - { assertThat(mockSession.getSentMessages()).contains(hello); }); }这里我一般会 mock 掉 WebSocketSession通过断言 session 对象收到的消息来判断推送逻辑是否正确而不是真的去连一个 WebSocket 服务。定时任务的核心逻辑同理把任务要执行的业务方法抽出来单独测。对于外部 HTTP 调用比如 Service 里调了第三方接口最简单的做法是 mock 掉 WebClient 或 RestTemplate。如果你想把问题阻断得更彻底可以用 okhttp 的 MockWebServer在测试里起一个本地的模拟服务既能验证出参也能验证入参特别适合测那些需要跟外部系统交互的 Service。5.3 测试环境的配置管理与动态端口测试代码跑起来之后环境配置是另一个容易踩坑的地方。我的习惯是准备一个 src/test/resources/application-test.yml把数据库切到 H2把日志级别调成 WARN把一些外部依赖的开关关掉。然后在测试类上标注 ActiveProfiles(test)这样就能确保测试环境跟开发环境隔离。如果你用 Testcontainers 起了 Docker 容器数据库、Redis推荐用 DynamicPropertySource 动态注入端口。这个注解会在 Spring 容器启动前执行可以读取容器的真实端口号并写进 EnvironmentContainer static PostgreSQLContainer? postgres new PostgreSQLContainer(postgres:15-alpine); DynamicPropertySource static void dynamicProperties(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, postgres::getJdbcUrl); registry.add(spring.datasource.username, postgres::getUsername); registry.add(spring.datasource.password, postgres::getPassword); }这一套组合拳下来测试就是以一种跟生产环境很接近、但又完全隔离的方式在跑既不会污染开发库又能发现真实 SQL 的问题。不过 Testcontainers 依赖 Docker用之前最好确认 CI 环境支持否则到了流水线上直接挂绿灯。6. 常见问题排查与避坑实录6.1 常见报错速查表我把这些年写测试时遇到的典型报错整理成了表格按“症状 → 原因 → 解决办法”来写症状根本原因解决办法测试类里 Autowired 注入为 null测试类没被 Spring 管理比如少了 SpringBootTest 或切片注解检查类上注解切片测试要写 WebMvcTest/DataJpaTestNoSuchBeanDefinitionException容器里没有对应 Bean或者被测类没有交给 Spring 管理检查是否漏了组件扫描、是否应该用 MockBean/MockitoBeanNullPointerExceptionmock 对象返回 null对 mock 方法没做 stub比如忘了 when(...).thenReturn(...)补上 stub或者用 lenient() 处理不被调用的特点MockBean 标了 deprecated 警告Spring Boot 3.4 推荐 MockitoBean新代码用 MockitoBean老代码重构时顺手改掉JUnit 4 和 JUnit 5 注解混用依赖里残留了 junit:junit在 spring-boot-starter-test 里排除 JUnit 4测试方法相互影响数据污染测试数据没清理或事务没回滚Repository 层用 DataJpaTest默认回滚Service 层用 Transactional 隔离切片测试中 Controller 依赖的 Bean 找不到切片只加载部分 BeanService 等 Bean 没被实例化用 MockitoBean mock 掉非当前层的依赖6.2 版本升级踩坑从 Spring Boot 3.x 到 4.x很多人是从旧版本一步步升级上来的版本升级之后测试经常莫名其妙全挂。这里挑几个最常见的点提醒一下。Spring Boot 3.0 开始javax 包名全面换成 jakarta如果你从 2.x 升到 3.x测试代码里的 import javax.* 必须改成 jakarta.*否则编译都过不了。Spring Boot 3.4 之后 MockBean 标记为 deprecated官方引导用 MockitoBean虽然老版本还能跑但升级的时候最好一并改掉。Spring Boot 4.x 出来后数据源自动配置相关类的包路径又发生了调整很多人升级后启动测试直接报找不到 DataSourceAutoConfiguration本质上就是自动配置类的位置变了需要检查项目里是否硬编码引用了旧包路径。类似的还有 Jackson 的 JsonMapper 构造方式在 4.0 里有了变化如果你在测试里手动构建过 ObjectMapper升级后要多留意构造器参数。这些问题排查的思路是统一的测试启动报错时先看类路径有没有编译错误再看自动配置类是否被正确扫描最后看有没有用错注解。别一上来就怀疑测试代码写错了很多时候是环境层面的问题。6.3 实战中的几点心得稍微沉淀一下这几年的测试经验挑几条最值钱的分享给你。第一Service 层的测试是性价比最高的投资。它不需要启动容器跑得快又能完整覆盖业务规则。我基本要求自己项目里的 Service 测试必须覆盖所有分支正常流程、异常流程、边界值。每次改动 Service 代码跑一遍测试花不了几秒钟但能挡下很多回归问题。第二Controller 测试不要追求 100% 覆盖主路径加关键异常路径就够了。什么参数校验的枚举、非法 JSON 的这些可以多写几条但不要把 Controller 测试写成 Main 方法的重演否则维护成本非常高。第三mock 是手段不是目的。如果发现某个测试里 mock 了一大堆东西才勉强能跑大概率是设计有问题。比如 Service 层过度依赖其他 Service、一个方法里做了太多事这时候拆解代码比硬写测试更划算。第四测试命名一定要清晰。我自己用的是“should_期望结果_when_条件”这个格式配合 DisplayName 写中文说明三个月后回来看测试报告一眼就能知道每个用例在守什么。好的测试代码本身就是最好的业务文档。最后再分享一个小技巧如果你手头是个老项目之前从来没有测试现在想补不要贪多求全。先挑业务价值最高的几个 Service 类写起把最核心的业务规则用测试锁住然后再慢慢扩展。我见过太多人雄心勃勃要给全项目铺测试结果写了一周用例跑一次全红了直接放弃。从一个类开始跑通第一个测试把这个正反馈建立起来后面自然就有动力了。单元测试这东西一个项目的初始阶段越早引入越好但如果已经晚了也别灰心——任何时候开始补都比不补强。