
前一阵我临时接手一个老模块任务内容很小把一个用户字段从 old_name 改成 nickname。代码改了SQL 脚本漏了测试更是一条没跑。上线第二天线上历史数据错了一大片。复盘的时候同事问我这个模块的单元测试覆盖到哪一层了我一时答不上来。这件事之后我才真正把 SpringBoot 单元测试当成开发流程的一部分而不是写完代码之后用来打发时间的仪式。这篇就把我在 SpringBoot 项目里沉淀下来的测试套路、依赖配置、常见坑和排查思路一次性写清楚适合准备开始补测试的人也适合写了测试但总在踩坑的朋友。文中会用实际可运行的代码演示 Controller、Service、Repository 三个层的测试怎么写也会讲清楚每一步为什么这么设计。1. 为什么最终要认真写单元测试——从一次数据错乱事故说起1.1 一次字段改名引发的线上事故那次事故的过程很简单我把实体类里一个字段从oldName改成nickname所有代码引用都顺手改了但生产环境的历史数据导入 SQL 没有同步改。当时我没有单元测试验证方式就是在本地跑一次接口看到返回 200 就认为没问题。结果上线后线上老数据全部查不到昵称新数据写入后也落到错误列。这种问题如果有一个 Repository 层的测试把 SQL 映射到实体这一层验证一下根本不会走到线上。我后来补测试的时候发现那套 SQL 本身就是手拼的动态语句没有单元测试保护谁改谁踩雷。这次事故让我意识到一个事实单元测试不是给领导看的覆盖率数字而是开发者的安全网。它不能防止所有 Bug但能把最蠢、最不应该犯的错误挡在提交之前。1.2 单元测试在你的模块里守的是什么我理解的单元测试主要守三条线接口契约Controller 层的 URL、HTTP 方法、请求参数、返回结构、状态码一旦被别人依赖改坏了能立刻暴露。业务规则Service 层的分支逻辑、条件判断、异常抛出、边界值处理。业务代码最容易被后续需求改乱也最需要测试保护。数据映射Repository 层的 SQL 是否正确、字段映射是否对得上、新增字段或改表结构时没有回归风险。大多数项目里最容易缺的是第三条。很多团队把 Mock 用到底所有数据访问层都 Mock 掉结果 SQL 写错时测试全绿。后面我会专门讲 Repository 层为什么至少要留一个能真跑 SQL 的测试。1.3 “单元”的边界怎么划Controller、Service、Repository 各测各的很多人写测试时一测到底启动整个 Spring 容器真实数据库连接一个测试方法里把 HTTP 请求发进去Service 真实执行Repository 真实落库。这种测试不是单元测试而是集成测试的雏形。它的缺点是失败时定位成本极高不知道是哪一层出了问题。我的划分方式是Controller 层用WebMvcTest只加载 MVC 相关配置Service 用 Mock 替身。Service 层用纯 Mockito不启动 Spring 容器Repository 用 Mock。Repository 层用DataJpaTest H2 内存数据库真跑 SQL 和字段映射。每层只关心自己的职责。这样测试方法执行特别快定位问题时一眼就能看出是哪一层挂了。2. 项目里搭建测试环境的第一步版本选择与依赖配置2.1 Spring Boot 版本太高带来的连锁反应——从 BOM 聊起很多人搜“springboot 版本太高”一看到报错就想降版本。其实大部分问题不是版本太高而是代际切换导致的兼容性问题。Spring Boot 2.x 用的是javax.*命名空间Spring Boot 3.x 全家迁移到了jakarta.*。如果你从 2.x 升到 3.x老代码里所有import javax.annotation.Resource、javax.validation.*都会直接编译失败。这不是版本“太高”的问题是迁移没做完。另一个典型变化是 Spring Boot 3.4 开始官方把MockBean标记为废弃推荐使用org.springframework.test.context.bean.override.mockito.MockitoBean。很多人在网上抄的老写法是MockBean在最新版本里依然能用只是一直会有废弃警告。我会在后面 Controller 测试章节详细对比这两个注解的用法。我的建议是不要盲目降版本先搞清楚项目当前处于哪个代际。对比项Spring Boot 2.xSpring Boot 3.x基础 JDKJava 8 起步Java 17 起步命名空间javax.*jakarta.*Mock 注解MockBeanMockBean可用3.4 起推荐MockitoBean默认测试框架JUnit 52.2JUnit 5如果你的项目依赖很多第三方库升级前先检查这些库是否已经适配 Jakarta 命名空间比纠结版本数字重要得多。2.2 spring-boot-starter-test 里到底包含什么Spring Boot 提供了一个测试全家桶依赖spring-boot-starter-test。一个依赖搞定 JUnit 5、Mockito、AssertJ、Spring Test、JSONPath 等不用自己一个个引。Maven 项目里这样加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependencyGradle 项目里这样加dependencies { testImplementation org.springframework.boot:spring-boot-starter-test }如果 Repository 层测试要跑 H2再加一个dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scopetest/scope /dependency这里有个经验点如果遇到 Mockito 报 “Cannot mock final class” 这类错误通常不是代码的问题而是 Mockito 版本不支持 mock final 类。可以先更新 mockito-core 版本或者引入mockito-inline。Spring Boot 的 BOM 会自动管理这些版本所以大多数情况下你不需要手动指定版本号。2.3 测试资源文件profile 与日志噪音单元测试最好有一套独立的测试配置不要直接复用主环境的application.yml。我习惯在src/test/resources下建一个application.ymlspring: main: banner-mode: off datasource: url: jdbc:h2:mem:testdb;MODEMySQL;DB_CLOSE_DELAY-1;DATABASE_TO_LOWERTRUE driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: create-drop database-platform: org.hibernate.dialect.H2Dialect logging: level: root: WARN关掉 banner、把日志调成 WARN测试输出会干净很多。跑测试时定位自己的报错信息不用在一堆 INFO 日志里翻。如果项目里有多个环境也可以给测试类加ActiveProfiles(test)指定使用application-test.yml。不过我更喜欢直接用src/test/resources/application.yml因为测试资源目录天然和主代码隔离不容易误改到生产配置。3. Controller 层实战MockMvc 让接口测试变成模拟请求3.1 一个典型的 REST 接口长什么样先做一个简单的业务场景用户积分查询接口。Controller 只负责参数接收和结果返回具体逻辑在 Service 层。RestController RequestMapping(/api/users) public class UserPointController { private final UserPointService userPointService; public UserPointController(UserPointService userPointService) { this.userPointService userPointService; } GetMapping(/{userId}/points) public ResultLong getUserPoints(PathVariable Long userId) { return Result.ok(userPointService.getUserPoints(userId)); } }这里假设项目里有一个统一的ResultT响应体包含code、data、msg三个字段。这种写法在企业项目里很常见。Controller 测试的重点是URL 是否正确映射参数能否正确绑定返回结构是否符合约定异常时状态码是否符合预期3.2 用 MockMvc 写第一个请求断言WebMvcTest是 Controller 层测试的利器。它只加载 Web 层相关配置不会启动完整应用上下文因此速度很快。WebMvcTest(UserPointController.class) class UserPointControllerTest { Autowired private MockMvc mockMvc; MockitoBean private UserPointService userPointService; Test void 查询积分_用户存在_返回正常结构() throws Exception { when(userPointService.getUserPoints(1001L)).thenReturn(866L); mockMvc.perform(get(/api/users/1001/points)) .andExpect(status().isOk()) .andExpect(jsonPath($.code).value(0)) .andExpect(jsonPath($.data).value(866)); } }运行方式很简单IDEA 里直接点击测试方法旁边的绿色箭头。命令行则执行./mvnw test或./gradlew test。第一次写 MockMvc 测试的人容易忽略一点WebMvcTest不会自动注册 Service 的 Bean所以必须用MockitoBean或MockBean提供替身否则启动测试上下文时会报找不到 Bean。jsonPath的语法我第一次接触时也觉得反直觉比如$.data表示根节点下的data字段数组用$[0]表示第一个元素。多用几次就顺手了。3.3 参数校验和异常处理的测试——别让 500 成为默认答案接口里最常见的坑是参数校验失败时返回 500而不是 400。正常的项目会加RestControllerAdvice统一处理异常但这段逻辑同样需要测试保护。先给 Controller 加入参数校验GetMapping(/{userId}/points) public ResultLong getUserPoints(PathVariable Positive Long userId) { return Result.ok(userPointService.getUserPoints(userId)); }测试非法参数Test void 查询积分_参数不合法_返回400() throws Exception { mockMvc.perform(get(/api/users/-1/points)) .andExpect(status().isBadRequest()); }如果项目里自定义了全局异常处理器把校验异常转换成统一的响应结构那么这里断言的部分会更多比如jsonPath($.code).value(400)。实际项目里我还见过一种情况全局异常处理器把MethodArgumentTypeMismatchException漏掉了导致传非数字 userId 时直接落到 500。这种问题如果不写测试根本不会注意到。所以 Controller 测试里除了“正常返回”这个用例一定要留几个异常分支的用例。3.4 MockBean 与 MockitoBean版本差异别踩空Spring Boot 3.4 开始推荐MockitoBean位置在org.springframework.test.context.bean.override.mockito包下。如果你还在用 Spring Boot 2.x只能用MockBean。两个注解的作用完全一样往 Spring 容器里塞一个 Mock 替身。区别只是包名和是否标记废弃。我遇到过一种情况代码从 2.x 升到 3.x测试类里MockBean没改结果编译过、运行也过只是控制台一直有 deprecated warning。当时项目启用了“警告即错误”的编译参数直接把构建打断了。所以升级版本时顺手把MockBean批量替换成MockitoBean能省很多麻烦。有一种说法是MockitoBean不会因为找不到被替换的 Bean 而报错因此更容易出现“测试对象是空 Mock”的隐蔽问题。我的建议是替换后跑一遍完整测试套件确认没有出现意外的空指针。4. Service 层实战Mockito 隔离依赖只测自己的业务逻辑4.1 为什么 Service 层测试通常不碰数据库Service 是业务逻辑的核心也是分支最多的地方。测 Service 时我最关心的是给定某种输入这段业务代码是否走对分支、抛对异常、返回对结果。如果用真实数据库每跑一个用例都要准备数据、清理数据速度慢不说还会因为表结构、环境差异影响结果。单元测试的黄金法则是测什么就只测什么。所以 Service 层的依赖一律用 Mockito 隔离掉。纯 Mockito 测试根本不启动 Spring 容器跑一个用例通常只要几十毫秒。我习惯给每个 Service 类建一个同名的*Test类放在src/test/java下相同包路径里方便管理和扫描。4.2 带分支逻辑的积分计算案例假设积分 Service 做了一个小规则用户存在时返回积分不存在时返回默认值 0参数非法时直接抛异常。Service public class UserPointService { private final UserPointRepository userPointRepository; public UserPointService(UserPointRepository userPointRepository) { this.userPointRepository userPointRepository; } public Long getUserPoints(Long userId) { if (userId null || userId 0) { throw new IllegalArgumentException(userId不合法); } return userPointRepository.findByUserId(userId) .map(UserPointEntity::getPoints) .orElse(0L); } }测试类这样写ExtendWith(MockitoExtension.class) class UserPointServiceTest { Mock private UserPointRepository userPointRepository; InjectMocks private UserPointService userPointService; Test void 用户存在_返回积分() { when(userPointRepository.findByUserId(1001L)) .thenReturn(Optional.of(new UserPointEntity(1001L, 866L))); Long points userPointService.getUserPoints(1001L); assertEquals(866L, points); } Test void 用户不存在_返回默认积分0() { when(userPointRepository.findByUserId(999L)) .thenReturn(Optional.empty()); Long points userPointService.getUserPoints(999L); assertEquals(0L, points); } Test void userId非法_抛出异常() { assertThrows(IllegalArgumentException.class, () - userPointService.getUserPoints(-1L)); } }这里用到了三个 Mockito 核心注解Mock创建一个 Mock 实例InjectMocks把 Mock 自动注入到被测 Service 中ExtendWith(MockitoExtension.class)启用 Mockito 注解处理InjectMocks是基于构造器注入的所以被测 Service 最好使用构造器注入。如果项目还在用字段Autowired注入InjectMocks也能处理但遇到多个同类型依赖时容易注入错强烈建议新代码统一用构造器注入。4.3 verify 验证调用次数与参数有时候测试不仅要关心“返回什么”还要关心“某个方法到底有没有被调用”。比如一个接口要求用户查询命中缓存时不打数据库那就要验证 Repository 没有再次被访问。这种场景用verify。Test void 用户查询_只访问一次仓储() { when(userPointRepository.findByUserId(1001L)) .thenReturn(Optional.of(new UserPointEntity(1001L, 866L))); userPointService.getUserPoints(1001L); userPointService.getUserPoints(1001L); verify(userPointRepository, times(2)).findByUserId(1001L); }verify的参数匹配也很有讲究verify(userPointRepository, never()).findByUserId(anyLong()); verify(userPointRepository, times(1)).findByUserId(eq(1001L));使用anyLong()、eq()等匹配器时要注意不要混用原始值和匹配器。比如findByUserId(1001L)是允许的但when(userPointRepository.findByUserId(1001L)).thenReturn(...)如果不加eq在参数匹配器场景里会报空指针难排查。保持一致的写法能让测试稳定很多。我的经验是verify不要到处用只在“调用次数是业务规则的一部分”时才写。比如发送短信、记录审计日志、检查缓存命中这类行为次数错了就是 Bug。4.4 Service 层测试里缓存、事务的隐藏行为很多人第一次在纯 Mockito 测试里看到Transactional不生效、Cacheable不生效会以为自己代码写错了。其实不是纯 Mockito 单元测试根本没有 Spring 容器这些注解自然不会生效。这里有个容易犯的错在 Service 方法上加了Cacheable测试里调用两次方法期望第一次走方法体、第二次直接走缓存于是断言 Repository 只被访问一次。但这种断言在纯 Mockito 测试里会失败因为根本没有缓存管理器在工作。如果要验证缓存逻辑应该写一个SpringBootTest的集成测试显式开启EnableCaching并在测试方法里清理缓存SpringBootTest EnableCaching class UserPointServiceCacheTest { Autowired private UserPointService userPointService; Autowired private CacheManager cacheManager; BeforeEach void setUp() { cacheManager.getCache(userPoint).clear(); } Test void 缓存生效_第二次不访问数据库() { // 配置 Repository 返回固定值并验证调用次数 } }结论是先分清“业务逻辑测试”和“框架行为测试”再用不同的测试方式去覆盖能避免很多莫名其妙的失败。5. Repository 层实战H2 内存数据库让 SQL 真跑起来5.1 完全 Mock 掉仓储层分页 SQL 错误根本测不出来如果你只测 Service把 Repository 全部 Mock 掉那一层写错的 SQL 就是漏网之鱼。我之前就踩过一次一个分页查询的 ORDER BY 字段写错了MyBatis 的 XML 里字段名和实体对不上测试因为 Repository 全是 Mock跑得飞快全绿。联调时才暴露白白浪费了大半天。Repository 层测试的目标很简单让 SQL 在真实数据库引擎里跑一遍。不需要连接生产库用 H2 内存数据库就够了。它启动快、数据隔离、不污染开发库非常适合 CI 环境。5.2 DataJpaTest H2 的配置与方言差异如果项目用的是 Spring Data JPA用DataJpaTest最合适。它只加载 JPA 相关配置并且默认使用内嵌数据库。DataJpaTest class UserPointRepositoryTest { Autowired private UserPointRepository userPointRepository; Test void 保存后按用户id查询() { userPointRepository.save(new UserPointEntity(1001L, 866L)); OptionalUserPointEntity result userPointRepository.findByUserId(1001L); assertTrue(result.isPresent()); assertEquals(866L, result.get().getPoints()); } }如果你的项目用了 Flyway 或 Liquibase 管理 Schema测试类里会自动执行迁移脚本。如果脚本里用了 MySQL 特有语法比如ENGINEInnoDB、ON DUPLICATE KEY UPDATEH2 在默认模式会报错。需要在连接 URL 上加兼容模式spring: datasource: url: jdbc:h2:mem:testdb;MODEMySQL;DB_CLOSE_DELAY-1;DATABASE_TO_LOWERTRUE但要注意H2 的 MySQL 兼容模式不是 100% 等价。一些函数、索引语法、JSON 操作仍有差异遇到报错时先判断是不是方言差异不要直接改业务 SQL 迁就测试库。补充一个排查技巧如果项目同时引入了 MySQL 驱动和 H2 驱动DataJpaTest自动配置时可能选错数据源。可以在测试类上显式加AutoConfigureTestDatabase(replace AutoConfigureTestDatabase.Replace.NONE)这样 Spring 会使用你配置的数据源而不是自动替换成内嵌数据库。通常配合H2使用避免两个数据源能力冲突。5.3 默认事务回滚与数据隔离DataJpaTest默认给每个测试方法开启事务测试结束自动回滚。这意味着你在测试里写入的数据不会互相污染也无需手动清理。但有一个反直觉的地方如果你在测试方法里手动调用TestEntityManager.flush()或entityManager.flush()数据只是在当前事务内可见回滚依然会发生。如果某个测试里自己引入了REQUIRES_NEW传播行为或者直接在测试里提交了事务数据可能不会被回滚掉后续用例可能被污染。遇到这种用例优先考虑调整业务代码的事务边界而不是在测试里反复清理。5.4 schema.sql 和 data.sql 不是万能的早期我在测试资源目录放一个data.sql每个测试启动时先执行一遍插入基础数据。后来发现两个坑Spring Boot 默认只要 classpath 下有data.sql每次上下文启动都会执行多个测试类共用一份数据时重复执行会导致主键冲突。测试类执行顺序不固定一个测试类修改了数据另一个测试类可能依赖原始值排查起来非常痛苦。现在的建议是结构用schema.sql或迁移工具管理测试数据尽量在用例内通过 Repository 或 TestEntityManager 构造不要大量依赖全局data.sql。如果确实需要类级别数据用Sql注解局部指定Test Sql(/sql/user-point-init.sql) void 基于初始化数据_查询积分() { // 测试逻辑 }这样数据的可见范围被限定在指定测试方法内互不干扰。6. 那些真正耗费时间的排查链路——踩坑实录6.1 排查链路一测试里拿不到 ApplicationContext现象测试类加了SpringBootTest但启动时直接报NoSuchBeanDefinitionException或者提示找不到某个配置类。排查链路我一般这样走先看完整启动日志定位第一个异常在哪一行不要被后面的连环报错带偏。检查SpringBootApplication所在包是否在根包。如果主类的位置太深测试类在别的包下可能扫描不到对应的 Bean。检查测试类是否也放在被测类相同的包路径下。IDEA 自动生成的测试类默认是正确但手动移动过目录就很容易出错。如果用的是WebMvcTest或DataJpaTest这类切片测试确认被切的 Bean 是否真的在扫描范围内。一个常见误区是看到NoSuchBeanDefinitionException就加ContextConfiguration把一大堆配置类手动塞进测试。这会让测试上下文变得臃肿反而掩盖了真正的装配问题。优先排查包扫描和自动配置。6.2 排查链路二MockMvc 返回 403 而不是 200现象Controller 测试里一个明显公开的接口用 MockMvc 请求返回 403 Forbidden。原因通常是项目引入了spring-boot-starter-security而WebMvcTest会把 Spring Security 的自动配置也加载进来导致请求需要认证信息。解决办法有两种按需求选如果这个测试只想验证业务接口逻辑不想关心安全可以关闭过滤器WebMvcTest(UserPointController.class) AutoConfigureMockMvc(addFilters false) class UserPointControllerTest { // 测试代码 }如果希望模拟一个已登录用户用spring-security-test提供的WithMockUserTest WithMockUser(username tester, roles USER) void 查询积分_已登录用户_返回正常结构() throws Exception { // 请求代码 }需要提醒的是addFilters false会把安全过滤链整体关掉如果后续要测权限相关逻辑就不能用这种方式。安全测试应该单独设计不要塞在普通业务测试里和稀泥。6.3 排查链路三本地单个测试过、整个测试套件崩现象单独运行某个测试类或者某个方法一切正常执行./mvnw test全套时跑到一半崩掉。这种问题的典型原因是测试之间的环境污染。几个常见来源测试里修改了静态变量或系统属性没有还原。多个测试类共用同一个 Spring 上下文某个测试修改了 Bean 内部状态。内嵌的临时文件、端口、消息队列没有清理干净。我的排查步骤用./mvnw test -DtestClassName单独跑嫌疑测试类确认是否稳定通过。把可疑的几个测试类用一个测试套件按顺序跑看是不是顺序依赖。检查测试类里是否有static字段被跨类共享有的话改成实例字段或使用DirtiesContext。查看是否所有临时资源都有AfterEach清理逻辑。DirtiesContext会强制 Spring 在测试后重建上下文代价是速度慢但排查环境污染时很有效。定位到根因后再考虑是清理资源还是调整测试设计不要一上来就全局加这个注解。6.4 几条降低入坑概率的习惯写了一年多测试我总结了一些值得参考的习惯测试类和被测类放在同一个包路径下不同代码目录。这样既能访问包级私有成员又不污染主代码。不要追求 100% 覆盖率。核心链路、异常分支、边界值必须有其余的按风险取舍。新需求提测前先补一个失败测试再写实现这个习惯能明显降低回归概率。提交代码前跑一次完整测试而不是只跑自己改动的那个类。如果测试跑不过先看是不是环境问题再看是不是代码问题避免在错误的方向上调试。我个人现在的习惯是每个需求提交前至少保证 Controller 契约测试和 Service 核心逻辑测试通过Repository 层涉及 SQL 变更时额外补一个 H2 集成测试把 SQL 真实跑一遍。这样做以后线上回归问题明显少了。单元测试这件事真的是认真起来就回不去原来的开发节奏了。祝你在 SpringBoot 项目里也能早点体会到测试带来的安全感。