☰
Spring Boot 3.0 + Mockito 5.0 实战:手写高覆盖率Java单元测试
2026/10/21 9:47:44 网站建设 项目流程

Spring Boot 3.0 + Mockito 5.0实战:手把手教你写高覆盖率的Java单元测试

写单元测试这件事,很多团队都在做,但真正把覆盖率做上去、把测试质量做好的项目并不多。我在接手某些遗留系统、以及搭建新服务的基础设施时,最大的痛点往往不是业务代码难写,而是测试代码根本无从下手——要么是Mock方式老旧、要么是Spring上下文启动太慢、要么是覆盖率低到不好意思看。Spring Boot 3.0搭配Mockito 5.0,这套组合解决了不少老版本的历史包袱,但很多人升级之后反而不会写测试了,因为API变了、初始化方式变了、连注解的行为都微妙地不一样了。

这篇文章不聊虚的,我用一个真实的模拟项目X来演示,从环境搭建开始,把Service层、Repository层、Controller层、还有外部API调用的测试全部过一遍,包括覆盖率工具Jacoco的集成和分析。目标是让你看完之后,能直接照着一套思路往自己的项目里套,踩过的坑、调过的参、绕过的弯,我都会明明白白写出来。

1. 为什么要盯紧单元测试和覆盖率

1.1 覆盖率数字背后的真实含义

很多人对行覆盖率(Line Coverage)有误解,觉得覆盖率数字高等于代码质量高。在项目实战里,行覆盖率只是最基础的度量指标,它表示你测试执行到的代码行数占比,但分支覆盖率(Branch Coverage)才更接近测试的真实有效性。比如一段if (a && b)的条件判断,全真的走一遍,行覆盖可能已经100%了,但分支覆盖就50%,这意味着a真、b假和a假、b真这两条路径完全没人管,将来改条件的时候大概率翻车。

我自己常用的做法是,行覆盖率必须过80%,分支覆盖率尽量过70%。如果某个模块分支覆盖率特别低,我第一时间怀疑的是断言写得太稀松,或者根本就只验证了"有没有调某个方法",而不是验证"返回结果对不对"。

1.2 单元测试在Spring Boot 3.0中的定位变化

Spring Boot 3.0基于Spring Framework 6,基础要求是Java 17以上,整个生态也开始全面拥抱Jakarta EE命名空间。这意味着老一套javax.servlet、javax.persistence的导入统统要换成jakarta.*。很多老项目升级到Spring Boot 3.0之后,第一波编译报错几乎全是在这些导入上,这种迁移问题会直接影响测试环境和本地调试的体验。

对单元测试来说,最大的变化在于Spring Boot 3.0强调"精简上下文"这个理念。一个单元测试要是把整个Spring上下文都拉起来,动辄几十秒起步,这在以前大家还能忍,但在现在强调快速反馈的开发流程里,完全拖节奏。实际项目里我更多采用Mockito直接@InjectMocks手动new出被测类,或者用@WebMvcTest只切Controller层,减少加载范围,这一整个思路在后面会展开。

2. 手写Service层测试的真正难点

2.1 使用Mockito 5.0初始化Mock对象

先说环境。用Spring Boot 3.0.2 + Mockito 5.1.0 + JUnit 5.9.2这套组合,pom.xml里最关键的是要引入spring-boot-starter-test,它内部会带JUnit 5和Mockito,版本由Spring Boot的BOM统一管理,不用自己瞎填版本号,不然容易跟Spring Boot 3.0不兼容。

但有一个点必须单独注意:Mockito 5.0默认不支持mock final类和final方法。Java 17本身没有这个限制,Mockito 5默认用的是自带的Inline MockMaker,如果测试中遇到"InlineByteBuddyMockMaker requires ..."之类的报错,别慌,这不是版本冲突,多半是你项目里还有老版本的mockito-core或byte-buddy依赖没有清理干净。我自己遇到过一次,排查了两个小时,最后发现是某个传递依赖把mockito-core 4.11拉进来了,版本被覆盖,行为反而退化。

初始化Mock对象有两种方式。第一种是纯代码:

