☰
JUnit 5与Mockito实战指南:构建可维护的单元测试体系
2026/10/20 9:24:18 网站建设 项目流程

工作这几年,我接手过好几个维护成本高到离谱的遗留系统,最后发现一个共性规律:要么是单元测试覆盖率形同虚设,要么是测试代码写得比业务代码还难懂。所以当我决定给团队立一套测试规范时,第一件事就是选定 JUnit 5 和 Mockito 这套组合,并整理成一份能直接照着写的实战教程。今天把它分享出来,就是希望你在搭测试体系时少走点弯路,不管是刚入行的新人,还是被复杂依赖折磨的老手,都能从这里拿到可以直接抄作业的方案。

这套组合能解决的问题很具体:JUnit 5 负责测试的生命周期管理、断言和参数化执行,Mockito 负责把外部依赖(数据库、远程服务、消息队列)替换成可控的替身对象。两者配合,你可以把一段整天跟外部系统耦合的代码,变成一个在本地几十毫秒跑完、结果可精准验证的测试单元。下面这份教程,严格按我实际落地的路径来写,每一步都标注了为什么这么做。

1. 项目整体设计与技术选型思路

1.1 为什么在这个时间点全面转向 JUnit 5 与 Mockito

先说一个容易忽略的背景:JUnit 4 统治了 Java 测试领域非常多年,但它的设计停留在注解驱动加反射的年代,很多能力是靠扩展包硬凑出来的。比如参数化测试在 JUnit 4 里写起来非常啰嗦,需要写专门的 Runner;动态条件跳过测试基本做不到;断言库和第三方工具之间也经常有版本兼容问题。JUnit 5 把架构彻底拆成了三个模块:JUnit Platform(测试启动入口)、JUnit Jupiter(新编程模型)、JUnit Vintage(兼容老代码的引擎),这样设计的好处是底层扩展点干净了,上层写测试的方式更灵活,也更容易跟 Gradle、Maven、IDE 集成。

我用 JUnit 5 之前,团队里最常见的做法是 JUnit 4 加 Hamcrest 加一个自己封装的反射工具类,每次写 mock 还要手动 new 一个实现类。后来升级时最痛苦的并不是写测试,而是跟历史遗留的断言表达式做斗争。JUnit 5 的 Assertions 系列把常见断言收得很完整,assertEquals、assertThrows、assertTimeoutPreemptively、assertAll 都是现成的,配合 lambda 表达式写起来非常顺手。而 Mockito 到 4.x/5.x 之后 API 又做了大幅精简,when(...).thenReturn(...) 这一套核心用法几乎没变过,学习成本极低。

我的选型结论很干脆:新项目直接上 JUnit 5 + Mockito 5,老项目用 JUnit Vintage 迁移过渡,不要搞两套并存太久,因为一旦测试代码分散在两套模型里,维护成本会成倍上升。

1.2 测试金字塔与资源投入比

单元测试在整个测试策略里的定位,可以用一句话概括:用最便宜、最快、最稳定的手段,把代码的基本正确性和回归风险锁死在提交之前。我见过不少团队过于迷信端到端测试,结果就是测试跑一次要二十分钟,一遇到网络抖动就全挂,最后大家都不愿跑。正确的做法是把大部分测试放在单元测试这一层,让它成为代码提交和持续集成的第一道闸门。

具体比例上,我一般按 70% 的单元测试、20% 的集成测试、10% 的端到端测试来分配精力。单元测试覆盖核心业务逻辑和工具类;集成测试只用来验证数据库映射、接口序列化、配置加载这些问题;端到端测试只保留用户主路径的冒烟场景。这样分配的原因不复杂:单元测试执行速度最快,隔离性最好,随手可以跑一万个也不心疼;端到端测试每增加一个,整套流程的脆弱度就上升一个级别。

我团队里那条硬性规定是,核心业务代码的分支覆盖率不得低于 80%,但覆盖率不是硬指标,重点在于每个分支都有人真正跑过一遍。宁可少写几个泛泛而谈的“happy path”用例,也要把异常分支、边界值、空值场景补上。

