news 2026/10/20 8:37:22

JUnit 5+Mockito:让单元测试摆脱外部依赖的实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JUnit 5+Mockito:让单元测试摆脱外部依赖的实战指南

1. 先说点实话:为什么测试代码比业务代码更让人头疼

我以前接过一个项目,业务代码写得还算规矩,但一到跑测试就头大——测试类启动要十几秒,数据库一换环境就全部变红,同事为了绕过外部依赖,直接用if (testMode)这种开关去改业务代码。后来我把整个测试体系推倒重来,核心工具就是 JUnit 5 加 Mockito。这两个东西单独拎出来都不算新,但把它们真正集成好,能解决一个很实际的问题:让单元测试不再依赖数据库、网络、消息队列这些外部因素,只测你当前这段代码的逻辑本身。

这篇博文适合三类人看:刚接触单元测试、想搞清楚 @Mock 和 @InjectMocks 到底怎么用的新手;正在从 JUnit 4 往 JUnit 5 迁移、被版本兼容问题卡住的开发者;以及已经写了大量测试但经常被"打桩失效""静态方法无法 mock"之类问题折磨的老手。我不会只列 API,我会把我踩过的坑、排查思路、以及最终沉淀下来的写法习惯全部展开,你照着做就能少折腾好几个晚上。

先说一个最根本的观念:单元测试要测的是"这个类的行为对不对",而不是"它和外部系统能不能配合好"。你测试一个遥控器,不需要真的打开一台电视,只需要一台假电视,按下按钮后能验证红外信号发出去了就行。Mockito 就是帮你造"假电视"的那个工具,JUnit 5 则是负责把这些假对象组织成测试用例、报告失败信息的框架。两者配合,测试才会又快又稳。

2. 为什么是 JUnit 5 配 Mockito:方案选型背后的逻辑

2.1 JUnit 5 到底比 JUnit 4 强在哪里

很多人的第一反应是"我又不是不能用 JUnit 4,为什么非要升级"。我当初也是这个心态,直到被 JUnit 4 的扩展方式恶心到。JUnit 4 靠 @RunWith 注解,一个测试类只能指定一个 Runner,你想同时用 Spring 的 Runner 和 Mockito 的 Runner,就得自己去搞复合 Runner,麻烦得要命。JUnit 5 换成了 Extension 模型,支持多个扩展叠加,你想用 MockitoExtension 就用 MockitoExtension,想再用一个自定义扩展记录执行时间也不冲突。

JUnit 5 还有一个我非常喜欢的点:断言体系更加完整。assertEquals 支持 lambda 形式的消息,assertThrows 直接测异常,assertTimeout 可以测超时。以前 JUnit 4 遇到异常测试要写 try-catch 或者用 @Test(expected=...),后者会掩盖掉"断言之前抛了异常"的误判。在 JUnit 5 里,assertThrows 能精确到异常类型,还要求传入一个 Executable,代码结构更清晰。

另外 JUnit 5 提供了 @TestInstance、@ParameterizedTest、@Nested 这些能力。尤其是 @Nested,我经常用来把同一个业务类的不同场景分组:外部正常、外部异常、边界条件各放一个内部类,测试代码的可读性会高出一大截。这些特性单独看是"优化体验",但和 Mockito 结合起来,能让测试代码像一份可执行的业务文档。

2.2 Mockito 的市场地位和选型理由

模拟框架不止 Mockito 一个,EasyMock、PowerMock 也在老项目里出现过。但我在新项目里只推荐 Mockito,原因很直白:API 语义最接近自然语言。when(userDao.findById(1L)).thenReturn(user)这句话,读起来就是"当调用 userDao.findById 并传入 1 时,返回 user"。而 EasyMock 的 expect 风格我在实际用的时候总觉得思维要绕一下。

PowerMock 的年代感更重。它确实能 mock 静态方法和构造函数,但代价是必须用自定义的类加载器去加载测试类,和 JaCoCo 覆盖率插件经常冲突,和 Java 9 之后的模块系统磨合得也不好。Mockito 从 3.4.0 版本开始,通过 mockito-inline 也能支持静态方法、构造函数、final 方法/类,而且是用字节码插桩实现的,不需要额外类加载器。我实测过,大部分场景下稳定性和兼容性比 PowerMock 好太多。

2.3 JUnit 5 和 Mockito 的扩展模型怎么契合

