ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Mockito实战手册:从打桩到验证的单元测试精要

Mockito实战手册:从打桩到验证的单元测试精要

单元测试写到一定阶段,几乎所有人都会撞上同一堵墙:业务代码里依赖了数据库、消息队列、第三方接口,直接 new 一个对象测不下去,mock 一套又写得又臭又长。我最早接触 Mockito 的时候,也是被各种when(...).thenReturn(...)绕得头晕,直到把它的行为模型彻底理顺,才发现这套框架其实就三件事——造替身、定行为、做验证。这篇内容就是把我这些年攒下来的 Mockito 用例整理成一份可以随时翻查的实战手册,从最基础的打桩到参数匹配器、Spy、Answer、静态方法模拟,再到那些年踩过的坑,全部摊开讲。不管你是刚写第一个单元测试的新手,还是想把团队测试规范统一起来的老手,都能从里面找到能直接抄作业的片段。

1. 先搞清楚 Mockito 到底在替谁干活

很多人用 Mockito 用了大半年,还是分不清 Mock 和 Stub 的区别,写出来的测试要么过度打桩,要么验证得莫名其妙。这一节把概念地基打牢,后面所有用例才有落脚点。

1.1 Mock、Stub、Spy 三者的行为边界

用生活化的说法:Stub 是"预设答案的应答机",你问它什么它答什么,但它不关心你问了几次;Mock 是"带录音功能的应答机",除了给答案,它还记录你调用了哪些方法、传了什么参数、调了几次;Spy 是"在真人身上装了个监听器",对象本身是真实实例,方法默认走真实逻辑,只有你显式打桩的那部分才被替换。

在 Mockito 里,mock()创建的对象所有方法默认返回"空值"(对象返回 null,基本类型返回默认值,集合返回空集合),而spy()创建的对象默认调用真实方法。这个差异决定了选型:

类型创建方式默认行为典型场景
MockMockito.mock(X.class)全部返回默认空值隔离外部依赖,验证交互
SpyMockito.spy(realObj)调用真实方法部分打桩,测真实逻辑
Stub通过when().thenReturn()实现由你定义只关心返回值,不验证调用

我个人的经验是:优先用 Mock,慎用 Spy。Spy 看起来很美,但它会执行真实构造函数和真实方法,一旦真实方法里有副作用(写文件、发请求),测试就变得不可控。只有在"想测一个类的部分方法、又需要另一部分方法保持真实"时才考虑 Spy,而且必须清楚哪些方法被桩了。

1.2 一个最小可运行用例,把流程跑通

先别急着上复杂场景,把最小闭环跑通比什么都重要。假设有个订单服务依赖一个库存客户端:

public class OrderService { private final StockClient stockClient; public OrderService(StockClient stockClient) { this.stockClient = stockClient; } public boolean placeOrder(String sku, int qty) { int available = stockClient.queryStock(sku); if (available >= qty) { stockClient.deduct(sku, qty); return true; } return false; } }

对应的测试:

@Test void placeOrder_shouldSucceed_whenStockEnough() { StockClient stockClient = Mockito.mock(StockClient.class); Mockito.when(stockClient.queryStock("SKU-1")).thenReturn(10); OrderService service = new OrderService(stockClient); boolean result = service.placeOrder("SKU-1", 3); assertTrue(result); Mockito.verify(stockClient).deduct("SKU-1", 3); }

这段代码里三个动作对应 Mockito 的三板斧:mock()造替身、when().thenReturn()定行为、verify()做验证。跑通它,你就理解了 80% 的日常用法。剩下的 20% 是各种边界和陷阱,下面逐个拆。

提示:mock()出来的对象,未打桩的方法不会抛异常,而是返回默认值。这一点和"严格模式"有关,后面第 5 节会专门讲,别在这里埋雷。

1.3 为什么你的测试类需要 MockitoExtension

早期我习惯在每个测试方法里手动mock(),后来统一改成注解方式,代码干净了一大截:

@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private StockClient stockClient; @InjectMocks private OrderService orderService; }

@Mock负责造替身,@InjectMocks负责把替身塞进被测对象(按构造器、setter、字段的顺序尝试注入)。这里有个容易忽略的点:@InjectMocks的注入是"尽力而为",如果被测类有多个构造器或者字段类型对不上,它可能悄悄注入失败,然后你在测试里拿到一个 null 依赖,报 NullPointerException 却找不到原因。我的做法是:一旦发现注入行为不符合预期,立刻改回手动构造,别跟注解较劲。