UserService userService = mock(UserService.class); when(userService.getUserById(1L)).thenReturn(new User(1L, "张三"));

第二种是注解方式:

@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; }

注解方式在Spring Boot 3.0 + Mockito 5.0的项目里最推荐,因为@ExtendWith(MockitoExtension.class)可以跟JUnit 5无缝集成,每个测试类只加载Mockito上下文,完全不碰Spring容器,单测跑起来毫秒级完成。

2.2 打桩进阶:doReturn与when的取舍

有朋友问过,doReturn().when()和when().thenReturn()这两种打桩方式到底有什么区别,实际用起来哪个好?我给出的答案是分场景。

常规方法调用,比如查询用户列表、按ID查询记录,用when(...).thenReturn(...)最直观,代码读起来跟自然语言似的,非常舒服。但是遇到void方法、或者方法内部抛异常的case,when写法经常出问题。

举个例子,删除用户的方法设计为void:

userService.deleteUser(1L);

如果你用when(userService.deleteUser(1L)).thenReturn(null)这种写法,编译期可能就过不去,或者运行时报错,因为when需要接收一个有返回值的方法调用。这时候应该改用:

doNothing().when(userService).deleteUser(1L); doThrow(new RuntimeException("删除失败")).when(userService).deleteUser(2L);

还有一个比较隐蔽的场景,就是被测方法内部会多次调用同一个mock对象的方法,第一次和第二次返回值不同。用when写起来极其别扭,用doReturn配合连续调用会清晰很多:

doReturn("第一次").doReturn("第二次").when(userService).getStatus(1L);

这种写法在模拟"缓存穿透后再查库"的业务场景里尤其管用,一开始返回旧值,第二次返回新值,能把你想要的分支逻辑完整测到。

2.3 参数匹配器的正确打开方式

Mockito的参数匹配器是另一个高频使用的点。any()、anyLong()、eq()这些匹配器看着简单,用错了照样翻车。有一个铁律:所有参数都匹配,要么全用精确值,要么全用matcher,不能混着来。

错误写法示范:

when(userService.updateUser(1L, any(User.class))).thenReturn(true);

这个代码在Mockito 5.0里会直接抛InvalidUseOfMatchersException,因为一个参数用了精确值1L,另一个参数用了any()匹配器,两者混用不被允许。正确写法是:

when(userService.updateUser(eq(1L), any(User.class))).thenReturn(true);

另外提醒一下,any()和any(User.class)在行为上略微有所不同,前者不会做类型检查,后者会进行类型匹配。对于强类型代码来说,推荐用any(User.class)这种形式,更容易在测试阶段发现问题。

3. 数据层测试怎么做才不踩坑

3.1 Repository Mock方案的确定

有人说用@DataJpaTest做数据层测试才是正路,这个我部分同意,但注意它本质上是集成测试,它会真的去连数据库(哪怕是用H2内存库),而且会加载JPA相关的自动配置。数据层测试有个比较矛盾的地方:如果Repository方法是你自己写的@Query,那确实该用@DataJpaTest来验证SQL对不对;但如果只是Spring Data JPA根据方法名自动生成的查询,比如findByUsernameAndStatus,那单元测试里直接把Repository mock掉就好,完全没必要拉数据库。

在我那个模拟项目X里,业务逻辑是这样的:用户会调用getUserOrders方法,Service层先查用户是否存在,如果不存在直接抛异常,存在则再查订单列表。这种场景的单元测试,Repository用@Mock注入即可:

@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private UserRepository userRepository; @InjectMocks private OrderService orderService; @Test void getUserOrders_userNotExist_shouldThrowException() { when(userRepository.findById(99L)).thenReturn(Optional.empty()); assertThrows(UserNotFoundException.class, () -> orderService.getUserOrders(99L)); verify(orderRepository, never()).findByUserId(99L); } }

这里verify(orderRepository, never())非常关键,它的作用是验证当用户不存在时,后面的订单查询逻辑确实没有执行。这种"业务链路是否短路"的验证,比单纯测一个返回值有意义得多,能有效防止将来有人把代码改成先查订单再校验用户。

3.2 使用Mockito的Answer来模拟复杂数据库行为

