Spring Boot单元测试实战:JUnit 5与Mockito从入门到坑排查
2026/9/9 12:01:16 网站建设 项目流程

有段时间,我把“写单元测试”这件事排在了所有开发任务的最后。不是不想写,是真不敢碰。一提到 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,只要配置没变,后续测试类会复用同一个上下文。所以真正的问题不是“启动慢”,而是你为了测一个UserServicegetUserById方法,把整个应用的 Bean 全部实例化了一遍。这属于典型的杀鸡用牛刀。

正确思路是:能用切片测试就用切片测试,能纯 Mock 就纯 Mock。后文会具体讲每个层次的测试怎么选型。这里只需要记住一个判断标准:你的测试涉及到的 Bean 越多,定位失败时越困难,执行也越慢。单元测试的核心是“隔离”,不是“完整性”。

1.2 测试不好写的另一个原因:代码耦合度高,不是 JUnit 的锅

我在很多项目里见过这样的 Service:

public 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</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.propertiesapplication.yml里加一行配置,把路径匹配策略改回去:

spring.mvc.pathmatch.matching-strategy=ant_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,什么时候用 @SpringBootTest

Service 层是单元测试的主战场。这里有个很实际的选择题:一个 Service 依赖了 Repository,测试时是直接 Mock Repository,还是把 Repository 也真实地拿来用?

我的经验是:能用 Mockito 纯单测就纯单测。比如OrderService依赖UserRepository,测试createOrder的等级校验逻辑时,直接 MockUserRepository返回一个 level=1 的用户,然后断言抛出异常:

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("zhangsan@example.com", "张三")); Optional<User> result = userRepository.findByEmail("zhangsan@example.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进去的数据,测试结束后不会留在数据库里。这让用例之间天然隔离,你不需要写一堆@AfterEachdeleteAll()

但注意:一旦你在测试方法里手动改了事务的传播行为,比如加了@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 容器,测试连真实库跑。关于三种方式的取舍,下面这张表可以帮你快速决策:

数据准备方式优点缺点适合场景
测试代码构造直观、随代码走对象复杂时代码冗余简单 CRUD
@Sql 脚本复杂数据一目了然脚本与实体容易脱节多表关联预置数据
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.enabled=false")关闭调度。

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 -Dgroups="unit" mvn test -Dgroups="integration"

用 JUnit 5 的@Tag注解可以给测试打标签,@Tag("unit")@Tag("integration"),然后通过-Dgroups控制跑哪一组。这样一个 Service 的纯 Mock 测试几百个跑完也就几秒,而真正连数据库的集成测试在合并前统一跑,开发节奏和安全性都兼顾了。

写到这里,你会发现所谓“测得爽”没有太多神秘技巧,无非是把测试用例的粒度切小、依赖隔离干净、数据准备可控,再配合一组顺手的环境配置。这套流程跑顺之后,最直观的变化其实不在测试报告上,而是改代码的时候敢重构了。以前改一个 Service 方法,手都是抖的,因为不知道哪条调用链会炸;现在测试用例就在那摆着,改完跑一遍就知道有没有破坏行为。这种安全感,才是单元测试真正值钱的地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询