2. 打桩的十八般武艺:从 thenReturn 到 thenAnswer

打桩是 Mockito 使用频率最高的操作,但真正用明白的人不多。返回值、抛异常、连续返回、动态计算、回调触发,每种都有对应的写法,选错了会让测试又脆又难读。

2.1 返回值、异常与连续调用的组合拳

最基础的三种打桩方式,先列清楚:

// 返回固定值 when(repo.findById(1L)).thenReturn(user); // 抛出异常 when(repo.findById(2L)).thenThrow(new NotFoundException("not found")); // 连续调用返回不同值(第一次、第二次、之后) when(repo.nextId()).thenReturn(1L, 2L, 3L);

连续返回这个特性特别适合测"重试逻辑"或"分页游标"。比如一个带重试的远程调用,前两次失败第三次成功:

when(remoteClient.call()) .thenThrow(new TimeoutException()) .thenThrow(new TimeoutException()) .thenReturn("ok");

这里有个细节值得说:thenThrow和thenReturn可以链式混用,Mockito 会按顺序消费。但如果你只写了一个thenReturn,后续所有调用都返回同一个值,不会"用完就没了"。很多人误以为连续返回是"一次性"的,其实最后一个值会被无限复用。

2.2 参数匹配器:别让精确匹配拖垮测试

写死参数值(when(repo.findByName("张三")))在参数简单时没问题,但一旦参数是动态生成的(比如带时间戳、UUID),精确匹配就废了。这时候要用参数匹配器:

when(repo.findByName(anyString())).thenReturn(user); when(repo.save(any(Order.class))).thenReturn(savedOrder); when(repo.findByAge(gt(18))).thenReturn(adults);

匹配器家族常用的有any()、anyString()、anyInt()、eq()、isNull()、notNull()、gt()/lt()、contains()、startsWith()。这里有一条铁律:一旦某个参数用了匹配器,同一个方法调用里的所有参数都必须用匹配器。下面这种写法会直接抛InvalidUseOfMatchersException:

// 错误:混用精确值和匹配器 when(service.transfer("A", anyInt())).thenReturn(true); // 正确:全部用匹配器 when(service.transfer(eq("A"), anyInt())).thenReturn(true);

我见过太多人在这条规则上翻车,报错信息又不够直观,排查半天。记住它,能省下大量时间。

2.3 thenAnswer:需要"看参数下菜"时的正解

有些场景返回值依赖入参,比如"根据 ID 返回对应对象",用thenReturn写死就不合适了。thenAnswer允许你拿到调用参数现场计算:

when(repo.save(any(Order.class))).thenAnswer(invocation -> { Order arg = invocation.getArgument(0); arg.setId(100L); return arg; });

InvocationOnMock提供getArgument(int)、getArguments()、getMethod()等方法,足够应付绝大多数动态场景。我的经验是:能用 thenReturn 就别用 thenAnswer,因为 Answer 里的逻辑本身也需要测试,容易把测试写复杂。只有当返回值确实依赖入参、或者需要在打桩时产生副作用(比如记录调用顺序)时才用它。

2.4 doReturn/doThrow 系列:绕开真实方法调用的陷阱

when(spy.someMethod())这种写法有个隐蔽问题:它会先真实调用一次someMethod(),然后才把打桩结果注册进去。如果真实方法有副作用或者会抛异常,测试还没开始就挂了。这时候要用doXxx系列:

// 对 spy 打桩,避免真实方法被调用 doReturn("mocked").when(spy).someMethod(); // 对 void 方法打桩(void 方法没法用 when) doThrow(new RuntimeException()).when(repo).deleteById(1L); doNothing().when(repo).flush();

doReturn/doThrow/doNothing/doCallRealMethod这一族的核心价值就是"先声明行为,再触发",天然避开了真实调用。对 void 方法打桩只能用 doXxx,这是硬性限制,因为when(voidMethod())在语法上就不成立。

3. 验证:不只是 verify 一次那么简单

打桩解决"给什么",验证解决"做了什么"。很多人只会verify(mock).method(),其实验证的维度远比想象中丰富:次数、顺序、超时、从未发生、验证后重置,每个维度都对应一类测试需求。

3.1 调用次数与"从未调用"的精确表达