有时候Mockito自带的thenReturn已经不够用了。比如分页查询,需要根据传入的Pageable返回对应的内容,这时候用一个自定义Answer会更灵活:

when(orderRepository.findByUserId(eq(1L), any(Pageable.class))) .thenAnswer(invocation -> { Pageable pageable = invocation.getArgument(1); List<Order> orders = new ArrayList<>(); for (int i = 0; i < 5; i++) { orders.add(new Order((long) i, "ORDER" + i)); } return new PageImpl<>(orders, pageable, orders.size()); });

我当时写这段的动机是:如果只用thenReturn(new PageImpl<>(...)),不论测试里传什么Pageable,返回的都是固定的那几行数据。一旦测试新增了"第二页是否有数据"、"每页条数是否生效"这类断言,这个mock就完全不可控了。用thenAnswer可以把入参读出来再动态构造返回值,测试的覆盖面和真实度都上一个台阶。

注意:mock返回PageImpl时,PageImpl构造器的分页信息来自total和content,如果你希望getTotalPages()计算正确,total必须给准。

4. Controller层测试:@WebMvcTest的适用边界

4.1 用@WebMvcTest只测Web层

如果某个Controller依赖了很多Service,而你想快速验证接口的路由和参数校验逻辑,@WebMvcTest是极好的选择。只加载Controller、ControllerAdvice、Filter等Web层的Bean,Service用@MockBean替代。测试启动快,依赖隔离干净。

一个典型的Controller测试:

@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void getUserById_shouldReturnUser() throws Exception { when(userService.getUserById(1L)) .thenReturn(new User(1L, "张三", 28)); mockMvc.perform(get("/api/users/{id}", 1L) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("张三")); } }

jsonPath这套API挺好用的,但也要注意,如果Controller返回的是一个包装对象(比如统一的Result<T>结构),断言的时候要带层级,比如$.data.name,很多刚上手的朋友在这里踩坑,老对不上。

4.2 MockBean与Mockito自带的Mock选择问题

@MockBean是Spring Boot提供的注解,它会把mock对象注册到Spring的应用上下文里,替换掉原本的Bean。这里有个性能上的隐忧:@MockBean每次往上下文里塞mock对象,实际上会改动ApplicationContext的缓存,导致测试上下文重启。大量使用@MockBean的时候,测试变慢就是这个原因。

Mockito 5搭配新版Spring Boot 3,还有另外一个选择是@MockitoBean,这是Spring Framework 6.1才引入的新注解,目的就是为了替代@MockBean,减少对上下文的破坏性修改。如果你用的Spring Boot版本足够新,优先考虑@MockitoBean。

但如果只是单元测试(不拉起Spring上下文),就在类上用@ExtendWith(MockitoExtension.class)配合@Mock,这是最干净的做法。一句话总结:测Controller用@WebMvcTest+@MockitoBean(或@MockBean),测Service用@Mock+@InjectMocks,别混着来。

5. 外部API调用怎么Mock才安全

5.1 用Mockito替身隔离Http Client

我负责的模拟项目X中有个模块,要调用外部系统的接口获取汇率数据。这类外部调用在测试环境里最烦人:网络不稳定、接口限流、返回数据格式不固定。

我的习惯是定义一个接口,比如ExchangeRateClient,然后生产环境注入一个基于RestTemplate或WebClient的实现,测试的时候就mock这个接口,完全不牵涉网络。这也是依赖倒置原则在测试中的典型应用。

public interface ExchangeRateClient { BigDecimal getRate(String baseCurrency, String targetCurrency); }

测试类里直接:

@ExtendWith(MockitoExtension.class) class CurrencyServiceTest { @Mock private ExchangeRateClient rateClient; @InjectMocks private CurrencyService currencyService; @Test void convertCurrency_withMockRate_shouldSuccess() { when(rateClient.getRate("USD", "CNY")) .thenReturn(new BigDecimal("7.15")); BigDecimal result = currencyService.convert(100, "USD", "CNY"); assertEquals(new BigDecimal("715.00"), result); } }

5.2 超时与重试逻辑的测试技巧