Mockito 官方做了 mockito-junit-jupiter 这个模块,核心类是 MockitoExtension。它做的事情听起来简单但非常关键:在 JUnit 5 的测试生命周期里自动识别 @Mock、@Spy、@InjectMocks 注解,把 mock 对象创建好并注入进去。你不需要在 @BeforeEach 里写mock = Mockito.mock(Xxx.class),也不需要手动MockitoAnnotations.openMocks(this),类一加载就给你准备好。

更值得说的是它支持严格打桩(Strict Stubbing)。JUnit 4 时代的 Mockito 默认是宽松模式,你在测试里打了一堆桩,其中有一些根本没被调用,测试照样通过。问题是这些"无用桩"会掩盖代码重构之后的行为变化:你以为你还在测那个分支,实际那个分支已经不存在了。MockitoExtension 默认就是严格模式,一旦出现未使用的打桩,它会直接抛 UnnecessaryStubbingException,逼着你删掉多余的桩,让测试永远保持精简。

3. 环境搭建:先把地基打牢

3.1 Maven 依赖的完整写法与版本选择

我用的组合是 JUnit 5.10.x 和 Mockito 5.x,这两个版本在 Maven 中央仓库上有明确的兼容保证。Maven 的 pom 里这样配:

<properties> <junit.version>5.10.2</junit.version> <mockito.version>5.11.0</mockito.version> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.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>

很多人会漏掉 junit-jupiter 和 junit-jupiter-api 的区别。junit-jupiter 是聚合依赖,它会把 junit-jupiter-api、junit-jupiter-engine、junit-jupiter-params 一起拉进来。如果你只引入 api,跑测试的时候会得到 "No tests found" 的提示,因为缺少 engine,JUnit 平台根本不知道要执行哪个测试引擎。所以只用 junit-jupiter 这一个就够了,不需要手动拆开。

Mockito 5.x 默认就是内联 mock maker 吗?并不是。mockito-core 默认用的是子类 mock maker,能 mock 普通类、接口,但不能 mock final 类和 final 方法。如果你要 mock final 类,就用 mockito-inline 替换 mockito-core。注意是替换,不是同时加两个,否则会冲突。

Gradle 用户对应这样写:

dependencies { 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() }

3.2 @ExtendWith 与手动初始化的取舍

在 JUnit 5 里,最简单的用法是给测试类加一个注解:

@ExtendWith(MockitoExtension.class) class UserServiceTest { @Mock private UserRepository userRepository; @Mock private EmailClient emailClient; @InjectMocks private UserService userService; // 测试方法... }

@ExtendWith是 JUnit 5 的统一扩展入口。加载 MockitoExtension 后,Mockito 会在每次测试执行前扫描当前测试实例里的字段,看到 @Mock 就创建 mock 对象,看到 @InjectMocks 就把已经创建好的 mock 注入进去。

有些时候你不想用扩展,比如测试类已经用另一个自定义扩展了,或者你想在任意一个普通方法里临时 mock 一下,那可以手动:

@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }

这个方法和 JUnit 5 没有任何依赖,你哪怕不开 @ExtendWith 也能生效。它内部库也有缓存和清理逻辑,需要关注的是返回值。返回的 AutoCloseable 建议存起来,在 @AfterEach 里 close,不然 IDE 可能会提示资源泄漏。我在真实项目里更推荐 @ExtendWith,因为不用自己管生命周期,而且默认严格模式会自动启用的。手动 openMocks 默认不是严格模式,你还要额外加 @MockitoSettings 去调 strictness,很别扭。

3.3 第一个能跑的测试:写出你的最小示例

写一个简单的订单折扣计算器,它依赖一个汇率服务。假设业务逻辑是根据会员等级打折,再把美元价格转为人民币。我不想去真的请求汇率接口,于是 mock 掉汇率服务:

public interface ExchangeRateService { BigDecimal convert(BigDecimal amount, String from, String to); } public class DiscountCalculator { private final ExchangeRateService exchangeRateService; public DiscountCalculator(ExchangeRateService exchangeRateService) { this.exchangeRateService = exchangeRateService; } public BigDecimal calculate(BigDecimal originalAmount, int level) { BigDecimal discountRate = switch (level) { case 1 -> new BigDecimal("0.9"); case 2 -> new BigDecimal("0.8"); default -> BigDecimal.ONE; }; BigDecimal discounted = originalAmount.multiply(discountRate); return exchangeRateService.convert(discounted, "USD", "CNY"); } }

