作为一名多年泡在业务代码里、又对工程质量有点执念的后端开发我始终觉得单元测试这关过不好后续的重构和项目演进心里就没底。很多团队不是不想写测试而是写出来的测试要么脆得像玻璃一碰就碎要么维护成本高得吓人改一行生产代码要花半小时改测试。这背后的核心问题往往不是态度而是测试体系没有搭对。今天想跟你好好聊聊我目前在项目里落地的一套组合拳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 写新测试迁移成本极低。我实际迁移的时候基本上是先把依赖切换掉老测试一个没改绿的还是绿的。那为什么不选 TestNGTestNG 确实在数据驱动和套件配置上做得很早也很强但 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());。这种代码机器能看懂人很费劲。BDDBehavior 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_yyyDisplayName用中文描述业务场景这样团队里不同技术背景的人都能快速 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这是最强的一种因为你可以返回StreamArguments甚至直接构造复杂对象static StreamArguments 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基于真实对象创建代理没打桩的方法走真实逻辑打了桩的走桩逻辑。InjectMocksReflectionTestUtils 的高级替代自动把 Mock 或 Spy 注入到被测对象的字段中。我个人的建议是默认用 Mock能不用 Spy 就不用。Spy 容易导致测试不稳定因为你无法完全隔离外部状态。InjectMocks可以省去手动 new 对象的样板代码但它依赖反射字段名一变就容易注入失败或注入错地方。所以我在团队里统一规范被测对象如果依赖较少直接在构造器里传入 Mock简单直接依赖很多或者需要同时注入多个 Mock 时才用InjectMocks配合Mock。3.2 BDD Mockitogiven...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。另外如果我需要“连续调用返回不同值”可以用链式willReturngiven(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上是不同的。要用isEqualByComparingToassertThat(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 的好处是异常断言可以继续往下链比如检查异常 causeassertThatThrownBy(() - 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 groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version5.11.0/version scopetest/scope /dependency dependency groupIdorg.assertj/groupId artifactIdassertj-core/artifactId version3.25.3/version scopetest/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 跑通闭环测试写好了最简单的方式是右键单个测试类运行。但作为项目级工程我更推荐走 Mavenmvn test -DtestUserServiceTest如果想生成覆盖率报告可以把 JaCoCo 插件加到pom.xmlplugin groupIdorg.jacoco/groupId artifactIdjacoco-maven-plugin/artifactId version0.8.11/version executions execution goals goalprepare-agent/goal /goals /execution execution idreport/id phasetest/phase goals goalreport/goal /goals /execution /executions /plugin跑完mvn clean test后打开target/site/jacoco/index.html就能看到行覆盖率、分支覆盖率。我的经验是不要把覆盖率数值当成 KPI它的真正价值是告诉你哪些分支完全没测到。比如你有一个if (a b)JaCoCo 会明确告诉你atrue, bfalse这个分支缺测试。补上这个分支往往比盲目把覆盖率从 80% 刷到 90% 更有意义。6. 常见问题与排查技巧实录这套体系用久了团队成员陆陆续续也踩过一些比较经典的坑。我把它们整理成一张速查表又挑了几个典型的场景展开说说希望能帮你少走弯路。6.1 问题速查表现象可能原因解决方案测试类运行时报No tests found测试类不是 public或者方法签名不规范JUnit 5 测试类和方法默认包级可见即可检查是否有Test注解参数化测试报ParameterResolutionExceptionMethodSource引用的方法不存在或签名错误确认方法名一致无括号方法必须是static且返回StreamArgumentsMockito 报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所有人都会心领神会。这套测试体系值得你花一个下午认真整整。