verify(repo).save(any()); // 默认验证恰好 1 次 verify(repo, times(3)).save(any()); // 恰好 3 次 verify(repo, never()).deleteById(any());// 从未调用 verify(repo, atLeastOnce()).findAll(); // 至少 1 次 verify(repo, atMost(2)).findAll(); // 最多 2 次

never()在测"短路逻辑"时特别好用。比如一个缓存命中就不查库的服务,你可以验证"缓存命中时数据库查询从未发生":

when(cache.get("k")).thenReturn(Optional.of(value)); service.get("k"); verify(db, never()).query(anyString());

这种"负向验证"往往比正向验证更能暴露逻辑错误,因为它直接锁定了"不该发生的事"。

3.2 InOrder 与超时验证:处理时序敏感场景

当多个 mock 之间存在调用顺序要求时,用InOrder:

InOrder inOrder = inOrder(lock, db, cache); inOrder.verify(lock).acquire(); inOrder.verify(db).query(any()); inOrder.verify(cache).put(any(), any());

顺序验证适合测"资源释放顺序""事务提交顺序"这类强时序逻辑。但要注意:InOrder 只约束你显式 verify 的那些调用之间的相对顺序,没被 verify 的调用不参与排序,别指望它校验全局顺序。

超时验证用于异步场景:

verify(executor, timeout(1000)).submit(any());

timeout(ms)会等待指定时间,期间轮询检查调用是否发生。它适合验证"异步任务最终被提交",但不适合验证"没有发生"——因为超时验证本质是"等到发生为止",验证 never 还是要用never()配合适当的等待策略。

3.3 verifyNoMoreInteractions 的正确打开方式

verifyNoMoreInteractions(mock)表示"除了已经 verify 过的调用,没有其他调用"。听起来很严格,但滥用它会制造大量脆弱的测试——被测类多调一个无关方法,测试就红。我的建议是:只在明确需要锁定"交互集合"的场景用它,比如验证一个门面类只调用了预期的几个下游方法。日常测试里,精确的verify(mock, times(n))通常比verifyNoMoreInteractions更稳。

4. 静态方法、构造器与 final 类:Mockito 的能力边界在扩张

传统认知里 Mockito 不能 mock 静态方法和构造器,这个说法在 Mockito 3.4 之后已经不准确了。mockito-inline和后续内置的 inline mock maker 让这些成为可能,但代价和限制必须讲清楚。

4.1 mockStatic 的用法与资源释放

