Spring Boot单元测试实战:JUnit 5与Mockito从入门到坑排查 有段时间我把“写单元测试”这件事排在了所有开发任务的最后。不是不想写是真不敢碰。一提到 Spring Boot 的测试脑子里全是“启动上下文好慢”“Mock 半天搞不定”“测试数据乱成一锅粥”。直到后来在一个遗留项目里被线上 Bug 反复教育才狠下心把 Spring Boot JUnit 5 这套东西从头捋了一遍。捋完之后发现大部分“不敢测”的恐惧其实来自三个误会以为测试一定要启动整个 Spring 容器、以为 Mock 是魔法、以为测试代码是一次性消耗品。这篇就把我实际用下来的完整打法写出来从环境准备到分层测试再到排查路上踩过的坑。目标是让你看完就能在自己项目里动手把“不敢测”变成“测得爽”。1. 先搞清楚你不敢写测试的根子在哪慢、难、无从下手1.1 “启动慢”的真相不是 JUnit 慢是你的测试把整个应用都拉起来了很多 Spring Boot 项目里的测试类一上来就是SpringBootTest。这个注解本身没有错但它会把整个 ApplicationContext 都加载一遍。如果你的项目里有数据源、Redis、MQ、各种第三方 SDK那启动时间奔着十几秒甚至几十秒去很正常。一个测试类还好十个测试类叠加起来CI 上跑一次测试等于煮一壶水。这里有个关键概念叫 TestContext 缓存。Spring 的测试框架会缓存已加载的 ApplicationContext只要配置没变后续测试类会复用同一个上下文。所以真正的问题不是“启动慢”而是你为了测一个UserService的getUserById方法把整个应用的 Bean 全部实例化了一遍。这属于典型的杀鸡用牛刀。正确思路是能用切片测试就用切片测试能纯 Mock 就纯 Mock。后文会具体讲每个层次的测试怎么选型。这里只需要记住一个判断标准你的测试涉及到的 Bean 越多定位失败时越困难执行也越慢。单元测试的核心是“隔离”不是“完整性”。1.2 测试不好写的另一个原因代码耦合度高不是 JUnit 的锅我在很多项目里见过这样的 Servicepublic class OrderService { public Order createOrder(OrderRequest request) { User user userRepository.findById(request.getUserId()).orElseThrow(); if (user.getLevel() 3) { throw new BusinessException(用户等级不足); } // ... 业务逻辑 } }这段代码第一眼没啥问题但如果你想给createOrder写单元测试会发现userRepository是不是被Autowired进来的是不是在 Spring 容器里才有如果没有接口、没有 Mock 点测试就只能真连数据库。这种情况下不敢写测试很正常因为写起来太痛苦。解决这个问题不是靠测试框架而是靠依赖注入。把外部依赖通过构造器注入测试里用 Mockito 轻松替换成假实现。所以“不敢测”很多时候是代码设计在报警而不是测试本身难。这也是为什么说单元测试倒逼代码松耦合——当你觉得一个方法测不了的时候大概率这个方法也违反了单一职责。1.3 “先写测试还是先写功能代码”到底怎么选热搜词里有个问题被反复搜“springboot先写单元测试还是先写功能代码”。我的答案一直没变过看你在这个模块上的熟悉程度。如果你对需求完全清楚、对接口设计有把握那 TDD测试驱动开发确实爽先写一个会失败的测试再写实现让它变绿整个过程像打怪升级。但如果你连需求都还在探索期上来逼自己写测试只会写出“为了测试而测试”的代码后面需求一变全得重写。折中方案是核心业务逻辑、工具类、复杂状态流转这些适合先写测试CRUD 接口、简单查询、配置类先写实现再补测试也完全没问题。关键是别把测试当成事后补作业把它当成代码的一部分写完功能顺手就把测试写了拖得越久越不想写。2. 环境与依赖让 Spring Boot 3 和 JUnit 5 在一开始就站对位置2.1 spring-boot-starter-test 到底帮你装了什么新建一个 Spring Boot 项目时pom.xml里一般会带上这个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这个 starter 是 Spring Boot 官方提供的测试全家桶默认包含 JUnit 5也就是 JUnit Jupiter、Mockito、AssertJ、Spring Test、JSONassert、JsonPath 这些。也就是说你不需要手动分别引入 JUnit 和 Mockito一个依赖全都搞定。很多新手不知道这点结果自己手动引了一堆版本冲突的依赖越搞越乱。这里要特别提醒 Spring Boot 2.2 之后的变化默认的测试引擎已经从 JUnit 4 切到了 JUnit 5。如果你在源码里看到RunWith(SpringRunner.class)这种写法那是 JUnit 4 时代的产物JUnit 5 直接继承SpringExtension但通常你连这个都不需要写因为SpringBootTest已经自动注册了扩展。2.2 版本兼容的坑springfox 3.0.0 与 Spring Boot 2.6 的路径匹配冲突这个坑我是在一个老项目升级 Spring Boot 时踩的。项目里用了 springfox 3.0.0 做 Swagger 文档Spring Boot 从 2.5 升到 2.6 之后测试一跑就报错IllegalStateException: Failed to introspect Class [springfox.documentation.spring.web.WebMvcPatternsRequestConditionWrapper]根因是 Spring Boot 2.6 开始默认的路径匹配策略从AntPathMatcher换成了PathPatternParser而 springfox 3.0.0 还没适配这个变更。这个报错看起来特别吓人实际上解法很简单在application.properties或application.yml里加一行配置把路径匹配策略改回去spring.mvc.pathmatch.matching-strategyant_path_matcher或者更推荐的做法老项目别挣扎了直接换springdoc-openapi它从设计上就和 Spring Boot 的新版本匹配更好。如果你手头是个新项目直接用springdoc-openapi-starter-webmvc-ui就完事了别在 springfox 上浪费生命。这个案例之所以值得提是因为它会在你写测试的时候突然冒出来。原因是你之前的业务代码可能没有触发这个类的加载但测试上下文启动时会扫描所有配置类于是把兼容性问题提前引爆了。这是好事测试帮你提前发现了问题。2.3 第一个能跑起来的测试从断言开始建立感觉环境装好之后先别急着写业务测试。写一个最简单的测试类把整条链路走通import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class DemoApplicationTests { Test void contextLoads() { assertThat(2 3).isEqualTo(5); } }JUnit 5 里最关键的两个注解是Test和BeforeEach/AfterEach。Test标记一个测试方法BeforeEach在每条用例执行前跑。断言我强烈推荐用 AssertJ因为它的 API 更接近自然语言assertThat(actual).isEqualTo(expected)比 JUnit 自带的assertEquals(expected, actual)更好读而且后面学 Mockito 的时候AssertJ 和 Mockito 的集成也更顺滑。跑一次测试如果看到 BUILD SUCCESS说明你的测试基础设施已经通了。接下来才进入真正的内容。3. 分层测试打样Controller、Service、Repository 的三种测试姿势3.1 Controller 层用 WebMvcTest 和 MockMvc 模拟 HTTP 请求Controller 层测试的核心是验证 HTTP 请求到方法参数、再到返回结构的映射是否正确。没必要启动整个 Spring Boot 应用用WebMvcTest只加载 Web 层相关配置即可。配合MockMvc可以直接模拟 GET、POST 请求不需要真的启动 Tomcat。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; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Test void getUserById_shouldReturnUser() throws Exception { given(userService.getUserById(1L)) .willReturn(new User(1L, 张三)); mockMvc.perform(get(/api/users/1)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(张三)); } }这里有个很关键的点MockBean会把UserService替换成一个 Mock 对象注入到 Spring 容器里。所以你不需要准备任何真实数据只要定义好 Mock 行为请求就能顺利走通。使用WebMvcTest后ControllerAdvice、RestControllerAdvice这些全局异常处理也会被加载。如果你想验证某个异常被全局处理器捕获并返回 500可以这样写given(userService.getUserById(100L)) .willThrow(new BusinessException(用户不存在)); mockMvc.perform(get(/api/users/100)) .andExpect(status().isInternalServerError()) .andExpect(jsonPath($.message).value(用户不存在));Controller 测试最需要注意的是别把业务断言写在这里。Controller 只负责协议转换业务正确性应该由 Service 测试去保证。如果你发现 Controller 测试里塞了一大堆 Mock 和业务逻辑校验说明你的 Controller 太胖了该重构的是 Controller不是测试。3.2 Service 层什么时候用纯 Mockito什么时候用 SpringBootTestService 层是单元测试的主战场。这里有个很实际的选择题一个 Service 依赖了 Repository测试时是直接 Mock Repository还是把 Repository 也真实地拿来用我的经验是能用 Mockito 纯单测就纯单测。比如OrderService依赖UserRepository测试createOrder的等级校验逻辑时直接 MockUserRepository返回一个 level1 的用户然后断言抛出异常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.util.Optional; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; ExtendWith(MockitoExtension.class) class OrderServiceTest { Mock private UserRepository userRepository; InjectMocks private OrderService orderService; Test void createOrder_userLevelNotEnough_shouldThrow() { given(userRepository.findById(1L)) .willReturn(Optional.of(new User(1L, 1))); assertThatThrownBy(() - orderService.createOrder(new OrderRequest(1L))) .isInstanceOf(BusinessException.class) .hasMessageContaining(用户等级不足); } }这里Mock创建 Mock 对象InjectMocks把 Mock 注入到OrderService的构造器参数中。前提是OrderService的依赖是通过构造器注入的否则InjectMocks会失效这也是前面强调依赖注入的原因。什么时候必须用SpringBootTest呢当你的 Service 内部逻辑和 Spring 事务、AOP 切面、缓存注解强相关时纯 Mockito 不好模拟这些横切逻辑。比如你在 Service 方法上加了Transactional希望测试能验证事务回滚行为那用SpringBootTest加上真实数据源才有意义。但这类测试属于集成测试的范畴执行速度慢数量应当尽量少。3.3 Repository 层DataJpaTest 和内存数据库的边界Repository 层测试用DataJpaTest它只加载 JPA 相关组件不加载 Web 层和 Service 层。默认情况下Spring Boot 会用内嵌数据库比如 H2替换你的真实数据源。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.Optional; import static org.assertj.core.api.Assertions.assertThat; DataJpaTest class UserRepositoryTest { Autowired private UserRepository userRepository; Test void findByEmail_shouldReturnUser() { userRepository.save(new User(zhangsanexample.com, 张三)); OptionalUser result userRepository.findByEmail(zhangsanexample.com); assertThat(result).isPresent(); assertThat(result.get().getName()).isEqualTo(张三); } }这里要留个心眼H2 和你实际的 MySQL、PostgreSQL 在 SQL 方言上存在差异某些 SQL 语法或者字段类型映射在 H2 上能过不代表生产库没问题。如果你不确定某个查询语句的兼容性可以把AutoConfigureTestDatabase(replace AutoConfigureTestDatabase.Replace.NONE)加上让它使用真实数据源——当然这要求本机有可用的数据库一般在 CI 里配合 Testcontainers 或者独立测试库来做。最近不少项目在往 Testcontainers 方向走直接用真实数据库镜像跑测试虽然启动慢一点但能从根本上避免“H2 上过、生产挂”的情况。如果你维护的是查询复杂度很高的老项目这个投入很值。4. 测试数据与事务回滚让用例互不干扰的正确姿势4.1 为什么很多测试方法不需要你手动清理数据DataJpaTest和SpringBootTest配合Transactional时Spring 会在每条测试用例执行结束后自动回滚事务。也就是说你在测试方法里save进去的数据测试结束后不会留在数据库里。这让用例之间天然隔离你不需要写一堆AfterEach去deleteAll()。但注意一旦你在测试方法里手动改了事务的传播行为比如加了Transactional(propagation Propagation.REQUIRES_NEW)或者用了Rollback(false)那这条用例的写操作就会真实提交。这种操作通常只用于调试普通测试千万别用。我就见过同事写测试时为了“看数据方便”加了Rollback(false)结果 CI 跑完测试库越来越脏最后一堆用例莫名其妙互相关联。4.2 测试数据准备的三种姿势按场景选第一种是直接在测试代码里构造对象适合简单的单表操作。但对象字段一多构造代码会变得冗长所以很多人会封装工厂方法或 Builder。第二种是Sql注解。在测试类或测试方法上声明要预置的 SQL 脚本Test Sql(scripts /sql/init_user.sql, executionPhase Sql.ExecutionPhase.BEFORE_TEST_METHOD) void updateUser_shouldWork() { // 测试逻辑 }这适合预置复杂的主从表数据SQL 脚本维护起来比 Java 构造代码直观。缺点是 SQL 脚本和实体字段不同步时容易漏字段所以改完实体记得同步脚本。第三种是 Testcontainers。在 CI 环境里直接启一个 MySQL 或 PostgreSQL 容器测试连真实库跑。关于三种方式的取舍下面这张表可以帮你快速决策数据准备方式优点缺点适合场景测试代码构造直观、随代码走对象复杂时代码冗余简单 CRUDSql 脚本复杂数据一目了然脚本与实体容易脱节多表关联预置数据Testcontainers最大程度贴近生产启动慢、依赖 Docker查询逻辑复杂、方言依赖强4.3 “测试过了但上线挂了”的隔离性陷阱你可能会遇到一种诡异情况本地所有测试都通过但部署到生产环境就出问题。排除环境差异后大概率是测试之间互相污染了。一个经典场景是静态变量。如果代码里有静态缓存、静态计数器测试用例之间会共享这些状态。今早跑测试是绿的下午把某个测试类重排了一下执行顺序突然就红了。这类问题排查方式非常简单粗暴在每个BeforeEach里重置相关静态状态或者使用AfterEach清理。不要相信“每个测试自己管好自己”这种话静态状态必须显式收敛。另一个经典场景是测试方法里用了真实线程池、异步任务。Transactional只能保证当前线程的事务回滚异步线程的事务根本不受控制。如果测试代码里发起了异步任务记得在断言前置条件里等待任务完成否则就会出现偶发失败。这种偶发是最烦人的因为你很难本地复现只能靠排查链路慢慢找。5. 四个高频坑的完整排查链路Mock 失效、上下文加载失败、偶发失败与兼容性5.1 Mockito 的 when 没起作用查询还是走了真实方法这个问题几乎每个 Mockito 用户都遇过。你明明写了when(userRepository.findById(1L)).thenReturn(Optional.of(user))但跑测试时却走进了真实方法甚至连数据库都查了。排查链路是这样的第一步确认你用的是org.mockito.Mockito.when还是org.mockito.BDDMockito.given。两者本质等价但不要在同一个测试类里混用不然读起来很乱。第二步检查 Mock 对象是否真的被注入到了被测类里。常见错误是用Autowired拿到被测类但 Mock 对象没有通过InjectMocks注入进去。Spring 容器里的 Bean 是原来的实现Mock 根本没替换成功。解决办法是让测试类内部通过InjectMocks或构造器方式创建被测对象而不是依赖 Spring 容器。第三步检查when里的参数是否和实际调用完全匹配。比如findById(1L)实际调用时传的是long类型自动装箱后是Long但实际上 1 在编译时可能被当成 int。这里要用1L明确指定 Long 类型。如果参数有很多字段建议用any()或anyLong()匹配减少匹配失败的可能性。5.2 上下文加载失败从报错堆栈倒推配置问题上下文加载失败最常见的报错是Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name xxx这个堆栈信息量大但排查时不要从头读直接找Caused by后面的根因。比如Caused by: java.lang.IllegalArgumentException: Could not resolve placeholder redis.host这说明测试启动时配置文件里缺少redis.host这个占位符。解决方案是在src/test/resources/application.yml里补齐测试专用配置。要注意测试配置不会覆盖主配置但能补充缺失值。所以测试目录下的配置一般只维护测试需要的环境变量、内存数据库、Mock 服务地址。另一个常见情况是SpringBootTest遇到了多个配置类或者有EnableScheduling导致定时任务启动测试上下文被各种后台线程拖住。这时可以考虑加MockBean把定时任务组件 Mock 掉或者用SpringBootTest(properties spring.task.scheduling.enabledfalse)关闭调度。5.3 偶发失败从“今天绿明天红”到锁定时区偶发失败最典型的元凶有三个时间和随机数、线程并发、外部依赖不稳定。时间相关的问题我遇到最多。比如测试里断言“创建时间早于当前时间”如果创建时间是LocalDateTime.now()那测试理论上永远能过。但如果你断言了一个精确到秒的时间字符串而代码在毫秒级完成了创建两边就可能差一毫秒导致失败。解决办法是在测试里使用固定时钟Clock clock Clock.fixed(Instant.parse(2024-01-01T00:00:00Z), ZoneId.of(UTC));然后在被测类里注入这个Clock。如果你的代码里到处都是LocalDateTime.now()那这个偶发问题会伴随着项目很长一段时间因为修复它意味着把时间源统一收口。线程并行的问题在上文提过核心思路还是收敛异步行为测试里尽量同步执行。外部依赖不稳定比如测试连了真实 Redis、真实第三方 API那失败就完全不奇怪了。测试必须可控要么 Mock要么 Testcontainers绝不能在测试里依赖外部环境。5.4 兼容性问题版本升级后的突发失败版本升级引发的测试失败往往不是测试代码本身有问题而是框架行为变了。除了前面提到的 springfox 与 Spring Boot 2.6 路径匹配问题另一个高频场景是 JSON 序列化库从 Jackson 1.x 升到 2.x时间字段的默认格式变了导致jsonPath断言对不上。这类问题排查时先把 Spring Boot、springfox、jackson 的版本变更记录翻一遍关注“默认值变更”“Deprecated”这类关键词基本能定位。如果时间紧可以用git log看这次升级改了什么配置把旧配置临时加回去再逐步移除找到最小化有效的配置。6. 把测试写进日常从“凑覆盖率”到“改代码有底气”的三个习惯6.1 测试命名规范比想象的更重要测试方法名没人读但测试失败时的报错信息会直接暴露方法名。用业务方法_场景_期望结果这种三段式命名比如getUserById_userNotExist_shouldThrowException失败时看名字大概就能猜到问题出在哪。配合DisplayName(用户不存在时抛出异常)测试报告直接就是需求文档。6.2 覆盖率是工具不是 KPI很多团队把 80% 覆盖率挂在嘴边对着 JaCoCo 报告各种补测试。但覆盖率只能证明代码被执行过不能证明行为被验证过。我见过一个项目覆盖率 90%但大量测试都是在测 Getter、Setter 和空方法核心业务逻辑没几条有效断言。更务实的做法是把覆盖率报告当作发现盲区的工具哪个类的行覆盖率和分支覆盖率明显低于平均水平就说明哪些逻辑最不受保护优先补那里的测试。加上变异测试比如 PIT可以进一步验证断言是不是真的有效但这属于进阶玩法先把基础测试写好再说。6.3 把测试跑进 CI用失败当预警本地测试全绿提交后 CI 挂了这件事本身不可怕可怕的是团队形成“CI 红了先不管后面有空再修”的默契。正确做法是测试是提交的闸门不绿不许合入。为了保证开发者体验可以拆分快速测试和慢速集成测试用 Maven Profile 或 JUnit 标签来区分日常开发只跑快速测试CI 合并前跑全量。比如mvn test -Dgroupsunit mvn test -Dgroupsintegration用 JUnit 5 的Tag注解可以给测试打标签Tag(unit)、Tag(integration)然后通过-Dgroups控制跑哪一组。这样一个 Service 的纯 Mock 测试几百个跑完也就几秒而真正连数据库的集成测试在合并前统一跑开发节奏和安全性都兼顾了。写到这里你会发现所谓“测得爽”没有太多神秘技巧无非是把测试用例的粒度切小、依赖隔离干净、数据准备可控再配合一组顺手的环境配置。这套流程跑顺之后最直观的变化其实不在测试报告上而是改代码的时候敢重构了。以前改一个 Service 方法手都是抖的因为不知道哪条调用链会炸现在测试用例就在那摆着改完跑一遍就知道有没有破坏行为。这种安全感才是单元测试真正值钱的地方。