外部API调用通常还伴随超时和重试逻辑,这块的测试要专门设计。Mockito本身可以直接模拟抛出超时异常:

when(rateClient.getRate("USD", "CNY")) .thenThrow(new ReadTimeoutException("timeout")) .thenReturn(new BigDecimal("7.15"));

这里比较精髓的是thenThrow接thenReturn的组合,也就是说第一次调用抛异常,第二次调用正常返回。代码中如果写了retryTemplate.execute(...)或者for循环重试,这个Mock能完美模拟"第一次失败、第二次成功"的场景,从而验证重试机制确实起作用了。光验证正常路径不验证异常路径,覆盖率上去了但有效性不足,这种双重验证才能证明设计意图。

6. 提高覆盖率不光靠写用例,还要靠工具链

6.1 Jacoco配置与报告生成

写测试用例是提高覆盖率的第一手段,但没有人能拍胸脯说所有分支我都想到了,所以必须用工具来量化。Jacoco是目前最主流的Java覆盖率工具,跟Maven集成非常简单:

<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.11</version> <executions> <execution> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>verify</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin>

执行mvn test之后,运行mvn verify或者直接查看target/site/jacoco/index.html,就能得到很直观的覆盖率报告。报告中会按类、方法、行、分支四个维度展示覆盖率。我拿到报告后一般先看那些低于50%的类,大部分情况下会发现是Controller层的参数校验逻辑没测到,这类逻辑虽然简单但分支多,值得补用例。

6.2 覆盖率阈值的设置别太乐观

很多团队推行覆盖率卡点,但阈值设得很随意。有次我见到一个项目的jacoco.check规则里写了<counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.80</minimum>,表面看着挺美,实际一跑,发现一堆工具类、配置类根本没有单元测试,覆盖率直接被平均分拉下来了,开发者就会选择把阈值降低,而不是补测试。

我更推荐的做法是用excludes把这些非业务类排除掉,然后对核心业务模块单独设高阈值:

<configuration> <excludes> <exclude>**/config/**</exclude> <exclude>**/entity/**</exclude> <exclude>**/dto/**</exclude> </excludes> <rules> <rule> <element>BUNDLE</element> <limits> <limit> <counter>LINE</counter> <value>COVEREDRATIO</value> <minimum>0.85</minimum> </limit> </limits> </rule> </rules> </configuration>

实体类、DTO、配置类这些纯数据结构,测试价值不高,把它们排除在统计之外,阈值才能真正用于约束业务逻辑的测试质量。

6.3 哪些代码不值得花精力测

讲覆盖率很容易走向"为了数字而数字"的歪路。我的经验是,并不是所有代码都要追求90%以上的覆盖率,有些代码测试成本高、收益却很低:

  • 纯Getter/Setter:Lombok都帮你生成好了,测它没有意义。
  • 框架胶水代码:比如配置类里注册一堆Bean的@Bean方法,测它也没啥意思。
  • 简单的直出DTO转换:如果只是字段复制,用BeanUtils.copyProperties或者MapStruct这类工具即可,写一堆断言反而拖慢迭代。
  • 序列化、反射相关的封装:这种代码依赖底层实现,单测Mock很难模拟真实行为,留到集成测试阶段去覆盖更合理。

把时间花在业务规则、状态流转、权限判断、金额计算、异常分支上,这些才是Bug真正藏身的地方。

7. 实操中常见的Mockito疑难杂症

7.1 静态方法如何Mock

Mockito 3.4以后开始支持mock静态方法,Mockito 5也保留了这一能力。比如工具类IdGenerator.generate()需要被mock:

try (MockedStatic<IdGenerator> mockedStatic = mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::generate).thenReturn("CUSTOM_ID_001"); String id = orderService.createOrder(...); assertEquals("CUSTOM_ID_001", id); }

这里有几个重点提醒:MockedStatic必须用try-with-resources包裹,否则静态mock的作用域会扩散到其他测试,造成各种诡异问题;其次,不是特别必要的话不建议mock静态方法,因为它的实现是基于字节码插桩的,用多了会拉慢测试速度,而且容易跟并行测试冲突。

7.2 构造方法mock与@InjectMocks的局限性

如果被测类是通过构造器注入依赖的,那@InjectMocks会用构造器完成注入,这也是最推荐的模式。但如果被测类用的是setter注入或字段注入,@InjectMocks也能处理,不过容易出现问题。

举个例子,被测类有两个构造器,一个参数多、一个参数少,@InjectMocks会挑参数最多的那个构造器,如果某些参数没有对应的mock,就会传入null,测试时再调用依赖就空指针了。这种问题排查起来很麻烦。我自己更倾向于在测试代码里手动创建被测对象:

UserService userService = new UserService(userRepository, orderRepository);

这样能完全掌控要传入哪些依赖、传什么值,比@InjectMocks的"自动注入"更可控。一旦测试复杂起来,可读性和可维护性都更好。

7.3 没有必要verify的操作

verify()是Mockito里很常用的验证方法,但也不见得每个交互都要verify一遍。我见过不少测试里写满了verify(userRepository).findById(1L)这种断言,先不说它有没有实际意义,单是这类验证就把测试变得冗长繁琐。

我一般只在两种情况下使用verify():

  • 验证某个方法没有被调用,比如前置校验失败后,核心流程不应该执行,这是保护性断言;
  • 验证某个方法是按照预期被调用了特定次数,比如重试机制中,retryTemplate执行了3次,这时候verify(..., times(3))意义重大。

单纯为了证明"我mock的接口被调了"而verify,那是给自己添堵,覆盖率数字也不会因此变好。

7.4 测试类的组织结构

最后分享一下我的测试代码组织习惯。一个被测试的类,对应一个测试类,放在src/test/java下与主代码同包名的路径里。测试方法命名采用"方法名_状态描述_预期行为"三段式,例如:

@Test void convertCurrency_rateNotFound_shouldThrowException() { ... } @Test void convertCurrency_amountZero_shouldReturnZero() { ... }

这种命名方式最大的好处是:测试失败时,从方法名就能立刻看出是在测什么状态,完全不用再去看代码。我有次凌晨排查一个构建失败,看到测试报告里的方法名,几秒钟就定位到了汇率配置问题,这就是好命名的价值。

8. 争议与反思:高覆盖率是否等于高质量

讨论到最后,聊聊一个老话题:覆盖率数字高了,就说明代码质量高吗?

我的答案比较直接:高覆盖率只是高质量的必要不充分条件。覆盖率低,基本上可以断定测试是缺失的;但覆盖率高,也可能是测试一堆、断言一堆、没有挑战核心逻辑的"塑料测试"。

我见过最极端的例子是,有人为了把覆盖率从85%顶到95%,硬写了十几个测试用例,每个用例都是在测试"方法正常执行、返回预期值"这种happy path,对于异常路径、边界值、并发情况完全没cover。覆盖率是上去了,但代码里的隐含Bug一个没少。

所以后来我在团队里推行的思路是:覆盖率不是目标,质量才是。覆盖率卡点只是一个底线工具,真正有价值的是测试用例的设计是否抓住了业务的核心风险点。写完一组测试,我会问自己几个问题:核心业务规则有没有被测试锁定?关键异常分支有没有被模拟?外部依赖的行为变化有没有被隔离?如果这些问题的答案都是肯定的,那覆盖率低一些也没那么可怕;如果覆盖率很高但这些问题一个都答不上来,那这个测试套件反而是负资产。

在我自己做技术选型和代码评审时,如果一个PR没有带着核心分支的测试,覆盖率再高我也不会给过;反过来,如果一个PR的测试用例设计锻炼了我的业务理解,哪怕覆盖率差一点,我也愿意给机会等他补充用例。

写测试是一个长期投入的事情,现在省下来的时间,终将在某个深夜的生产事故中连本带利地还回去。动手从你项目里最核心的一段逻辑开始,写一个真正能验证它的测试。一个好的测试,不仅仅是让覆盖率数字好看,更是给未来的维护者一张安全网。就我个人体验而言,当测试套件真正可靠的时候,你会明显感觉到改动代码的胆子大了一些,这种自信感,比任何覆盖率报告都来得真实。

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

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

立即咨询