在做 Java 后端测试时,Mockito 基本是一个绕不开的名字。它帮你造出虚拟依赖、控制外部调用返回值,并精确验证对象之间的交互行为。这篇文章不会只贴几个示例凑数,我会把 Mockito 从入门到进阶的核心用法、设计思路、踩坑经验一次性讲清楚,包括参数匹配器、自定义 Answer、verify 验证、ArgumentCaptor、常见异常排查,以及我们在真实项目里沉淀下来的 Mock 测试规范。无论你是刚开始接触单元测试,还是已经在项目里用了一阵子 Mockito,这篇内容都可以当作一份能直接参考的实战手册。
1. 先从整体看 Mockito 要解决什么问题
1.1 单元测试的痛点:依赖地狱与不稳定测试
我一直觉得,很多团队不做单元测试,不是不想做,而是“做了就挂”。原因很典型:被测的业务类里 new 了一个别的 Service,那个 Service 又连 Redis、调第三方接口、读数据库。一旦本地环境没这些中间件,测试直接失败。就算中间件存在,每次跑测试还要等网络、等 IO,慢得让人怀疑人生。
Mockito 的解决方案是“替身”。它不启动容器、不连数据库,而是通过动态代理或者子类方式,在内存里生成一个目标接口或类的替身对象。被测类拿到的永远是替身,而不是真实依赖。替身的所有方法默认什么都不做,返回 null、false 或者 0,然后你再通过打桩让它按测试需要返回指定结果。这样一来,测试环境不再依赖中间件,测试运行速度也能控制在毫秒级。
但这里必须说清楚一个边界:Mockito 是用来写单元测试的,不是用来替代集成测试的。如果我们把数据库访问、Redis 缓存、外部 HTTP 全部 mock 掉,那测的是“你自己的代码在理想环境下的逻辑”,不是“组件协作的正确性”。所以我个人建议:核心业务逻辑层用 Mockito 写单测,涉及真实中间件的链路交给集成测试。你不需要为了 Mockito 而 mock 一切。
1.2 Mockito 的三板斧:mock、stub、verify 的定位与关系
Mockito 的全部功能可以用三个词概括:mock、stub、verify。三者各管一段,组合起来才完整。
- mock:造替身对象。它可以在不写实现类的情况下,模拟一个接口或类的行为。
- stub(打桩):配置替身对象在特定参数下返回特定结果,或者抛出指定异常。
- verify(验证):校验某方法是否被调用、调用次数、调用顺序和实际参数。
它们的定位如果乱掉,很容易写出意义不明的测试。我经常看到有人只 mock 不打桩,结果对象返回 null,断言全挂在 NPE 上;也有人疯狂打桩,但从不 verify,根本不关心被测类到底调了谁。正确的心态应该是:先用 mock 隔离外部依赖,再用 stub 控制外部依赖的输出,最后用 verify 保证被测类确实“做了该做的事”。这三步合起来才是 Mockito 能带给你的测试价值。
1.3 什么时候不该用 Mockito
Mockito 不是银弹,过度使用会反噬。用我经历过的一个小例子来说:一个订单服务,内部调用了库存服务扣减库存,某次上线前单测全绿,但联调时发现库存服务接口参数反了。原因就是单测里我把库存服务整个 mock 掉了,根本没验证真实 HTTP 报文。这种测试看起来在隔离外部依赖,实则把外部依赖的错误一起“隔离”了。
所以我的判断标准是:如果你 mock 的东西是“你自己团队写且逻辑复杂”的类,请谨慎。它可能承担了重要业务规则,mock 掉等于绕过了核心逻辑。如果你 mock 的东西是“跨进程、跨系统”的稳定边界,比如第三方支付接口、消息队列客户端,那非常合适。Mockito 是让测试变快的工具,不是掩盖问题的遮羞布。
2. 环境准备与最基础用法:从 0 到 1 跑通 mock 和打桩
2.1 引入依赖与初始化 mock 对象
现在主流项目基本都是 Maven 或 Gradle。如果用的是 Mockito 5.x 和 JUnit 5,Maven 里只需要两个依赖:一个 mockito-core,一个 mockito-junit-jupiter。Gradle 也一样,不过顺手引入这两个依赖就行:
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency>依赖引入后,创建 mock 对象有两种方式。最简单的是直接静态调用mock():
PaymentClient paymentClient = mock(PaymentClient.class);另一种是配合注解,我强烈推荐这种方式,因为它让代码更干净:
@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock PaymentClient paymentClient; @Mock OrderRepository orderRepository; @InjectMocks OrderService orderService; }这里@ExtendWith(MockitoExtension.class)是 JUnit 5 的桥接器,它负责在每条测试用例执行前初始化所有@Mock注解字段。@InjectMocks会把 mock 对象按构造方法参数注入到被测类里。很多新手忘了加@ExtendWith或者MockitoAnnotations.openMocks(this),结果字段一直是 null,测试一跑就 NPE,排查半天。这个坑说小不小,先记住。
2.2 when().thenReturn() 打桩的正确姿势
创建了 mock 对象,默认情况下它的方法什么都不做。要让方法有返回值,需要打桩。最经典的写法是:
when(paymentClient.pay("A001", new BigDecimal("99.00"))) .thenReturn(true);这段代码翻译过来是:当paymentClient.pay("A001", "99.00")被调用时,返回true。被测类调用这段代码时,不会真的发起网络请求,而是直接拿到你配置的结果。
这里有几个重要的细节。第一,打桩的入参必须是 mock 对象的方法调用,而不是被测对象的方法调用。我看过有人写when(orderService.create(...)).thenReturn(...),这在 Mockito 里是无效的,因为它只能对 mock 对象生效。第二,如果你不关心具体参数,可以用参数匹配器,比如when(paymentClient.pay(anyString(), any())).thenReturn(true),后面我会单独讲匹配器的坑。
另外,打桩的返回值要和真实业务逻辑匹配。比如pay方法本身可能返回一个布尔值,但你打桩返回true,测试里订单服务就会认为支付成功。如果你打桩返回 null,订单服务可能直接 NPE。所以打桩不只是“让测试能跑”,还在模拟真实依赖的合理输出。
2.3 spy 的真实对象与部分打桩
mock 是完全的替身,但有时候我们需要“真实对象的大部分逻辑”,只替换其中一小段。最典型的情况是测试一个类的私有方法之外的内部方法:你不想跑真实的发邮件逻辑,但希望类里其他方法正常执行。这时可以用spy。
@Spy EmailService emailService; @Mock SmtpClient smtpClient; @Test void testSendWelcomeEmail() { doReturn(true).when(emailService).sendInternal(anyString()); UserService userService = new UserService(emailService); userService.welcome("张三"); verify(emailService).sendInternal("张三"); }这段代码里emailService是一个 spy 对象,它默认会调用真实方法,但sendInternal被显式打桩,所以不会真的发出邮件。使用 spy 时有一点特别容易掉坑:如果方法在 spy 内部被自调用,比如sendWelcomeEmail方法内部调用了this.sendInternal,那么打桩sendInternal是可以拦住的,因为 Mockito 走的是子类代理。但如果方法被super.xxx()方式调用,或者 final 方法、私有方法,就拦不住了。遇到这种情况,我会优先考虑重构代码,把需要替换的依赖抽出来,而不是强行对 spy 做文章。
2.4 注解到底怎么配合 JUnit 使用
使用注解时,初始化顺序也算一个高频问题。@InjectMocks会优先选构造方法注入,如果没有构造方法,则会用 setter 或字段注入。我个人建议被测类统一用构造方法注入,并在测试类里把它写成显式创建,而不是完全依赖@InjectMocks:
@BeforeEach void setUp() { orderService = new OrderService(paymentClient, orderRepository); }显式构造有几个好处:一是读者一眼能看清被测类依赖了哪些 mock;二是避免@InjectMocks在多个构造方法时“猜不透”而注入错对象;三是测试里想传入一个不完全 mock 的依赖时,灵活度高很多。@InjectMocks本身很方便,但我也见过它把我们“误导”得以为依赖都注入了,实际却有字段漏掉。稳妥起见,管理好依赖注入,代码稍微多写两行完全值得。
3. 参数匹配器与自定义 Answer:让桩更聪明
3.1 常用匹配器:any、eq、matches 组合
真实业务里,方法调用很多是动态参数。比如订单号是一串随机生成的编号,支付金额是计算出来的小数。如果用精确值打桩,测试写起来会很痛苦,因为你要提前知道所有运行时参数。这时参数匹配器就派上用场了。
when(paymentClient.pay(anyString(), any(BigDecimal.class))) .thenReturn(true);anyString()匹配任意字符串,any(BigDecimal.class)匹配任意 BigDecimal。更细一点的匹配器还包括anyInt()、anyLong()、contains("abc")、startsWith("订单")、matches("\\d{6}")等。使用匹配器有一个铁律:如果方法有多个参数,要么全部用精确值,要么全部用匹配器。不能写成when(obj.method("A001", anyInt())),因为 Mockito 在栈里记录了匹配器的位置,你混用精确值会破坏它的内部状态,直接抛InvalidUseOfMatchersException。这条我当年踩过,后来成了团队评审必查项。
// 这句话看起来“聪明”,实际上会报错 when(paymentClient.pay("A001", any())) .thenReturn(true);正确做法是混用时用eq()包住精确值:
when(paymentClient.pay(eq("A001"), any())) .thenReturn(true);这个eq()也是一个匹配器,它表示“等于这个值”。只要配上eq,上面的铁律就满足了。记住:匹配器的世界里没有特殊的“精确值”,一切都要用匹配器表达。
3.2 自定义 Answer:实现有逻辑的返回
thenReturn只能返回固定值。但有些场景需要根据实际调用参数计算返回值,比如根据折扣率算出最终支付金额,或者根据用户类型返回不同结果。这时可以用thenAnswer:
when(promotionService.calculateDiscount(any(User.class))) .thenAnswer(invocation -> { User user = invocation.getArgument(0); if ("VIP".equals(user.getLevel())) { return new BigDecimal("0.8"); } return new BigDecimal("1.0"); });thenAnswer接收一个Answer对象,或者直接传 Lambda。InvocationOnMock里有getArgument()、getArguments()和getMethod()等方法,你可以拿到本次调用的完整信息,写任意计算逻辑。我曾经用它模拟过一个分布式锁:连续调用 3 次返回成功,之后返回失败,以此测试并发场景下的降级逻辑。这个能力比写一堆 mock 状态字段干净多了。
那thenReturn和thenAnswer怎么选?固定值就thenReturn,需要根据参数计算或需要模拟调用次数行为时,用thenAnswer。两者的执行时机也不一样:thenReturn是预先定义的返回值,thenAnswer是调用时动态计算。性能上两者没有明显差距,但thenAnswer灵活度更高,也更容易写出过于复杂的逻辑,注意别把测试写成了另一个业务实现。
3.3 doReturn/when 与 void 方法打桩
有些方法没有返回值,比如eventBus.publish(event)、logger.log(msg)。这种 void 方法不能直接when(...).thenReturn(...),因为when里的表达式本身需要评估一个返回值,而 void 方法返回 void 根本无法继续调用thenReturn。这时候要换doXxx().when()系列:
doNothing().when(eventBus).publish(any(OrderEvent.class)); doThrow(new RuntimeException("发送失败")) .when(eventBus) .publish(any(OrderEvent.class)); doAnswer(invocation -> { // 模拟异步回调 return null; }).when(eventBus).publish(any(OrderEvent.class));doReturn().when()也可以用来给普通方法打桩,它的效果和when().thenReturn()基本一致,但执行时机不同:when().thenReturn()是在调用时触发,而doReturn().when()是先配置,后触发。对于会执行真实方法而导致抛异常的情况(比如 spy 对象),doReturn().when()更保险。反过来,when().thenReturn()有编译期类型检查的优势,能提前发现返回值类型不匹配。两条路没有绝对优劣,我个人的习惯是:普通方法用when().thenReturn(),void、spy 和需要避免真实调用的场景用doReturn().when()。
4. 行为验证:verify 与 ArgumentCaptor
4.1 验证调用次数与顺序
mock 的另一个价值是行为验证。verify可以确认被测对象是否真的调用了某个依赖,以及调用是否满足预期次数。最常见的是times():
verify(orderRepository, times(1)).save(any(Order.class)); verify(paymentClient, never()).refund(anyString()); verify(paymentClient, atLeastOnce()).pay(anyString(), any()); verify(paymentClient, atMost(3)).pay(anyString(), any());times(1)是最常用的,但如果业务里存在循环、重试机制,次数断言能帮大忙。比如要求“失败后最多重试 2 次”,你可以在测试里模拟第一次失败、第二次成功,然后verify(paymentClient, times(2)).pay(...)。这种验证能直接暴露重试逻辑的缺陷。
如果还要验证调用顺序,用InOrder:
InOrder inOrder = inOrder(paymentClient, orderRepository); inOrder.verify(paymentClient).pay(anyString(), any()); inOrder.verify(orderRepository).save(any(Order.class));这个场景对应的是“先支付,再保存订单”的业务规则。顺序验证往往被忽略,但一旦你的业务对时序有要求,它是最能表达意图的断言方式。注意inOrder里要列出所有涉及的 mock 对象,不然 Mockito 无法构建全局顺序。
4.2 捕获参数:ArgumentCaptor 的用法
verify还能配合ArgumentCaptor捕获被测类传给依赖的实际参数,然后对参数内容做断言。相比用匹配器“匹配一个值”直接通过,捕获参数能让你检查更细致的字段。
ArgumentCaptor<Order> orderCaptor = ArgumentCaptor.forClass(Order.class); verify(orderRepository).save(orderCaptor.capture()); Order savedOrder = orderCaptor.getValue(); assertEquals("A001", savedOrder.getOrderId()); assertEquals(new BigDecimal("99.00"), savedOrder.getAmount());这段代码的意义在于:我们不只要确认save被调用了,还要确认保存的订单里金额是否经过正确计算、订单状态是否被置为“已支付”。如果被测类里有一行order.setStatus("PAYED")忘写了,thenReturn打桩不会暴露它,ArgumentCaptor却可以立刻抓住。需要捕获多个参数时,可以用captor.getAllValues()拿到本次测试里所有调用传入的值列表,适合测试循环批量保存的场景。
4.3 verifyNoMoreInteractions 与 verifyNoInteractions 的取舍
Mockito 还提供了两个“清场”式验证:verifyNoMoreInteractions(mock)验证 mock 上没有多余的未验证调用;verifyNoInteractions(mock)验证 mock 完全没有任何交互。很多人看到这俩挺兴奋,觉得能保证“被测代码没有额外动作”,结果在真实项目里堆了一堆verifyNoMoreInteractions,每加一行业务代码就开始红,测试变得出奇地脆弱。
我比较克制。verifyNoInteractions适合断言“某个依赖完全不该被触碰”的场景,比如库存不足时不应该调用支付客户端:
when(inventoryService.checkStock(anyString())).thenReturn(false); assertThrows(BusinessException.class, () -> orderService.create("A001")); verifyNoInteractions(paymentClient);但verifyNoMoreInteractions的问题是它对“验证过哪些方法”有严格顺序要求。如果你前面用verify只验证了pay,后面被测类还调用了getStatus,这个getStatus也会被算作“多余交互”,测试直接挂。这种约束收益低、维护成本高,我会尽量避免放在老代码改造里,只在全新代码且预先定义好交互契约时使用。
5. 常见问题与排查技巧实录
5.1 InvalidUseOfMatchersException 为什么老缠着你
这是 Mockito 新手最常遇到的异常,没有之一。字面意思是“匹配器使用非法”,但真实原因通常有两种。第一种是匹配器和精确值混用,上文已经提到。第二种是匹配器出现在了不该出现的地方,比如把any()写在测试方法里的某个中间变量上,或者写在一个自定义方法里,而非直接作为 mock 方法参数:
// 错误写法 String s = anyString(); when(paymentClient.pay(s, any())).thenReturn(true);这里anyString()在when外面执行,Mockito 的匹配器栈记录不到对应上下文,自然报错。排查技巧也很直接:看异常堆栈第一行,它通常会指出哪个测试方法、哪一行出问题;优先检查最近一次when/verify的参数是否全用了匹配器。如果你在循环里调用带匹配器的方法,也要注意循环结束后匹配器状态是否被清理干净。实际上,Mockito 会在下一步操作时自动清空匹配器栈,所以循环里使用匹配器本身没问题,但循环里如果混用了精确值,就会引发连锁异常。
5.2 静态方法不能 mock 和 final 类问题
传统 JVM 代理只能 mock 非 final 的实例方法和接口。因此很多人一直以为 Mockito 无法 mock 静态方法或 final 类。从 Mockito 4 开始内置了 inline mock maker,可以 mock 静态方法、final 类和 final 方法。但我必须强调,能用是能用,不建议当常规武器。
静态方法 mock 的核心风险在两个方面:一是 mock 静态方法要确保测试是单线程的、没有并发干扰,因为它改的是整个类加载器的字节码;二是测试代码和真实代码的耦合度会变高,一旦有人改了静态方法签名,测试里所有相关打桩都要变。我在项目里见过大量MockedStatic嵌套在 try-with-resources 里的写法,可读性很差。如果想把静态调用干掉,我更推荐先做代码重构,把静态方法包一层实例对象,或者用一个可替换的间接层。重构之后测试自然干净,不会沦为 Mockito 特技表演。
5.3 thenReturn 与 doReturn 搞混会踩什么坑
这两者的区别前面说过,但踩坑实录值得再强调一次。如果你 mock 的对象是一个 super 类或者接口代理,when().thenReturn()在配置阶段会先执行一次真实调用(对 spy 而言)。这个“先执行”有时候会引发副作用。例如spyUserService.getUser("A001")在真实环境会查数据库,你用when(spyUserService.getUser("A001")).thenReturn(fakeUser)时,Mockito 为了计算 stubbing,真的会把getUser("A001")调用一次,数据库直接报错。
解决办法就是把打桩顺序反过来,用doReturn(fakeUser).when(spyUserService).getUser("A001")。它不会在配置阶段触发真实方法。另一个常见点:如果thenReturn传的参数类型不对,编译器能帮我们拦截;但doReturn是 Object 类型,没有编译期类型检查,如果返回值类型不匹配,会运行到被测代码时才 ClassCastException。所以我的习惯是:能用when().thenReturn()就优先用,spy 和 void 场景再用doReturn().when()。
5.4 排查技巧:失败测试先看哪几样
当一个 Mockito 测试失败时,很多人上来就 debug,效率极低。我的排查顺序固定成四步。
第一步,看断言错误里哪个 mock 方法“被期望调用却没有调用”,这通常是打桩条件不对或参数匹配器写错;第二步,看是否有 NPE,这多半是 mock 对象没初始化,检查@ExtendWith和@InjectMocks是否存在;第三步,看是否有UnnecessaryStubbingException,说明你打桩了但测试路径根本没走到那一步,可能被测逻辑分支不对,也可能是过度打桩;第四步,再往上翻异常堆栈,确认是不是在 spy 或静态方法 mock 时触发了真实调用。
这套顺序在实际排查中省了我大量时间。有些开发一看到异常就怀疑框架 bug,其实 Mockito 异常信息写得已经很直白,错误原因和提示修复方向通常就在第一行异常里。静下心读异常,往往比 debug 一个个断点快得多。
我顺手整理了一个速查表,供参考:
| 异常现象 | 常见原因 | 处理方案 |
|---|---|---|
| InvalidUseOfMatchersException | 匹配器和精确值混用 | 用 eq() 包住精确值 |
| 大量 NPE | mock 未初始化 | 加 @ExtendWith(MockitoExtension.class) 或 openMocks |
| UnnecessaryStubbingException | 打桩未被执行 | 删除未用打桩或用 lenient() |
| 真实方法被调用(spy) | when().thenReturn 配置阶段触发真实调用 | 改用 doReturn().when() |
| ClassCastException | doReturn 返回值类型不匹配 | 检查返回值类型,尽量用 when().thenReturn() 获得编译期检查 |
| MockitoException: static mocking | 未启用 inline mock maker | 确认 Mockito 5.x 内置,或引入 mockito-inline |
6. 实际项目中的工作流与团队规范
6.1 写 mock 代码的团队约定
Mockito 用久了,很容易写出“全 mock、全覆盖、全 verify”的测试,看着绿,实际是纸老虎。我们团队慢慢沉淀了几条规则,我觉得值得分享。
第一条,对外部依赖做 mock,对内部逻辑不做 mock。说白了就是只替身“别人的对象”,不替身“自己的核心业务”;第二条,每条测试只验证一个核心行为。如果一个测试方法里有三个 Verify、五个 when,它已经变成集成测试了,维护成本远比收益高;第三条,打桩无用的桩要清理。Mockito 4 以后默认对未使用的桩报UnnecessaryStubbingException,这不是框架找茬,而是帮你发现测试用例没走到对应分支。如果确实准备了一个桩但这条分支没走到,说明测试意图不明,建议拆分成两个用例。
另外,命名上尽量沿着“行为”而非“实现”走。比如shouldThrowExceptionWhenStockNotEnough就比testCreateOrderFail更能表达意图。配合这些约定的测试,才能作为业务行为的活文档,而不是一堆只知道“跑绿”的验证器。
6.2 用 Mockito 重构一段“难测代码”的实例
有一个模拟项目我记忆很深。老代码里订单模块直接在方法内部new PaymentClient(),导致测试没法替换,每次单测都会真的去创建连接。我用 Mockito 重构它的思路分三步:第一步把PaymentClient抽成构造参数,让依赖从内部创建变外部注入;第二步在测试类里 mockPaymentClient,并用@InjectMocks注入被测服务;第三步对测试中原先无法触达的逻辑分支补齐验证。
重构之后,原来三分钟跑不完的测试变成了一秒内跑完,而且还顺手发现了一个老 bug:订单金额为 0 时,依然调用了支付接口。这类问题在旧代码里几乎不可能通过手工测试暴露出来,但重构后加一个verify(paymentClient, never()).pay(...)就牢牢锁住了。可以说,Mockito 倒逼了一次更健康的依赖注入设计,这种收益往往比 mock 本身更大。
6.3 我最后想分享的一个小技巧
关于 Mockito,我最后想补充一个不那么热门但非常实用的小技巧:用BDDMockito风格的 API 提高测试可读性。它把when(...).thenReturn(...)写成given(...).willReturn(...),把verify(...)写成then(...).should(...)。这听上去只是换了个名字,但对团队协作帮助很大,因为given让测试读起来像“安排场景”,then让断言看起来像“验证结果”,业务人员也能大致看懂测试在干什么。
// BDD 风格 given(paymentClient.pay(anyString(), any())) .willReturn(true); orderService.create("A001", new BigDecimal("99.00")); then(orderRepository).should(times(1)).save(any(Order.class)); then(paymentClient).should(never()).refund(anyString());当然,这只是风格层的改变,底层一切都还是 Mockito。如果你所在的团队正在从零建设单元测试,我建议直接统一成 BDD 风格,因为它更贴近“行为驱动”的思考路径,团队评审时也更高效。等大家形成了写测试的肌肉记忆,再慢慢挖掘 Mockito 更高级的特性也不迟。