作为一名多年泡在业务代码里、又对工程质量有点执念的后端开发,我始终觉得,单元测试这关过不好,后续的重构和项目演进心里就没底。很多团队不是不想写测试,而是写出来的测试要么脆得像玻璃,一碰就碎;要么维护成本高得吓人,改一行生产代码,要花半小时改测试。这背后的核心问题,往往不是态度,而是测试体系没有搭对。
今天想跟你好好聊聊我目前在项目里落地的一套组合拳:JUnit 5作为测试框架底座,Mockito用行为驱动(BDD)的风格去模拟依赖,AssertJ负责写出读起来像人话的流畅断言。这套体系用下来,最直观的感受是:测试代码不再是生产代码的“附属品”,而是真正能指导设计、保障重构的“安全网”。这篇文章会从为什么选这套组合讲起,一路拆解到具体注解怎么用、Mock 怎么打、断言怎么写,最后把我踩过的坑和排查思路也一并交代清楚。无论你是刚接触单元测试的新手,还是正在为项目里那堆老测试头疼的开发者,这篇文章应该都能给你一些可直接上手的参考。
1. 整体设计与选型思路:为什么是 JUnit 5 + Mockito + AssertJ
先聊点实在的。如果你去翻老项目,大概率会看到 JUnit 4 + 传统 Mockito 写法(when().thenReturn())+ 一堆assertEquals的组合。这套老组合不是说不能用,而是在面对现在越来越复杂的业务逻辑时,表达力跟不上了。我决定全面转向 JUnit 5 + BDD Mockito + AssertJ,不是赶时髦,是这三个工具放在一起,恰好解决了老组合最让我难受的三个问题:扩展能力弱、测试语义模糊、断言信息不友好。
1.1 核心需求拆解:一套测试体系到底该解决什么问题
在动手写测试之前,得先想明白单元测试的价值在哪。我个人理解,一套好的单元测试体系至少要解决三件事:
第一,快速定位回归。改了一个工具类的边界判断,能不能在几秒内知道有没有把别的模块搞挂。这要求测试执行要快,且失败信息要精确。JUnit 5 的测试引擎和 AssertJ 的断言描述,能让我在 CI 日志里一眼看出“哪个方法的哪个入参导致返回值不符合预期”,而不是“expected: true but was: false”这种没头没尾的话。
第二,倒逼代码可测试性。如果一个方法难以测试,往往意味着设计有问题——耦合太紧、依赖太隐晦、职责不单一。Mockito 的@InjectMocks和构造器注入的推荐方式,会迫使你把依赖通过构造方法暴露出来,而不是在方法内部new一个对象。这在无形中就把代码往干净的方向推。
第三,用测试当文档。团队里来了新人,与其让他啃十几个 Word 文档,不如让他跑一遍测试。测试方法名写得像一句人话(BDD 风格:given... when... then...),断言读起来像自然语言,新人对业务规则的吸收速度快得惊人。
1.2 方案选型解析:JUnit 5 对比 JUnit 4 和 TestNG 的取舍
JUnit 5 和 JUnit 4 最本质的区别,是它拆成了三个组件:Platform(测试启动的根基)、Jupiter(新的编程模型和扩展模型)、Vintage(兼容老版本 JUnit 3/4 的执行引擎)。这个拆分带来的直接好处是,你可以在同一个项目里跑 JUnit 4 的老测试,同时用 JUnit 5 写新测试,迁移成本极低。我实际迁移的时候,基本上是先把依赖切换掉,老测试一个没改,绿的还是绿的。
那为什么不选 TestNG?TestNG 确实在数据驱动和套件配置上做得很早也很强,但 JUnit 5 的 Jupiter 模型把参数化测试的能力补上之后,两者差距已经很小了。更重要的是生态,Spring Boot 2.2+ 对 JUnit 5 的支持是原生的,spring-boot-starter-test直接集成了 JUnit 5、Mockito 和 AssertJ,我不用额外配一堆依赖,开箱即用。对于一个以 Spring 技术栈为主的团队,JUnit 5 是阻力最小的选择。
1.3 BDD 风格与 AssertJ 的结合价值:测试代码也是要给人读的
很多开发写测试,脑子里只有“覆盖分支”这一个念头,写出来的代码就是一堆when(userService.getById(1L)).thenReturn(user);加assertEquals("张三", result.getName());。这种代码机器能看懂,人很费劲。BDD(Behavior Driven Development)的核心,是把测试当成行为的描述,而不是步骤的罗列。
Mockito 专门提供了given...when...then...的 BDD 风格 API,跟 JUnit 5 配合得天衣无缝。再加上 AssertJ 的流式断言,你几乎可以写出这样的代码:
// 给出一个余额为100元的账户 given(accountRepository.findByAccountNo("123456")).willReturn(account); // 当执行扣款80元时 boolean result = accountService.deduct("123456", new BigDecimal("80")); // 那么账户余额应为20元,且扣款成功 assertThat(result).isTrue(); assertThat(account.getBalance()).isEqualByComparingTo("20");你把这代码念出来,不需要任何注释,业务规则一目了然。这种可读性带来的价值,在需求频繁变动的项目里会被无限放大——因为测试变了,往往意味着业务逻辑变了,这时候有一份清晰的行为文档,比什么都管用。
2. 核心细节解析:JUnit 5 的测试生命周期与常用注解
先说清楚了为什么选这套组合,接下来就得看看到底怎么用。JUnit 5 用起来跟 JUnit 4 最大的不同,就是注解的语义更清晰,而且默认的包级可见性要求,也逼着你注意测试类的设计。我挑几个日常用得最狠的注解和机制展开讲讲。
2.1 从 @Test 到 @DisplayName:测试方法不再是乱码
以前写 JUnit 4 测试,方法名喜欢用testAddUserSuccess这种驼峰,时间一长,看命名根本猜不出业务场景是什么。JUnit 5 的@DisplayName注解允许你用中文甚至是带空格的句子描述测试行为:
@DisplayName("账户扣款:余额充足时扣款成功且余额正确") @Test void should_deduct_success_when_balance_is_enough() { // ... }@DisplayName是给 IDE 和测试报告看的,方法名是给代码维护者看的。我个人的习惯是方法名用 BDD 语法的英文(should_xxx_when_yyy),@DisplayName用中文描述业务场景,这样团队里不同技术背景的人都能快速 get 到测试意图。
还有一个容易忽略的注解是@Tag,它相当于给测试打标签。比如你有@Tag("slow")、@Tag("fast"),在 CI 流水线上就可以配置只跑 fast 组的测试,保证提交代码后几分钟内得到反馈。这个对于大型项目来讲非常实用,不然跑一次全量测试十几分钟,开发体验极差。
2.2 生命周期回调:@BeforeEach 与 @AfterEach 的正确用法
JUnit 5 用@BeforeEach和@AfterEach替代了 JUnit 4 的@Before和@After,语义更清晰。还有一个变化是@BeforeAll和@AfterAll默认必须是静态方法,除非你给测试类加上@TestInstance(TestInstance.Lifecycle.PER_CLASS)注解。
我一般每个测试类都会写一个setUp()方法,在里面初始化 Mockito 的 Mock 对象和待测对象。但这里有个容易踩坑的点:如果@BeforeEach里的初始化逻辑已经用构造器注入把 Mock 都塞进去了,就不要再调用MockitoAnnotations.openMocks(this),否则可能出现 Mock 对象被重复初始化的问题。最干净的做法是用构造器传参:
@BeforeEach void setUp() { userRepository = mock(UserRepository.class); userService = new UserService(userRepository); }这种方式写起来稍微多两行代码,但好处是你清楚知道被测对象的依赖是怎么来的,不会被@InjectMocks的“智能注入”搞晕。
2.3 参数化测试:@ParameterizedTest 的几种入参来源
@ParameterizedTest绝对是 JUnit 5 里我最爱的功能,没有之一。以前测试一个校验方法,得写五六个长得差不多的@Test方法,现在一行注解就能搞定。
第一种是@ValueSource,适合传单个基础类型参数:
@ParameterizedTest @ValueSource(strings = {"", " ", "null"}) @DisplayName("用户名为空时校验失败") void should_fail_when_username_is_blank(String username) { assertThat(userValidator.isValidUsername(username)).isFalse(); }第二种是@CsvSource,适合传多个参数或者带逗号的场景:
@ParameterizedTest @CsvSource({ "admin, 123456, true", "user, 123, false", "admin, , false" }) @DisplayName("登录校验:不同用户名密码组合") void should_validate_login(String username, String password, boolean expected) { assertThat(loginService.check(username, password)).isEqualTo(expected); }第三种是@MethodSource,这是最强的一种,因为你可以返回Stream<Arguments>,甚至直接构造复杂对象:
static Stream<Arguments> userProvider() { User normalUser = User.builder().age(20).build(); User minorUser = User.builder().age(16).build(); return Stream.of( Arguments.of(normalUser, true), Arguments.of(minorUser, false) ); } @ParameterizedTest @MethodSource("userProvider") @DisplayName("用户年龄校验") void should_check_user_age(User user, boolean expected) { assertThat(userService.isAdult(user)).isEqualTo(expected); }参数化测试的核心价值不是省几行代码,而是把“输入-预期”的数据集中在一起,让测试用例像一张表格一样清晰。哪条数据挂了,报告里直接显示那条数据的具体值,不用像以前那样写一堆System.out.println去猜是哪个场景出了问题。
2.4 重复测试:@RepeatedTest 不是让你偷懒的
@RepeatedTest主要用于稳定性验证,比如并发场景下有没有偶发性的状态问题。但它不是用来代替参数化测试的,两者解决的问题完全不同:
@RepeatedTest:验证“同一种场景执行 N 次,结果都符合预期”。@ParameterizedTest:验证“不同种场景下,结果能按预期分化”。
我用@RepeatedTest的场景基本是测试缓存组件或 ID 生成器,例如:
@RepeatedTest(100) @DisplayName("ID生成器在高频调用下无重复") void should_generate_unique_id() { String id = idGenerator.nextId(); assertThat(generatedIds).doesNotContain(id); generatedIds.add(id); }注意,@RepeatedTest默认是串行的,如果你真的想验证并发下的线程安全问题,得配合@Execution(CONCURRENT)或者自己在测试代码里起多线程,别指望注解本身帮你搞定并发。
3. Mockito 行为驱动实践:从 when 到 given 的思维转变
光有 JUnit 5 还不够,单元测试里最让人头疼的是怎么处理外部依赖。Mockito 就是干这个的,但很多人的用法停留在“为了 mock 而 mock”的阶段。这一部分我想重点讲清楚 BDD 风格下的 Mockito 到底怎么用,以及它和传统写法的区别。
3.1 Mock、Spy 与 @InjectMocks 的边界
先分清楚三个概念:
- Mock:创建一个完全虚拟的对象,所有方法都不走真实逻辑,返回值都是默认值(null、0、false)。
- Spy:基于真实对象创建代理,没打桩的方法走真实逻辑,打了桩的走桩逻辑。
- @InjectMocks:ReflectionTestUtils 的高级替代,自动把 Mock 或 Spy 注入到被测对象的字段中。
我个人的建议是:默认用 Mock,能不用 Spy 就不用。Spy 容易导致测试不稳定,因为你无法完全隔离外部状态。@InjectMocks可以省去手动 new 对象的样板代码,但它依赖反射,字段名一变就容易注入失败或注入错地方。所以我在团队里统一规范:被测对象如果依赖较少,直接在构造器里传入 Mock,简单直接;依赖很多或者需要同时注入多个 Mock 时,才用@InjectMocks配合@Mock。
3.2 BDD Mockito:given...willReturn... 与 when...thenReturn 的本质差异
Mockito 老 API 是when(mock.method()).thenReturn(value),BDD API 是given(mock.method()).willReturn(value)。两者执行效果几乎一样,但阅读顺序不同。传统的写法是先写“调用”,再写“结果”;BDD 的写法是先关注“准备条件”,再关注“行为触发”。可别小看这个顺序,当你用 Given-When-Then 结构去写测试时,大脑的思考方式会发生很大变化——你不再是一个只知道执行步骤的程序员,而是在描述一个业务行为的前提、动作和结果。
来看一个标准的 BDD Mockito 测试:
class OrderServiceTest { @Mock OrderRepository orderRepository; @Mock UserService userService; @InjectMocks OrderService orderService; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test @DisplayName("下单:用户存在且库存充足时返回订单号") void should_create_order_when_user_and_stock_ok() { // given User user = User.builder().id(1L).status(1).build(); given(userService.getById(1L)).willReturn(user); given(orderRepository.save(any(Order.class))).willAnswer(invocation -> { Order order = invocation.getArgument(0); order.setId(100L); return order; }); // when Long orderId = orderService.createOrder(1L, 101L, 2); // then assertThat(orderId).isEqualTo(100L); verify(orderRepository).save(any(Order.class)); } }这里的willAnswer是个很重要的技巧。当你需要根据入参动态决定返回值时,willReturn就不够用了。比如保存订单时,我们要模拟数据库回填自增 ID,就必须通过Answer接口拿到入参对象进行修改。
3.3 常用打桩技巧与参数匹配器
Mockito 的参数匹配器用得好,可以省很多事;用不好,就是给自己挖坑。我列几个常用的:
any(Class.class):匹配任意类型的非 null 参数。anyString()、anyInt()、anyLong():匹配对应基础类型包装类的任意值,注意anyString()不接受 null。eq(value):匹配指定值,当同时使用多个匹配器时,所有参数都得用匹配器包裹。argThat(ArgumentMatcher):自定义匹配逻辑,适合复杂对象的字段断言。
还有一个特别容易踩的坑:当方法有多个参数,只要有一个参数用了匹配器,其他参数也必须用匹配器。很多人写given(userService.getById(1L)).willReturn(user)没事,但一旦改成都用匹配器,就容易漏掉其他参数不包eq,运行时报InvalidUseOfMatchersException。
另外,如果我需要“连续调用返回不同值”,可以用链式willReturn:
given(cache.get("key")).willReturn("v1").willReturn("v2");这在测试重试逻辑或者缓存失效的场景下特别好用。
3.4 verify:不止是确认方法被调了
verify是 Mockito 里容易被忽视的大杀器,它能验证“某个 Mock 的方法是否被调用、调用了几次、调用参数是什么、调用顺序如何”。这在测试复杂交互时特别有用。
verify(userRepository, times(1)).save(any(User.class)); verify(userRepository, never()).deleteById(anyLong()); verify(userService, timeout(1000)).sendEmail(anyString(), anyString());timeout(1000)是我在测试异步操作时必用的,它能阻塞等待最多一秒钟,如果在这个时间内方法被调用了,就算通过。比Thread.sleep(1000)这种硬等靠谱多了,因为测试不会无谓地变慢。
这里还要多说一句:不要过度 verify。我看到过很多新手的测试,把生产代码里每一步方法调用都用verify验一遍,结果生产代码重构时,测试因为“调用次数变了”而挂掉,但功能其实完全正常。verify只用来确认那些“如果不调用就会产生严重后果”的交互,比如发邮件、调支付接口、写审计日志。
4. AssertJ 流畅断言实战:告别 assertEquals 地狱
如果你还在用 JUnit 自带的assertEquals(expected, actual),我强烈建议你试一下 AssertJ。它对集合、异常、字符串、日期等的断言支持极其丰富,而且 IDE 提示做得特别好——你输入assertThat(result).后面会自动联想出一堆断言方法,基本不用记忆 API。
4.1 基础断言:字符串、数字与布尔
AssertJ 的assertThat静态方法重载极多,几乎覆盖了所有类型。字符串断言我常用的是:
assertThat(patient.getName()) .startsWith("张") .endsWith("三") .contains("张") .hasSize(2);注意这个链式调用是逐条执行的,哪一条不过,报告就会精确告诉你“expecting string to start with... but was...”,定位问题非常方便。数字断言有个坑要特别说:比较 BigDecimal 时千万别用isEqualTo,因为BigDecimal("1.0")和BigDecimal("1.00")在equals上是不同的。要用isEqualByComparingTo:
assertThat(order.getTotalAmount()).isEqualByComparingTo("99.90");4.2 集合与异常断言:写得少,查得多
集合断言是 AssertJ 的绝对强项:
assertThat(userList) .hasSize(3) .extracting(User::getName) .containsExactly("张三", "李四", "王五");extracting可以从对象列表里抽取某个字段,然后对字段集合做断言,这个在验证查询结果顺序和内容时极其好用。还有filteredOn可以结合条件断言:
assertThat(users) .filteredOn(user -> user.getAge() > 18) .hasSize(2);异常断言方面,AssertJ 提供了assertThatThrownBy和assertThatCode两种写法:
assertThatThrownBy(() -> orderService.createOrder(null, 101L, 1)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("用户ID不能为空");如果你是 JUnit 5,也可以直接用assertThrows,但 AssertJ 的好处是异常断言可以继续往下链,比如检查异常 cause:
assertThatThrownBy(() -> paymentService.pay(null)) .hasRootCauseInstanceOf(NullPointerException.class);4.3 软断言与自定义断言:给团队沉淀公共资产
默认情况下 AssertJ 是“快速失败”的,也就是说第一条断言失败就停止,后面的断言不执行了。这在某些需要一次拿到所有失败点的场景下很不友好。AssertJ 提供了SoftAssertions来解决这个问题:
SoftAssertions softly = new SoftAssertions(); softly.assertThat(username).isEqualTo("admin"); softly.assertThat(password).isNotBlank(); softly.assertThat(loginResult).isTrue(); softly.assertAll();assertAll()会把所有软断言的结果汇总,如果有失败,最后统一抛出错误。这在接口测试、UI 自动化测试里特别适合,因为一次跑完能拿到所有问题,不用跑一次修一个。
更进一步,如果你的领域对象有大量重复断言逻辑,可以自定义 AbstractAssert 子类。比如我有一个UserAssert,封装了“必须是有效用户”的断言链。这样在多个测试类里,一行assertThat(user).isValid()就能完成 5 条断言。这是把测试代码也当成产品代码来维护的思路,前期投入一点时间,后期收益非常大。
5. 实操过程:从零搭建一个完整的单元测试模块
理论说了一大堆,下面用我实际写的一个“用户注册 + 登录”的简化模块,把 JUnit 5、Mockito、AssertJ 完整串一遍。这个例子我会直接给出代码和注释,你可以拷到自己项目里改吧改吧就能用。
5.1 项目依赖配置与目录结构
我是 Maven 项目,依赖配置在pom.xml里:
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.25.3</version> <scope>test</scope> </dependency> </dependencies>有几点值得注意:
junit-jupiter是聚合依赖,里面已经包含了junit-jupiter-api、junit-jupiter-params(参数化测试需要)和junit-jupiter-engine。- Mockito 5.x 要求 Java 11+,如果项目还在 Java 8,建议用 Mockito 3.7+,或者升级 JDK。这年头新项目还在 Java 8 的话,升级是迟早的事。
- 测试类放在
src/test/java下,测试资源放在src/test/resources下,保持与主代码一致的包结构。
在 Spring Boot 项目里其实更简单,引入spring-boot-starter-test即可,它默认集成了 JUnit 5、Mockito、AssertJ 和 Spring Test。
5.2 被测模块:UserService 与依赖接口
为了能演示 Mock 和断言,我定义了一个非常典型的 Service 层:
public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } public User register(String username, String rawPassword) { if (username == null || username.isBlank()) { throw new IllegalArgumentException("用户名不能为空"); } if (userRepository.existsByUsername(username)) { throw new IllegalStateException("用户名已存在"); } String encodedPassword = passwordEncoder.encode(rawPassword); User user = new User(username, encodedPassword); userRepository.save(user); return user; } public boolean login(String username, String rawPassword) { User user = userRepository.findByUsername(username); if (user == null) { return false; } return passwordEncoder.matches(rawPassword, user.getPassword()); } }注意这里的构造器注入,我刻意没有用@Autowired之类的注解,目的是让测试代码可以非常简单地手动装配依赖。如果你用的是 Lombok 的@RequiredArgsConstructor,效果一样。
5.3 单元测试完整代码:结合参数化、Mock 和断言
下面是把 JUnit 5 + Mockito + AssertJ 全部用起来的测试类:
class UserServiceTest { private UserRepository userRepository; private PasswordEncoder passwordEncoder; private UserService userService; @BeforeEach void setUp() { userRepository = mock(UserRepository.class); passwordEncoder = mock(PasswordEncoder.class); userService = new UserService(userRepository, passwordEncoder); } @Nested @DisplayName("注册方法测试") class RegisterTest { @Test @DisplayName("注册成功:返回加密后的用户") void should_register_success() { // given given(passwordEncoder.encode("123456")).willReturn("ENC(123456)"); given(userRepository.existsByUsername("newUser")).willReturn(false); given(userRepository.save(any(User.class))).willAnswer(inv -> { User user = inv.getArgument(0); user.setId(1L); return user; }); // when User result = userService.register("newUser", "123456"); // then assertThat(result.getId()).isEqualTo(1L); assertThat(result.getPassword()).isEqualTo("ENC(123456)"); verify(userRepository, times(1)).save(any(User.class)); } @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", " "}) @DisplayName("注册失败:用户名为空抛出异常") void should_fail_when_username_blank(String username) { assertThatThrownBy(() -> userService.register(username, "123456")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("用户名不能为空"); verify(userRepository, never()).save(any()); } @Test @DisplayName("注册失败:用户名已存在") void should_fail_when_username_exists() { given(userRepository.existsByUsername("dup")).willReturn(true); assertThatThrownBy(() -> userService.register("dup", "123456")) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("用户名已存在"); verify(userRepository, never()).save(any()); } } @Nested @DisplayName("登录方法测试") class LoginTest { @Test @DisplayName("登录成功:密码匹配") void should_login_success() { // given User user = new User("admin", "ENC(123456)"); given(userRepository.findByUsername("admin")).willReturn(user); given(passwordEncoder.matches("123456", user.getPassword())).willReturn(true); // when boolean result = userService.login("admin", "123456"); // then assertThat(result).isTrue(); } @Test @DisplayName("登录失败:密码错误") void should_fail_when_password_wrong() { User user = new User("admin", "ENC(123456)"); given(userRepository.findByUsername("admin")).willReturn(user); given(passwordEncoder.matches("wrong", user.getPassword())).willReturn(false); boolean result = userService.login("admin", "wrong"); assertThat(result).isFalse(); } @Test @DisplayName("登录失败:用户不存在返回false,不调用密码匹配") void should_fail_when_user_not_found() { given(userRepository.findByUsername("ghost")).willReturn(null); boolean result = userService.login("ghost", "123456"); assertThat(result).isFalse(); verify(passwordEncoder, never()).matches(anyString(), anyString()); } } }这组测试里有一个小细节我觉得特别值钱:@NullAndEmptySource。这个注解是@NullSource和@EmptySource的组合,可以直接给参数化测试提供 null 和空字符串的入参。再结合@ValueSource(strings = {" ", " "}),一次就把字符串为空的各种边界都覆盖到了,不需要手写一堆@Test方法。
verify(userRepository, never()).save(any())这行也很有深意——在注册失败的场景下,我不仅要验证抛出了异常,还要确保没有对数据库产生副作用。这种“副作用验证”在高并发或资金相关的代码里尤其重要。
5.4 运行与覆盖率:用 Maven 跑通闭环
测试写好了,最简单的方式是右键单个测试类运行。但作为项目级工程,我更推荐走 Maven:
mvn test -Dtest=UserServiceTest如果想生成覆盖率报告,可以把 JaCoCo 插件加到pom.xml:
<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>test</phase> <goals> <goal>report</goal> </goals> </execution> </executions> </plugin>跑完mvn clean test后,打开target/site/jacoco/index.html就能看到行覆盖率、分支覆盖率。我的经验是:不要把覆盖率数值当成 KPI,它的真正价值是告诉你哪些分支完全没测到。比如你有一个if (a && b),JaCoCo 会明确告诉你a=true, b=false这个分支缺测试。补上这个分支,往往比盲目把覆盖率从 80% 刷到 90% 更有意义。
6. 常见问题与排查技巧实录
这套体系用久了,团队成员陆陆续续也踩过一些比较经典的坑。我把它们整理成一张速查表,又挑了几个典型的场景展开说说,希望能帮你少走弯路。
6.1 问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
测试类运行时报No tests found | 测试类不是 public,或者方法签名不规范 | JUnit 5 测试类和方法默认包级可见即可,检查是否有@Test注解 |
参数化测试报ParameterResolutionException | @MethodSource引用的方法不存在或签名错误 | 确认方法名一致(无括号),方法必须是static,且返回Stream<Arguments> |
Mockito 报UnnecessaryStubbingException | 打了桩但没调用 | 在测试类上使用@MockitoSettings(strictness = Strictness.LENIENT)或直接删除多余打桩 |
InvalidUseOfMatchersException | 混用了匹配器和原始值 | 如果一个参数用了匹配器,所有参数都要用匹配器,原始值包eq() |
| Mock 对象方法返回 null 导致 NPE | 忘记打桩 | 用assertThatThrownBy复现,或者检查given参数是否完全匹配(包括 equals) |
| 测试方法间数据污染 | Mock 对象状态未清理 | 确保@BeforeEach里重新mock(),不要用static字段保存可变状态 |
| 测试偶发失败,重跑就过 | 并发执行导致共享状态 | 检查是否有 static 变量或 Spring 上下文共享,必要时使用@Execution(SAME_THREAD) |
断言 BigDecimal 用isEqualTo失败 | BigDecimal 的 equals 比较精度 | 使用isEqualByComparingTo(String) |
6.2 排查实录一:Mockito 的UnnecessaryStubbingException到底要不要管
这个问题在团队里吵过好多次。有人觉得 Striktness 默认值是STRICT_STUBS,一旦有打桩没被调用就报错,太烦人了,想全局调成LENIENT。我的态度很明确:默认严格模式是保护你的,不是烦你的。UnnecessaryStubbingException出现,通常意味着两种情况:一是生产代码重构后,某个依赖调用路径变了,打桩变成了死代码;二是你打桩用的参数匹配器写宽了,实际路径没走到。无论是哪种,都说明测试和生产代码已经不同步,这时候忽略警告,其实就是把一个定时炸弹埋在了测试里。
如果确实有个别打桩在某些测试方法中不需要,推荐用lenient()局部放宽:
lenient().when(userRepository.existsByUsername("optional")).thenReturn(false);这样既保留了全局严格检查,又对特殊场景做了精准豁免。
6.3 排查实录二:@ParameterizedTest在 CI 上跑得极慢
有个项目在本地跑参数化测试飞快,一上 CI 就动辄几分钟。后来排查发现是有个@CsvSource传了一个巨大的 JSON 字符串,几千行的那种,每次解析都耗时严重。解决办法是把大数据量的输入放到@MethodSource里,用@@CsvFileSource指向外部 CSV 文件,或者直接用Arguments.of()加载一个预先构造好的对象。其实核心原则是:参数化测试的入参应该是精简的“示例”,而不是大块的“数据样本”。如果依赖大量真实数据做测试,那应该叫集成测试,而不是单元测试。
6.4 排查实录三:Mockito 和 JUnit 5 的版本兼容问题
Mockito 5.x 默认集成了对 JUnit 5 的支持,但如果你在某些老项目里看到MockitoAnnotations.initMocks(this)已经废弃,要改成MockitoAnnotations.openMocks(this),并且记得在@AfterEach里调用close()。我最开始没注意openMocks返回的AutoCloseable,结果测试类多了以后,线程池资源一直没释放,CI 上跑完一堆测试后 JVM 直接 OOM。
规范写法是:
private AutoCloseable closeable; @BeforeEach void setUp() { closeable = MockitoAnnotations.openMocks(this); } @AfterEach void tearDown() throws Exception { closeable.close(); }或者为了省事,直接给测试类加@ExtendWith(MockitoExtension.class),让 Mockito 替你把生命周期管理掉,这也是我现在推荐的做法。但要注意,MockitoExtension默认就是严格打桩,如果团队里有人不适应,可以先在特定测试类上调整,不要一上来全局宽松。
7. 一点不一样的心得:好的测试是设计出来的,不是补出来的
最后再聊点比较虚但我觉得很核心的东西。很多人写测试是因为公司有覆盖率 KPI,于是测试变成了生产代码的“事后补丁”。但我在实际用 JUnit 5 + Mockito + AssertJ 这套体系之后,最大的变化其实是:我开始先写测试的 when 和 then,再去实现生产代码。这不是什么严格的 TDD 宗教,纯粹是因为当你用 BDD 风格去描述行为时,你会立刻发现这个类的接口设计是否清晰——如果 given 阶段你需要 mock 三个依赖,然后 when 阶段只做了一件事,那这个类大概率违背了单一职责原则;如果 then 阶段你发现自己不知道要断言什么,那说明这个方法压根没有明确的业务契约。
这套体系还有个隐形好处:团队 code review 的质量提升了。以前 PR 里夹带一些乱七八糟的代码,reviewer 很难通过肉眼发现。现在测试描述得清清楚楚,if 分支覆盖了哪些情况一眼便知。一旦有人给方法增加了一个分支,测试就跑红,然后大家就得在 PR 讨论里正式地讨论“这个新分支合理吗”。这样慢慢形成一个正循环:代码质量越高,测试越好写;测试越好写,代码质量越高。
回到实操层面,我给你的建议很简单:
- 如果你的项目还停留在 JUnit 4 + 传统 Mockito,别急着大改,先把新写的测试用 JUnit 5 + BDD Mockito + AssertJ 写起来,老测试跑在老引擎上完全不影响。
- 从最容易被参数化测试优化的“规则校验类”方法开始练手,用
@CsvSource把业务规则铺开,你会很快感受到这套组合的爽点。 - 别追求 100% 覆盖率,把那句“分支覆盖了多少”拿来自检就好,关注边界条件和异常路径,往往一个 bug 就藏在你没测到的分支里。
我自己的体会是:测试代码是项目里最好的注释,也是团队里最靠谱的文档。当新人问“这个接口到底允不允许传空字符串”时,与其翻十几个文档,不如直接跑一下UserServiceTest,所有人都会心领神会。这套测试体系,值得你花一个下午认真整整。