测试类:

@ExtendWith(MockitoExtension.class) class DiscountCalculatorTest { @Mock private ExchangeRateService exchangeRateService; @InjectMocks private DiscountCalculator discountCalculator; @Test void should_apply_level_two_discount_and_convert_currency() { when(exchangeRateService.convert(new BigDecimal("80"), "USD", "CNY")) .thenReturn(new BigDecimal("560")); BigDecimal result = discountCalculator.calculate(new BigDecimal("100"), 2); assertEquals(0, new BigDecimal("560").compareTo(result)); verify(exchangeRateService).convert(new BigDecimal("80"), "USD", "CNY"); } }

注意几个点:第一,@InjectMocks 是根据构造器注入的,它会把类型匹配的 mock 传进去,我们的 DiscountCalculator 只有一个 ExchangeRateService 参数,所以能装配成功。第二,BigDecimal 不能直接 equals 比较,因为 scale 不同也会影响 equals,用 compareTo 更安全。第三,verify 是在验证"这个方法确实被调用过、参数也对",这比只断言返回值更严格。

3.4 环境踩坑速查:版本冲突和模块问题

我遇到的第一个坑是 Java 9 之后模块系统带来的。如果项目里用了 Platform Thread 的模块路径,某些 mock 内部类反射会报 InaccessibleObjectException。解决办法一般是给 JVM 加--add-opens java.base/java.lang=ALL-UNNAMED,或者在 Maven Surefire 插件配置里加上 argLine。我见过不少团队查了很久才发现是这个原因。

第二个坑是引入了多个版本的 Mockito。同一个项目里 A 模块传递依赖带的是 Mockito 2.x,B 模块用 Mockito 5.x,结果运行时 NoSuchMethodError。遇到这种问题先去mvn dependency:tree看依赖树,把老的 Mockito 用 exclude 排除掉。

第三个坑最容易忽略:使用 JUnit 5 后,Maven 的 Surefire 插件版本太老。老版本 Surefire 根本不认识 JUnit 5 的测试类,输出一堆No tests were executed!。至少用 3.0.0 以上版本,我习惯直接固定 3.2.5。

4. 高频模拟技巧:从入门到顺手

4.1 打桩到底有几种姿势,什么时候用哪一种

最常见的写法是:

when(mockFoo.bar()).thenReturn(value);

这个写法有个隐含约束:Mockito 会在调用 when 之前,先去执行一次 mockFoo.bar(),再把这次调用登记下来作为 stub。如果 bar() 返回 void,这段代码就没法编译,因为 when 需要参数,void 方法不能被用在表达式里。所以 void 方法或者 spy 对象上的方法,需要换一种姿势:

doReturn(value).when(mockFoo).bar(); doThrow(new RuntimeException("xxx")).when(mockFoo).bar();

doReturn 系列的优点是:它不要求目标方法有返回值,也避免了"真的去执行一次方法"的副作用。特别是用 spy 的时候,when(spyObj.realMethod()).thenReturn(...)会先触发真实方法执行,如果真实方法有副作用(写数据库、发请求),测试就出问题了。这时候用 doReturn(...).when(spyObj) 才是安全的。

我踩过一个印象很深的坑:用 spy 监听一个缓存类,然后when(cache.get("key")).thenReturn("value"),结果每次跑测试真的去执行了 cache.get 里的原始代码,反而抛了空指针。改成 doReturn 后问题立刻消失。经验法则就是:凡是你不确定目标方法是不是安全、可重复执行的,一律用 doReturn/doThrow 系列。

4.2 参数匹配器:别像个傻子一样只打固定值

打桩里最忌讳的是把参数写死。你 mock 一个 findById(1L),测试里实际调用的是 findById(2L),那永远匹配不上。为了让测试贴近真实调用,应该用参数匹配器:

when(userRepository.findById(anyLong())).thenReturn(optionalUser); when(userRepository.save(any(User.class))).thenAnswer(invocation -> { User u = invocation.getArgument(0); u.setId(100L); return u; });

anyLong()、eq(value)、anyString()、argThat(自定义lambda) 是常用的几个。这里有个非常关键但不讲没人提醒的规则:如果你决定使用参数匹配器,那方法的所有参数都必须使用匹配器,不能出现一个用匹配器、一个用真实值。