2. 环境搭建:从零开始的第一份单测代码

2.1 依赖引入与版本选择

用 Maven 的话,在 pom.xml 里加上下面这些依赖就能起步。我建议显式固定版本,别用父工程里碰巧继承进来的版本,因为测试框架和 Spring Boot 版本之间偶尔会有细微的兼容问题,显式声明能让构建更可预测。

<properties> <junit.jupiter.version>5.10.2</junit.jupiter.version> <mockito.version>5.11.0</mockito.version> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> </dependencies>

这里有个小坑:mockito-junit-jupiter 这个桥接包很多项目会漏掉。它的作用是把 Mockito 的 mock 初始化和 JUnit 5 的生命周期打通,让你可以直接用 @ExtendWith(MockitoExtension.class),否则 @Mock 注解不会自动生效。另一个常见问题是只引入 junit-jupiter-api 而没有引入 junit-jupiter-engine,结果 IDE 或者命令行里跑了半天一个测试都没发现。省事的做法是直接引入 junit-jupiter 这个聚合包,它会把你需要的东西都带进来。

Gradle 用户对应这样写:

testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'org.mockito:mockito-core:5.11.0' testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0' test { useJUnitPlatform() }

注意 useJUnitPlatform() 这一步别省略,Gradle 默认不会自动识别 JUnit 5 的测试用例,不声明的话跑起来就是零测试。

2.2 第一个 JUnit 5 测试类长什么样

先写一个最简单的计算器工具类,再给它配一个测试类,走一遍完整流程。

public class Calculator { public int add(int a, int b) { return a + b; } public int divide(int a, int b) { if (b == 0) { throw new IllegalArgumentException("除数不能为0"); } return a / b; } }

对应的测试类:

import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class CalculatorTest { @Test @DisplayName("加法应该返回两数之和") void shouldAddTwoNumbers() { Calculator calculator = new Calculator(); int result = calculator.add(2, 3); assertEquals(5, result); } @Test @DisplayName("除数为0时应该抛出异常") void shouldThrowExceptionWhenDivideByZero() { Calculator calculator = new Calculator(); assertThrows(IllegalArgumentException.class, () -> calculator.divide(10, 0)); } }

这里面有两条命名上的实战约定:测试类名用被测试类名加 Test 后缀,测试方法名用“should + 预期行为”的英文短语,配合 @DisplayName 写中文描述。这么做的好处是,测试报告读起来像一份需求说明,哪里断了马上能定位是哪条行为出了问题。

断言这块我建议优先用 assertEquals、assertTrue、assertFalse、assertNotNull、assertThrows 这几个,不要再引入 Hamcrest 那套 matcher,否则整个项目会出现两套断言风格,新老员工写出来的测试没法看。JUnit 5 的 assertAll 很适合做多个字段的联合断言,它会把所有失败项一次性列出来,而不是遇到第一个失败就停。

@Test void shouldCreateOrderWithAllFields() { Order order = createTestOrder(); assertAll("订单字段检查", () -> assertEquals("CN123", order.getOrderNo()), () -> assertEquals(99.5, order.getAmount()), () -> assertNotNull(order.getCreateTime()) ); }

2.3 生命周期钩子与执行顺序

JUnit 5 提供了四个生命周期注解:@BeforeAll、@BeforeEach、@AfterEach、@AfterAll。它们解决的核心问题是资源准备和清理:哪些东西所有测试类只执行一次,哪些东西每个用例都要重新造一遍。我的习惯是:

  • @BeforeAll:初始化成本较高的共享资源,比如数据库连接池、Mockito 的全局配置、测试容器;
  • @BeforeEach:每个用例独立的测试数据、新建出来的对象、重置 mock 调用记录;
  • @AfterEach:清理本次用例产生的文件拷备、关闭临时目录、恢复静态状态;
  • @AfterAll:释放所有用例共用的大资源。