try (MockedStatic<SystemClock> mocked = mockStatic(SystemClock.class)) { mocked.when(SystemClock::now).thenReturn(Instant.parse("2024-01-01T00:00:00Z")); // 测试逻辑 }

关键点在于MockedStatic必须关闭,用 try-with-resources 是最稳的方式。如果忘记关闭,静态 mock 会泄漏到后续测试,造成"这个测试单独跑没问题、一起跑就挂"的灵异现象。我踩过一次,排查了两个小时才发现是上一个测试没关静态 mock。

另外,mockStatic是线程局部的,只在当前线程生效。如果你的被测代码开了子线程去调静态方法,mock 不会生效,这一点务必注意。

4.2 mockConstruction 与 final 类的处理

try (MockedConstruction<HttpClient> mocked = mockConstruction(HttpClient.class, (mock, context) -> { when(mock.send(any())).thenReturn(response); })) { // new HttpClient() 会返回 mock 实例 }

构造器 mock 适合测那些"在方法内部 new 依赖"的遗留代码。但我要泼盆冷水:能用构造器注入重构的,就别用 mockConstruction。它让测试和实现细节强耦合,重构时极易断裂。把它当作"改造遗留代码前的临时手段",而不是常规武器。

final 类在 inline mock maker 下可以直接 mock,无需额外配置。但如果你用的是老版本或者受限环境,可能需要显式启用:

// 在 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker 中写入 mock-maker-inline

5. 那些年踩过的坑:严格模式、空返回值与过度打桩

Mockito 的坑大多不在 API 本身,而在"默认行为"和"使用习惯"的错配上。这一节把高频翻车点集中列出来,每条都配了排查思路。

5.1 UnnecessaryStubbingException:严格模式的善意提醒

用MockitoExtension时默认开启STRICT_STUBS,如果你打了一个桩但测试里没用到,它会抛UnnecessaryStubbingException。这个设计初衷是好的——防止复制粘贴留下的无用桩掩盖真实问题。但有时候桩确实"可能用不到"(比如参数化测试的不同分支),这时候可以:

// 方式一:放宽整个测试类 @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) class SomeTest { } // 方式二:针对单个桩放宽 lenient().when(repo.find(any())).thenReturn(user);

我的原则是:优先修测试,而不是无脑 lenient。一个用不到的桩,往往意味着测试逻辑和实现脱节了,值得回头看看。

5.2 空返回值引发的 NPE 连锁反应

mock()的对象未打桩方法返回 null,如果被测代码直接链式调用,就会 NPE:

// 被测代码 String name = userService.getUser(id).getName(); // 如果 getUser 没打桩,返回 null,getName() 直接 NPE

排查这类问题的技巧:看 NPE 的堆栈,如果指向的是 mock 对象的方法调用,八成是漏打桩了。另一个隐蔽点是集合返回值——老版本 Mockito 对集合返回空集合,新版本在严格模式下可能返回 null,写代码时对 mock 返回值做空判断更稳妥。

5.3 过度打桩:测试通过但什么都没测到

最危险的情况不是测试失败,而是测试"假通过"。比如:

when(service.calculate(any())).thenReturn(100); assertEquals(100, service.calculate(5));

这个测试除了证明"Mockito 能返回 100",什么都没验证。打桩应该只发生在被测对象的依赖上,绝不打桩被测对象本身。判断标准很简单:如果你 mock 了正在测试的那个类的方法,这个测试基本没有价值。

5.4 常见异常速查表

异常典型原因处理方式
InvalidUseOfMatchersException精确值与匹配器混用全部改用匹配器
UnnecessaryStubbingException打了桩但没用到删除无用桩或 lenient
PotentialStubbingProblem参数不匹配导致走了默认桩检查参数或用any()
WrongTypeOfReturnValuethenReturn 类型与签名不符检查泛型与返回类型
MissingMethodInvocationExceptionwhen 里不是方法调用确认 when 包裹的是 mock 方法

6. 把用例组织成可维护的测试资产

单个用例写对不难,难的是一个项目几百个测试还能保持可读、可改、跑得快。这一节聊组织层面的经验,都是被真实项目磨出来的。

6.1 命名与结构:让测试自己说话

我坚持的命名规范是"方法名_场景_预期",比如placeOrder_shouldFail_whenStockInsufficient。结构上用 Given-When-Then 三段式,中间用空行隔开:

@Test void placeOrder_shouldFail_whenStockInsufficient() { // given when(stockClient.queryStock("SKU-1")).thenReturn(1); // when boolean result = orderService.placeOrder("SKU-1", 5); // then assertFalse(result); verify(stockClient, never()).deduct(anyString(), anyInt()); }

这种结构的好处是:读测试的人不需要理解实现,光看三段就能知道"前提是什么、做了什么、期望什么"。团队统一这套规范后,代码评审效率明显提升。

6.2 测试数据构造:Builder 模式比一堆 setter 香

构造复杂对象时,满屏的setXxx会让测试臃肿。用一个测试专用的 Builder:

Order order = OrderFixture.anOrder() .withSku("SKU-1") .withQty(3) .build();

Fixture 类放在 test 目录下,只服务于测试,不污染生产代码。这个模式在领域对象字段多的时候收益巨大,改一个字段不用满项目找 setter。

6.3 什么时候不该用 Mockito

最后说个反直觉的观点:不是所有依赖都该 mock。值对象、纯函数工具类、简单的 POJO,直接 new 真实实例更清晰。Mockito 适合隔离"有副作用、慢、不稳定"的依赖(网络、数据库、文件系统、时间)。把简单对象也 mock 掉,测试会变得又长又假。判断标准:这个依赖如果换成真实实现,测试会不会变慢、变不稳定、产生外部副作用?会,就 mock;不会,就用真的。

这套用例文档我在多个项目里反复用过,最大的体会是:Mockito 的 API 就那么些,真正拉开差距的是"知道什么时候不该用它"。把打桩控制在依赖边界、把验证聚焦在关键交互、把测试数据用 Fixture 管好,剩下的就是熟练度问题。遇到报错先对照第 5 节的速查表,八成能定位;定位不了就回到最小用例,把复杂场景一层层剥开,别在几十行打桩里硬猜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进