// 错误写法,运行时报 InvalidUseOfMatchersException when(foo.findByNameAndAge("张三", anyInt())).thenReturn(...); // 正确写法 when(foo.findByNameAndAge(eq("张三"), anyInt())).thenReturn(...);

为什么强制?因为 Mockito 在编译期不做检查,运行期靠一个内部标记(ArgumentMatcherStorage)来区分参数是匹配器还是真实值。混用时,Mockito 不知道 "张三" 到底是匹配器还是真实值,直接抛异常。这个错误信息特别容易让人懵,我见过同事改了一上午没找到原因。

argThat 和 any 的区别也值得提。any(SomeClass.class) 只判断类型,argThat 可以写更复杂的逻辑,比如"金额必须大于 0":

when(paymentClient.charge(argThat(a -> a.compareTo(BigDecimal.ZERO) > 0))) .thenReturn(successResponse);

4.3 模拟异常和 void 方法

业务方法里经常有这种场景:发送通知失败不能影响主流程,或者外部调用抛异常需要重试。测试里这样写:

doThrow(new PaymentException("insufficient balance")) .when(paymentClient).charge(any(BigDecimal.class)); assertThrows(PaymentException.class, () -> { orderService.createOrder(...); });

重点说下 doAnswer。它像一个钩子,可以在 mock 方法被调用时执行一段自定义逻辑,还能拿到参数做动态返回。这比写死的 thenReturn 灵活太多。比如你有一个 ID 生成器接口,你想测试"每次调用生成的 ID 都不同":

when(idGenerator.nextId()).thenAnswer(invocation -> { return counter.incrementAndGet(); });

还有更实际的使用场景:mock 一个消息队列发送器,进去后自动给消息打上"已发送"标记。这种副作用 doAnswer 非常合适,它可以拿到 invocation.getArgument(0)。

4.4 verify:不止是"调用过",而是"按预期调用过"

测试里最容易写的是 when,最容易忽略的是 verify。when 管的是"被测方法的返回值从哪来",verify 管的是"某些协作对象的方法是否真的被调用、调了几次、调用顺序对不对"。两者一起用才完整。

常见的 verify 用法:

verify(userRepository, times(1)).save(user); verify(emailClient, never()).sendEmail(anyString()); verify(paymentClient, atLeastOnce()).charge(any()); verify(userRepository, atMost(3)).findById(anyLong());

如果业务逻辑里有分支,比如"保存成功后必须发通知,保存失败则不发",你用 verify(never()) 就能精确测出这个行为。这比去 mock 返回 null 然后看有没有 NPE 要优雅得多。

跨多个 mock 对象的时候,我想验证调用顺序,用 InOrder:

InOrder inOrder = inOrder(auditLog, paymentClient); inOrder.verify(auditLog).record("START"); inOrder.verify(paymentClient).charge(any()); inOrder.verify(auditLog).record("END");

还有 verifyNoMoreInteractions。它要求这个 mock 的所有方法在测试里"交代清楚",有没验证过的调用会抛异常。我一般只在极小的接口上用它,因为大接口上太严格,改业务代码后测试很容易碎一地。

4.5 静态方法、构造函数和 final 类:现代 Mockito 的硬核能力

Mockito 3.4 之前,我不能碰静态方法,必须用 PowerMock。现在用 mockito-inline 后,代码干净很多。模拟静态方法要放在 try-with-resources 里,否则会报 "was not closed":

try (MockedStatic<IdGenerator> mockedStatic = mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::generate).thenReturn("FIXED-ID-001"); String result = orderService.createOrder(...); assertEquals("FIXED-ID-001", result); mockedStatic.verify(IdGenerator::generate, times(1)); }

作用域的限制不是 Mockito 故意刁难人,是因为静态 mock 会修改全局字节码,不限制作用域的话,线程里其他并发测试也会被影响。你想想,两个测试同时 mock 同一个静态方法,各自期待不同的返回值,那不乱套才怪。try-with-resources 保证测试结束就自动还原现场。

mockConstruction 的用法类似:

try (MockedConstruction<EmailClient> construction = mockConstruction(EmailClient.class)) { orderService.sendEmailToUser("test@example.com"); EmailClient mockedClient = construction.constructed().get(0); verify(mockedClient).send("test@example.com"); assertEquals(1, construction.constructed().size()); }

final 类和 final 方法,只要用了 mockito-inline,默认就能 mock,不需要额外写什么。但要注意:如果你的测试目标是一个 final 类,而且你还要用 @InjectMocks 注入进去,那注意构造器得能被 Mockito 访问,不能是 private。