一个常见错误是把 @BeforeAll 方法写成实例方法然后在里面初始化一些跟用例相关的状态,结果因为执行顺序不确定导致测试互相污染。JUnit 5 里 @BeforeAll 必须写 static 方法,除非你在类上声明 @TestInstance(Lifecycle.PER_CLASS)。这背后的逻辑是默认每个测试方法都会新建一个测试实例,而 @BeforeAll 要在任何实例创建之前执行,所以只能用静态方法。

如果你对测试方法执行顺序有依赖,可以在类上标注 @TestMethodOrder(MethodOrderer.OrderAnnotation.class),再给方法加 @Order(1)、@Order(2)。但我个人强烈不建议把测试写成强顺序依赖,因为单个用例该是独立的,顺序依赖一多,排错成本会直线上升。真要跑流程类测试,请用集成测试来承担,而不是单元测试。

3. Mockito 核心实战:可控的依赖才是好依赖

3.1 mock、when 与 verify 三板斧

JUnit 解决的是“怎么跑、怎么断言”,Mockito 解决的是“依赖怎么替换、调用怎么验证”。两者结合之后,你可以把 UserService 里的 UserRepository 换成 mock,然后决定它返回什么、记录它被调用几次,彻底摆脱测试数据库和外部 API 的限制。

先看一个最典型的业务场景:用户注册逻辑,它依赖 UserRepository 检查手机号是否已存在,依赖 SmsClient 发送验证码。

public class UserService { private final UserRepository userRepository; private final SmsClient smsClient; public UserService(UserRepository userRepository, SmsClient smsClient) { this.userRepository = userRepository; this.smsClient = smsClient; } public boolean register(String phone, String password) { if (userRepository.existsByPhone(phone)) { return false; } userRepository.save(new User(phone, password)); smsClient.send(phone, "注册成功"); return true; } }

测试类长这样:

import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.Mockito.*; import static org.junit.jupiter.api.Assertions.*; @ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private SmsClient smsClient; @Test void shouldRegisterUserWhenPhoneNotExists() { when(userRepository.existsByPhone("13800000000")).thenReturn(false); UserService userService = new UserService(userRepository, smsClient); boolean result = userService.register("13800000000", "pass123"); assertTrue(result); verify(userRepository, times(1)).save(any(User.class)); verify(smsClient, times(1)).send(eq("13800000000"), anyString()); } @Test void shouldReturnFalseWhenPhoneAlreadyExists() { when(userRepository.existsByPhone("13800000000")).thenReturn(true); UserService userService = new UserService(userRepository, smsClient); boolean result = userService.register("13800000000", "pass123"); assertFalse(result); verify(userRepository, never()).save(any(User.class)); } }

这里有一个关键细节:第一段代码里,测试失败了你会看到具体是哪里不匹配,但第二段代码里 verify(userRepository, never()).save(any(User.class)) 其实更值得细品。它的含义是,当手机号已存在时,代码不能走保存逻辑。如果保存逻辑里传了个 null 或者修改了某个参数,这个 verify 会第一时间爆出来。

Mockito 的三板斧其实就三句话:

  • mock 一个依赖:@Mock UserRepository userRepository;
  • 定义被 mock 方法的行为:when(...).thenReturn(...);
  • 验证方法有没有被调用、按什么参数调用:verify(...).times(...)。

这三个操作覆盖了我见过的绝大多数单测需求。等你能熟练使用它们,Mockito 的核心价值才算真正吃透。

3.2 参数匹配器与灵活返回

基础的 when(...).thenReturn(...) 只能处理固定参数,真实场景里经常需要按参数条件做不同返回。Mockito 提供了一套参数匹配器:any()、anyString()、eq()、anyLong()、argThat()。最常见的坑是混用裸值和匹配器时报 InvalidUseOfMatchersException,因为 Mockito 要求要么全用匹配器,要么不用匹配器。