5. 实战拆解:一个订单服务测试从设计到落地

5.1 先画业务用例,再写测试代码

空谈 API 太虚,我直接用一个贴近真实业务的例子。假设有个 OrderService,依赖四个东西:

  • UserRepository:查用户信息
  • PaymentClient:外部支付接口
  • NotificationClient:发送通知
  • IdGenerator:静态工具类,生成订单号

业务规则:

  • 用户不存在,抛 UserNotFoundException,不扣款不发通知
  • 用户存在但余额不足,抛 PaymentException,不发通知
  • 支付成功后,必须发通知,并且通知内容里包含订单号
  • 用户是 VPI 会员,享受 9 折优惠

为了设计测试用例,我先列一个测试矩阵:正常路径、用户不存在、余额不足、支付超时。这样写代码的时候思路是清晰的,不会写一半发现漏分支。

5.2 完整测试代码逐块讲解

@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private UserRepository userRepository; @Mock private PaymentClient paymentClient; @Mock private NotificationClient notificationClient; @InjectMocks private OrderService orderService; @Test void should_create_order_and_send_notification_when_payment_succeeds() { User user = new User(1L, "张三", new BigDecimal("1000"), 2); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(paymentClient.charge(new BigDecimal("90"))).thenReturn(PaymentResult.SUCCESS); try (MockedStatic<IdGenerator> mockedStatic = mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::generate).thenReturn("NO-2024-001"); Order order = orderService.createOrder(1L, new BigDecimal("100")); assertEquals("NO-2024-001", order.getOrderNo()); assertEquals(new BigDecimal("90"), order.getActualAmount()); verify(userRepository).findById(1L); verify(paymentClient).charge(new BigDecimal("90")); verify(notificationClient).send(1L, "订单 NO-2024-001 支付成功"); } } @Test void should_throw_when_user_not_found() { when(userRepository.findById(99L)).thenReturn(Optional.empty()); UserNotFoundException exception = assertThrows(UserNotFoundException.class, () -> orderService.createOrder(99L, new BigDecimal("50"))); assertEquals("用户不存在", exception.getMessage()); verify(paymentClient, never()).charge(any()); verify(notificationClient, never()).send(eq(99L), anyString()); } @Test void should_not_send_notification_when_payment_fails() { User user = new User(1L, "李四", new BigDecimal("10"), 0); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); doThrow(new PaymentException("余额不足")) .when(paymentClient).charge(any(BigDecimal.class)); assertThrows(PaymentException.class, () -> orderService.createOrder(1L, new BigDecimal("50"))); verify(notificationClient, never()).send(anyLong(), anyString()); } }

对应业务代码大概是这样:

public Order createOrder(Long userId, BigDecimal amount) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("用户不存在")); BigDecimal actualAmount = amount; if (user.getLevel() == 2) { actualAmount = amount.multiply(new BigDecimal("0.9")); } String orderNo = IdGenerator.generate(); try { paymentClient.charge(actualAmount); } catch (PaymentException e) { throw e; } notificationClient.send(userId, "订单 " + orderNo + " 支付成功"); return new Order(orderNo, userId, actualAmount); }

注意第三测试里的细节:我在 paymentClient.charge 上用了 doThrow,因为 charge 可能声明返回 PaymentResult,也可能是 void,用 doThrow 都成立。然后 verify(notificationClient, never()).send(anyLong(), anyString()),这一步很关键,它验证了支付失败后通知链路确实被切断。

5.3 细节:怎样让测试互不影响

测试类里 mock 是有状态的。如果两个测试方法共享同一个 mock 实例,前一个测试打桩的返回值可能会污染下一个测试。Mockito 默认每个测试方法跑完后会清理 mock 的状态。但你自己在测试方法里创建的本地 mock 或者外部静态资源,一定要手动清理。静态 mock 我前面说了用 try-with-resources,这是最稳妥的方案。

另外 @InjectMocks 的注入时机是每测试方法执行前。MockitoExtension 会为每个测试方法重新创建一个测试实例(JUnit 5 默认模式),字段上的 mock 也会重新生成,所以天然隔离。如果你把 @TestInstance(PER_CLASS) 打开了,那就得注意 mock 状态不会自动重置,这时候可以考虑在 @BeforeEach 里显式 reset 相关 mock。

5.4 测试命名和结构:让测试被人看得懂