when(userRepository.findById(1L)).thenReturn(Optional.of(user1)); when(userRepository.findById(2L)).thenReturn(Optional.of(user2)); // 或者按条件匹配 when(userRepository.findById(anyLong())).thenAnswer(invocation -> { Long id = invocation.getArgument(0); return id == 1L ? Optional.of(user1) : Optional.empty(); });

thenAnswer 是我特别想强调的玩法。它比 thenReturn 强大得多,因为你可以根据调用参数动态计算结果,还能模拟耗时操作、抛异常、甚至修改传入的对象。用它来模拟数据库主键自动生成就很合适:

when(userRepository.save(any(User.class))).thenAnswer(invocation -> { User user = invocation.getArgument(0); user.setId(100L); // 模拟数据库回填主键 return user; });

如果不这样处理,save 方法返回的 User 里 id 是 null,后续断言就会踩坑。这种细节如果不做单测,等到接口联调才暴露,排查成本会高很多。

3.3 @InjectMocks 与被测对象的装配

被测类如果有一堆依赖,手动 new 出来再一个个塞进去会非常啰嗦。Mockito 的 @InjectMocks 注解会自动把同类型的 @Mock 注入到被测对象里,注入方式按构造器注入、setter 注入、字段注入的顺序尝试。下面这段代码值得收藏:

@Mock private UserRepository userRepository; @Mock private SmsClient smsClient; @Mock private PasswordEncoder passwordEncoder; @InjectMocks private UserService userService; @Test void shouldEncodePasswordAndSave() { when(passwordEncoder.encode("raw")).thenReturn("encoded"); when(userRepository.existsByPhone(anyString())).thenReturn(false); userService.register("13900000000", "raw"); verify(passwordEncoder).encode("raw"); verify(userRepository).save(argThat(user -> "encoded".equals(user.getPassword()) && "13900000000".equals(user.getPhone()))); }

实战里 @InjectMocks 有个隐蔽缺陷:如果被测类有多种构造器,Mockito 可能选错。比如一个类既有无参构造器,又有全参构造器,@InjectMocks 大概率会走无参的那个,空指针就跟着来了。碰到这种情况我一般放弃 @InjectMocks,直接在测试方法里显式 new 并注入,意图更清晰,报错也更直接。

注意:@InjectMocks 不是万能的,遇到构造器重载复杂、静态工厂创建对象的被测类,请手动装配。测试代码里的显式意图,永远比自动化尽量省事更重要。

4. 高级场景:让单测真实落地

4.1 Spring Boot 中的 Controller 层测试

单元测试只覆盖 Service 就算了?Controller 层同样值得测,而且要测得轻巧。Spring Boot 提供 @WebMvcTest 切片测试,它不会启动完整的 Servlet 容器和数据库,只装配 Web 层相关的组件。配合 MockMvc 你可以直接对接口发请求、校验响应状态码和 JSON 结构。

@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void shouldReturnUserWhenExists() throws Exception { User user = new User(100L, "alice", "alice@example.com"); when(userService.findById(100L)).thenReturn(Optional.of(user)); mockMvc.perform(get("/users/100")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(100)) .andExpect(jsonPath("$.name").value("alice")); verify(userService).findById(100L); } @Test void shouldReturn404WhenUserNotFound() throws Exception { when(userService.findById(999L)).thenReturn(Optional.empty()); mockMvc.perform(get("/users/999")) .andExpect(status().isNotFound()); verify(userService).findById(999L); } }

这里有一点要留意:@MockBean 替换的是 Spring 上下文里的 UserService Bean,不是 Service 内部的依赖。@WebMvcTest 加载的是一套自定义的 Web 上下文,被 @MockBean 标记的Bean只在当前测试上下文生效。所以如果你一个测试类里既要测 Controller 又要测 Service,请把 Service 相关的 Bean 也加进切片,或者在同一个测试类里组合 @MockBean。

jsonPath 这套断言语法我建议至少学会 $. 取值、数组下标、通配符 * 这几个常见用法,绝大多数接口返回的校验都够用了。

4.2 数据库与文件系统的边界处理

单元测试要追求确定性,而数据库天然带状态,所以凡是接触 Repository 的测试,我都建议用 H2 内存库来测,而不是起一套 MySQL。H2 支持 MySQL 兼容模式,绝大部分标准 SQL 都能跑。但要注意另一点:H2 的 SQL 方言跟生产库终究有差异,别拿它做 SQL 兼容性验证,那应该由集成测试负责。

文件系统和外部 API 的测试更容易踩坑。我的处理方式是:文件读写抽象成接口,测试时 mock 接口返回固定内容;外部 API 客户端也抽象成接口,测试时 mock 返回固定响应。这样单元测试完全离线执行,本地一把梭,CI 也不受网络波动影响。至于那些真正需要验证跟外部系统交互的用例,交给集成测试和环境测试环境去覆盖。

4.3 参数化测试与重复测试

同一个逻辑,不同的输入,就该用参数化测试。JUnit 5 的 @ParameterizedTest 比 JUnit 4 好用太多,支持 @ValueSource、@EnumSource、@MethodSource、@CsvSource 多种数据来源。实际项目里 @CsvSource 是最高频的,写起来直观:

@ParameterizedTest @CsvSource({ "'' , false", // 空串 "' ' , false", // 空格 "'123456', true" // 正常 }) void shouldValidatePhone(String phone, boolean expected) { assertEquals(expected, validationService.isValidPhone(phone)); }

如果数据源来自数据库或文件,可以写一个方法返回 Stream ,用 @MethodSource 引用它。配合 @DisplayName("...") 描述用例,测试报告的可读性会很好。另外 @RepeatedTest 适合做随机数、并发这类不稳定场景的冒烟测试,例如连续跑 50 次确保不会偶发崩溃。

5. 常见问题与排查技巧实录

5.1 问题速查表

我整理了一份平时被问得最多的排查表,按“症状、原因、解法”三个字段记录:

症状典型原因解决办法
测试全绿但覆盖率很低只写了 happy path补充异常分支、边界值、空值用例
测试类里 @Mock 不生效没有 @ExtendWith(MockitoExtension.class)加上扩展,或改用 MockitoAnnotations.openMocks
InvalidUseOfMatchersException裸值和匹配器混用全部参数改成匹配器,或全部用裸值
verify 不通过但调用了参数不匹配,比如包装类型和基本类型不一致用 eq/anyInt 精确匹配,注意 Integer 与 int
mock 的静态方法不生效Mockito 默认不 mock 静态方法使用 mockito-inline,并在 try-with-resources 中 mockStatic
Spring 测试启动极慢每次起完整上下文用 @WebMvcTest、@DataJpaTest 等切片注解

5.2 三个值得单独展开的疑难杂症

第一个是静态方法 mock。Mockito 从 3.4 开始支持 mockStatic,但需要在依赖里引入 mockito-inline 或者 5.x 把 inline mock maker 设为默认。使用时要格外注意作用域,代码要在 try-with-resources 块里写完:

try (MockedStatic<IdGenerator> mocked = mockStatic(IdGenerator.class)) { mocked.when(IdGenerator::nextId).thenReturn("ORDER-001"); String id = orderGenerator.create(); assertEquals("ORDER-001", id); }

第二个是测试用例之间的状态污染。这通常是单例、静态字段、ThreadLocal 导致的。我排查过一次诡异问题:一个订单测试依赖同一个随机数,结果两个用例跑的顺序一变就挂。最后发现是 IdGenerator 是单例,内部还有一个缓存 List 没清。解决办法是给单例提供 reset 方法,或者在 @AfterEach 里手动重建实例并重新注入被测对象。

第三个是网络延迟和超时测试。单元测试里要模拟超时,不要真的去连外部服务,而是用 thenAnswer 里 sleep 或者直接 thenThrow 一个 SocketTimeoutException。这样测试几毫秒内就能验证超时分支,不用干等。

5.3 覆盖率数据怎么看

Java 生态最常用的覆盖率工具是 JaCoCo,配合 Maven 插件可以生成详细的 HTML 报告。我建议把它接入构建流程里,但不要只盯行覆盖率,还要看分支覆盖率。举一个例子:一个方法里有个 if (user != null && user.isActive()),行覆盖率可能是 70%,但分支覆盖率只有 25%——因为根本没有跑过 user 为 null 的那条路径。这种场景在真实项目里到处都是,只看行覆盖率会给你“测试很完善”的错觉。

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

跑完 mvn test 之后打开 target/site/jacoco/index.html,能看到每个类的行覆盖与分支覆盖百分比。我给自己定的线是核心模块行覆盖 85%、分支覆盖 70%,再低就要开会讨论原因了。

6. 测试分层与持续集成的最佳配合

6.1 单元测试、集成测试、端到端测试如何隔离

很多人把测试堆到一起,导致本地跑单测也要等数据库、等外部服务,体验极差。我的方案是用 Maven Surefire 和 Failsafe 插件把三类测试明确分开:单元测试命名为*Test.java,由 Surefire 负责在test阶段跑;集成测试命名为*IT.java,由 Failsafe 在integration-test阶段跑;端到端测试单独放在一个模块,由专门的 CI Job 驱动。这样本地你想快速跑单元测试,一条命令就搞定,不用陪集成测试干等。

本地最常用的还是:

mvn test -DfailIfNoTests=false

这条命令只跑单元测试,几十秒内出结果。集成测试留到推代码前再跑。

6.2 CI 流水线的阶段卡点设计

我在 CI 里设了三个质量卡点。第一个卡点:提交代码后第一时间跑单元测试和 JaCoCo 覆盖率检查,覆盖率不达标直接挂掉,防止有人把测试写成摆设。第二个卡点:合并到主干前跑全部集成测试,验证数据库映射和接口契约。第三个卡点:部署到预发环境后跑一条冒烟端到端用例,确认主链路没断。

这套设计背后的逻辑是让最快的测试最先拦人,慢的测试放在后面减少无效反馈循环。如果反过来,每次提交都要等 15 分钟,团队就会想尽办法跳过测试。

6.3 代码覆盖率与测试质量的关系

写到这里,我必须泼一盆冷水:覆盖率只是一张体检单,指标高不等于质量高。一个只有 assertTrue(true) 的用例也能把覆盖率刷到好看。所以我更倾向于把覆盖率分析和需求用例映射配合起来:每个核心业务需求能不能对应到一个命名清晰的测试方法。测试方法名像契约一样维护,比单纯刷数据有意义多了。

我团队至今坚持 Code Review 时把测试代码和业务代码放在同一份改动里审查,凡是业务逻辑改动没有配套测试用例的一律打回。时间久了,大家自然形成习惯:写代码时会想着它会被怎么测,反而减少了隐藏分支和防御式写法堆出来的烂代码。

7. 我的经验总结

从开始用 JUnit 5 和 Mockito 到现在,最大的收获不是工具本身,而是测试带来的设计反馈。以前写的 Service 层到处都是静态方法调用、new 对象、私有方法里藏着大段逻辑,根本没法 mock。后来为了让代码可测,我开始主动做依赖注入、抽接口、减少静态方法,整个项目的可读性和维护性反而上来了。测试是最好的代码评审官,这句话一点不虚。

最后分享一个小技巧:不要一上来就追求 100% 覆盖率,先挑核心业务模块里最容易出错、最影响收入路径的那几条链路补齐测试,把根基打稳,再逐步向边缘代码扩散。工具再强,也比不上一个能坚持补测试、认真读测试失败信息、愿意为可测性重构的团队。希望这份指南能帮你少踩一点坑。

如果你正在为项目的测试搭建发愁,我的建议是今天就把第一个测试类跑起来,哪怕只是给工具方法写一个断言。写出第一个,后面的路就容易多了。

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

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

立即咨询