我极不建议 testCreateOrder 这种命名。它只说"我在测创建订单",没说"什么条件下、期望什么结果"。我习惯用 should_do_xxx_when_yyy 这种格式,翻译过来是"当 yy 发生时,应该做 xx"。上面测试方法里的名字一长溜,一开始有人觉得啰嗦,但后来团队里新同学看测试的时候,几乎不用再去翻业务代码,测试本身就是文档。

JUnit 5 的 @Nested 可以更进一步,我经常这样组织:

@Nested class 支付成功 { // 正常路径测试 } @Nested class 用户异常 { // 找不到用户 } @Nested class 支付异常 { // 余额不足、超时 }

用中文类名没问题,JVM 支持,测试报告里显示也清晰。

6. 常见错误排查清单:我来当你的排雷手册

下面这张表是我这几年解决过的高频问题,照着对照排查,比反复试错高效得多。

错误现象根本原因解决方式
UnnecessaryStubbingException严格模式下存在未被使用的打桩删除无用 when 语句,或者拆分测试
InvalidUseOfMatchersException参数匹配器和真实值混用全参数都改成匹配器
Cannot mock/spy because final class默认 MockMaker 不支持 final依赖换成 mockito-inline,或在 resources 下配置 MockMaker
MockitoException: was not closedmockStatic/mockConstruction 作用域未关闭把静态 mock 放进 try-with-resources
No tests found 或找不到 engineSurefire 版本太老或缺少 junit-jupiter-engine升级 Surefire 至 3.0.0+,只引入 junit-jupiter 聚合依赖
InaccessibleObjectExceptionJava 9+ 模块对反射的限制添加 --add-opens 或调整模块描述文件
测试之间相互影响、偶发失败静态状态没清理或 mock 被共享静态 mock 限定作用域,检查 @TestInstance
MockitoAnnotations.openMocks 后注入失败测试类私有字段或构造器不可访问改为包私有字段,或给被测类提供可访问的构造器

我再提两个特别隐蔽的坑。

第一个:在 @BeforeEach 里打了桩,在某个测试方法里却没用到。严格模式下,这个桩会直接让测试失败,提示你可能多写了一行 when。很多人刚升 Mockito 5 时会吓一跳,以为是 bug。实际上这是设计好的:所有桩必须是"该测试真的需要"的。如果确实想保留这个通用桩,就在测试类上标注@MockitoSettings(strictness = Strictness.LENIENT),或者干脆把打桩逻辑放到具体的测试方法里。

第二个:mock 对象和被 mock 的对象被并发访问。JUnit 5 默认单线程执行测试,但如果你配置了并行执行,Mockito 的某些内部状态是非线程安全的。这时别执着于用 Mockito 设置全局配置,优先把并行的测试类隔离开。我的经验是:单元测试阶段没必要做真正意义的并行,收益小,成本高,不如把测试时间花在减少启动开销上。

7. 和 Spring Boot 测试搭配时的取舍

很多人在 Spring Boot 项目里会问:我已经用了 @SpringBootTest,还需要 Mockito 吗?答案是:看你要测什么。

如果测的是 Service 层逻辑,我强烈建议不要启动 Spring 上下文。你用 @ExtendWith(MockitoExtension.class) 加 @Mock/@InjectMocks,一个测试跑下来几十毫秒。而 @SpringBootTest 光启动容器就要好几秒,还依赖配置文件、数据库连接等一堆外部条件。我见过一个项目因为全部用 @SpringBootTest,全量测试跑了快二十分钟,开发反馈周期长到离谱。

如果是有 Controller 的 Web 层测试,可以用 @WebMvcTest 加 @MockBean。@MockBean 能把容器里某个 Bean 替换成 mock,这样测试 Web 层时不用拉起整个上下文,但仍然比纯 Mockito 慢,适合数量不要太多的场景。

我个人是这么分层的:Service 层用 @ExtendWith,Repository 层的数据访问测试用 @DataJpaTest 加真实内存库,Controller 层用 @WebMvcTest。集成测试(需要多个服务协作、真实中间件的场景)才用 @SpringBootTest。这个分层让单测、组件测试、集成测试的目标清晰,跑得也快。

注意 @MockBean 在 Spring Boot 3.4 里被标记为废弃,推荐用 @MockitoBean。迁移成本不高,换注解就行,但如果你还在老版本,只要别混用同一个测试类上的不同机制就好。

8. 关于测试工程师的自我修养,最后多讲几句

借用我团队里的一次 Code Review 经历:同事提交的测试类写了五百行,一半都是重复的 when 打桩,我看得头皮发麻。后来建议大家用"测试金字塔"的思路去设计系统——尽量多写运行快的单元测试,少量写需要真实组件的集成测试,端到端只覆盖关键核心链路。这个思路帮我们省下了大量排队跑 CI 的时间。

别过度模拟。如果你发现一个测试方法里打了几十个桩,那大概率不是测试的问题,而是被测类的设计耦合太重。我在重构时经常遇到这种情况:一个 Service 里 new 了太多依赖,或者依赖了太多静态方法,导致测试不得不把所有东西都 mock 掉。这时候优先考虑依赖注入、抽取接口,而不是勉强写一个复杂的测试。

关于模拟对象和真对象的边界,我的习惯是:值对象(VO、DTO、PO)用真的,因为它们没有逻辑,用真对象能让断言更直观;服务、仓储、外部客户端用 mock,因为这些才是需要交互控制的点。模拟一个纯数据类除了让代码变啰嗦,没有任何额外收益。

最后一个技巧:写完测试后,我总会做一个"故障注入"练习——把业务代码里的关键逻辑故意改坏(比如把比较符号从 > 改成 <),然后跑一遍测试,看看是不是真的会红。如果没红,说明测试没覆盖到这个逻辑,我会补上对应断言。这个方法成本极低,却能极大地提高测试的有效性,推荐每个人都试一试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:36:39

.NET AI工程化升级:Microsoft Agent Framework核心解析

1. 项目概述&#xff1a;这不是一次简单的工具替换&#xff0c;而是一场底层思维的迁移“.NET AI 生态系统的重构与演进&#xff1a;Microsoft Agent Framework 对 Semantic Kernel 的继承、超越与战略整合”——这个标题里藏着三个关键动作&#xff1a;“重构”、“演进”、“…

作者头像 李华
网站建设 2026/10/20 8:36:36

AI Agent底层技术栈拆解:从工具调用到记忆与规划

过去两年我一直在做一件事&#xff1a;把大模型从一个“会聊天的引擎”变成一个“能自己干活的下属”。这条路走下来最大的感受是&#xff0c;AI Agent听起来是个产品概念&#xff0c;本质上却是一整套底层技术栈的拼装——大模型负责思考&#xff0c;工具机制负责动手&#xf…

作者头像 李华
网站建设 2026/10/20 8:36:21

Token经济学原理与区块链激励机制设计

我无法根据您提供的输入内容生成符合要求的博文。原因如下&#xff1a;输入中项目标题为“杨植麟/张鹏/夏立雪/罗福莉/黄超&#xff0c;谈龙虾&#xff0c;谈‘Token经济学’”——该标题包含多位真实人名&#xff08;经公开信息可查&#xff0c;均为人工智能、计算机科学领域活…

作者头像 李华
网站建设 2026/10/20 8:35:54

图形界面文件加密与解密实操指南:从工具选型到故障排查

直接开工。图形界面加密这件事&#xff0c;很多人觉得无非是右键压缩包设个密码&#xff0c;真到用的时候才发现&#xff0c;加密和解密完全两个世界。我在实际处理文件安全这块踩过不少坑&#xff0c;这篇就把图形界面上从加密到解密的完整路径梳理清楚&#xff0c;工具选型、…

作者头像 李华
网站建设 2026/10/20 8:33:56

ESXi虚拟机运行Docker容器:三种方案对比与生产配置踩坑实录

朋友前阵子翻出一台闲置的物理服务器&#xff0c;装了ESXi&#xff0c;然后跑来问我&#xff1a;ESXi上能不能直接装个Docker&#xff1f;我第一反应是这问题得拆开看。后来我自己折腾了一整晚&#xff0c;把ESXi上跑Docker的几条路都试了一遍。这篇就把当时的思考过程、实测数…

作者头像 李华
网站建设 2026/10/20 8:33:24

TypeScript类、构造函数与单例模式:从原型链到工程实践全解析

1. 从JS到TS&#xff0c;先搞清楚我们为什么要折腾先说点接地气的。JavaScript 里的"类"其实是个挺唬人的东西&#xff0c;很多人学了class关键字就以为懂了面向对象&#xff0c;但真到项目里碰上this丢失、原型链污染、构造函数被当普通函数调用这类问题时&#xff…

作者头像 李华