单元测试写到一定阶段,几乎所有人都会撞上同一堵墙:依赖太多、状态太乱、真实对象根本构造不出来。数据库连接、外部接口、随机数、时间戳、文件系统,这些东西一旦掺进被测代码,测试就从“验证逻辑”变成了“搭建环境”。Mockito 这类测试替身框架就是在这个场景下被反复拿出来的工具,而“Mockito 超全用例文档”这个标题,本质上指向的是一件事——把日常写单测时能遇到的各类打桩、验证、参数匹配、行为模拟场景,整理成一份可以直接抄、可以对照排查的用例集。
我这些年带过不少刚接触单元测试的同学,发现一个共性:Mockito 的 API 就那么几个,mock、when、verify、thenReturn,看一遍文档半小时就能“会用”,但真正写起来,十有八九会卡在几个地方——静态方法怎么打桩、void方法怎么验证、泛型返回值怎么处理、连续调用怎么模拟不同结果、ArgumentCaptor什么时候该用。这些问题在官方文档里往往一笔带过,得靠踩坑才能记住。所以这篇内容我打算按“用例”的思路来组织,把每个高频场景拆成可复现的最小片段,讲清楚它解决什么问题、为什么这么写、写的时候容易错在哪。
适合谁看?如果你已经能写基本的 JUnit 测试,但对 Mockito 的掌握还停留在“会 mock 一个接口”的阶段,那这篇基本能覆盖你接下来半年会遇到的绝大多数场景。如果你已经用得比较熟,也可以把它当成一份速查手册,尤其是参数匹配器和验证模式那几块,细节多,忘得也快。下面我按设计思路、核心用例、实操流程、问题排查四个大块展开,中间会穿插大量可直接复制的代码片段和我自己踩过的坑。
1. 整体设计与用例组织思路
1.1 为什么按“用例”而不是按“API”来组织
大多数 Mockito 的入门材料是按 API 罗列:先讲mock(),再讲when(),再讲verify()。这种组织方式对查文档友好,但对实际写测试不友好。因为真实写测试时,你的思维起点不是“我要用哪个 API”,而是“我这个场景该怎么模拟”。比如“我要让某个方法第一次返回成功、第二次抛异常”,你脑子里想的是场景,不是thenReturn().thenThrow()这个链式调用。
按用例组织的好处是,每个条目都是一个完整的问题-方案对。读者遇到问题时,能直接按场景检索,而不是先翻译成 API 名再去查。这也是我把标题定成“用例文档”而不是“API 手册”的原因。整份内容的组织逻辑是:先按被测对象的类型分(普通方法、void 方法、静态方法、泛型方法),再按操作意图分(打桩返回值、模拟异常、验证调用、捕获参数、模拟连续行为)。这个二维结构基本能覆盖日常九成以上的场景。
另一个考量是版本兼容。Mockito 从 2.x 到 5.x 变化不小,尤其是静态方法打桩,早期需要配合 PowerMock,后来官方在 3.4.0 引入了mockStatic,用法完全不同。用例文档必须标注清楚每个写法适用的版本区间,否则读者复制过去直接编译失败。我在每个关键用例里都会注明版本要求,这一点比 API 罗列式文档更重要。
1.2 测试替身的分层:mock、spy、stub 到底怎么选
在动手写用例之前,有个概念必须先理清,否则后面全是糊涂账。Mockito 里常见的替身有三类:mock、spy、stub(严格说 stub 是一种行为,不是对象类型,但日常口语里常混用)。很多同学分不清 mock 和 spy,导致该用 spy 的地方用了 mock,结果被测对象的真实逻辑根本没跑。
mock创建的是一个完全空的对象,所有方法默认返回“空值”——对象返回null,基本类型返回默认值(int是 0,boolean是 false),集合返回空集合。它不保留任何真实实现。适合模拟那些你完全不关心内部逻辑、只需要它“配合演出”的依赖,比如一个远程服务客户端。
spy则是基于一个真实对象创建的包装,默认调用真实方法,只有你显式打桩的方法才会被替换。适合你想保留大部分真实行为、只对个别方法做干预的场景。这里有个经典坑:用spy时如果写成doReturn().when(spy).method()和when(spy.method()).thenReturn()效果不同,后者会真实调用一次方法,如果方法里有副作用或抛异常,测试就直接挂了。这个细节我在第 4 部分会专门展开。
至于 stub,它指的是“预设返回值”这个动作本身。when(...).thenReturn(...)就是在做 stub。理解这三者的关系,后面的用例才不会用错对象类型。
1.3 用例覆盖的边界与取舍
“超全”这个词容易让人误解,好像要把所有 API 都覆盖一遍。实际上 Mockito 有些能力我刻意没纳入重点,比如对final类的 mock(需要额外配置 inline mock maker)、对构造函数的 mock(依赖mockConstruction,场景较窄且容易让测试变脆)。这些能力不是没用,而是滥用会掩盖设计问题——一个类如果到处需要 mock 构造函数,往往说明它该重构了。
我重点覆盖的是那些“高频且容易写错”的场景:参数匹配器的混用规则、void方法的验证、连续调用的行为序列、ArgumentCaptor的捕获时机、Answer接口做动态返回。这些是实际项目里出现频率最高、也最容易出 bug 的地方。取舍的标准很简单:如果一个用例在真实项目里一个月都遇不到一次,就不值得占篇幅;如果一个用例每周都要写、每次都要查,就必须写透。
2. 核心用例拆解与关键细节
2.1 基础打桩:返回值、异常与默认行为
最基础的用例是给方法预设返回值。假设有个订单查询服务,依赖一个远程客户端:
public interface OrderClient { Order queryById(Long id); List<Order> queryByUser(Long userId); }打桩写法是when(orderClient.queryById(1L)).thenReturn(order)。这里第一个容易忽略的点是参数匹配。上面这种写法用的是“相等匹配”,即实际调用时参数必须equals传入的值。如果被测代码传的是new Long(1)或者从别处解析出来的1L,只要equals成立就能匹配上。但如果参数是对象且没重写equals,就会匹配失败,返回null,然后被测代码 NPE,排查半天才发现是打桩没生效。
模拟异常用thenThrow,注意抛出的异常类型必须和方法签名兼容(受检异常要么方法声明了,要么用RuntimeException子类)。连续行为用链式调用:
when(orderClient.queryById(anyLong())) .thenReturn(order1) .thenReturn(order2) .thenThrow(new RuntimeException("timeout"));这段的意思是第一次调用返回 order1,第二次返回 order2,第三次抛异常,之后一直抛异常。这个用例在测试重试逻辑时特别有用。但要注意,如果被测代码只调用了一次,后面预设的行为不会触发,也不会有任何提示——Mockito 默认不校验“所有 stub 都被用到”,需要开启严格模式(MockitoJUnitRunner.StrictStubs或@ExtendWith(MockitoExtension.class)默认就是严格模式)才会报UnnecessaryStubbingException。
提示:严格模式下未被使用的 stub 会直接让测试失败。这在团队协作时是好事,能清理无用打桩,但迁移老代码时会一次性爆出一堆失败,建议新项目默认开启,老项目逐步迁移。
2.2 void 方法的打桩与验证
void方法没有返回值,所以不能用when(...).thenReturn(...)。打桩要用doThrow、doNothing、doAnswer这一族:
doThrow(new IllegalStateException("locked")) .when(orderClient).cancel(anyLong());这里有个语法上的坑:doThrow(...).when(mock).method()里,when后面跟的是 mock 对象,不是方法调用。这和when(mock.method())的顺序正好相反,新手经常写混。记住一个口诀:有返回值的用 when 开头,void 的用 do 开头。
验证void方法是否被调用,和普通方法一样用verify:
verify(orderClient, times(1)).cancel(1L); verify(orderClient, never()).cancel(anyLong());void方法还有个高频场景是“回调式”接口,比如void process(Order order, Callback callback),被测代码在方法内部调用callback.onSuccess(...)。这时候要用doAnswer来模拟回调触发:
doAnswer(invocation -> { Callback cb = invocation.getArgument(1); cb.onSuccess("ok"); return null; }).when(orderClient).process(any(), any());invocation.getArgument(index)按位置取参数,索引从 0 开始。这个用例在测试异步回调、事件监听时几乎是标配,但要注意doAnswer里如果抛异常,会原样传给调用方,别在里面写会失败的断言。
2.3 参数匹配器:any、eq 与混用规则
参数匹配器是 Mockito 里最容易出错的一块。核心规则只有一条:如果用了匹配器,所有参数都必须用匹配器。下面这种写法是错的:
// 错误:第一个参数用了匹配器,第二个用了具体值 when(orderClient.query(anyLong(), "PAID")).thenReturn(order);正确写法是when(orderClient.query(anyLong(), eq("PAID")))。eq把具体值包装成匹配器。这条规则违反了会抛InvalidUseOfMatchersException,报错信息还算清楚,但新手往往看不懂“2 matchers expected, 1 recorded”是什么意思。
常用匹配器我整理成一张表,方便对照:
| 匹配器 | 作用 | 典型场景 |
|---|---|---|
any() | 匹配任意对象,包括 null | 不关心参数内容 |
anyLong()/anyInt() | 匹配对应基本类型 | 避免装箱拆箱问题 |
eq(value) | 相等匹配,可与其他匹配器混用 | 部分参数固定 |
isNull()/notNull() | 空值判断 | 校验可选参数 |
argThat(predicate) | 自定义条件 | 复杂对象的部分字段校验 |
contains("x") | 字符串包含 | 日志、消息内容匹配 |
argThat值得单独说,它接受一个ArgumentMatcher,可以做任意条件判断。比如只关心订单金额大于 100 的调用:
when(orderClient.query(argThat(o -> o.getAmount() > 100))).thenReturn(order);但argThat里如果写复杂逻辑,失败时的报错信息会很模糊,只告诉你“参数不匹配”,不告诉你具体哪个字段不对。所以我的经验是:能用eq就别用argThat,非要用的话,在ArgumentMatcher的toString()里返回可读描述,方便排查。
2.4 验证模式:times、atLeast、inOrder 与超时验证
verify的默认行为是“恰好一次”,等价于times(1)。常用变体:
times(n):恰好 n 次never():0 次,等价于times(0)atLeast(n)/atMost(n):至少/至多 n 次atLeastOnce():至少一次only():该方法是被验证对象上唯一被调用的方法
only()用得少但很有用,它能确保你没有漏验证其他调用。比如你只想确认cancel被调了、且没有其他方法被调,用verify(mock, only()).cancel(...)。
顺序验证用InOrder:
InOrder inOrder = inOrder(orderClient, inventoryClient); inOrder.verify(orderClient).queryById(1L); inOrder.verify(inventoryClient).deduct(1L);这个用例在测试“先查订单再扣库存”这类流程时很关键。但要注意,InOrder只校验你显式 verify 的那些调用之间的相对顺序,没 verify 的调用不参与排序,也不代表它们不存在。
超时验证用verify(mock, timeout(1000)).method(),适合测试异步调用。但timeout本质是轮询等待,不是真正的异步感知,在高并发测试里可能不稳定。我的建议是:能用Awaitility这类专门的等待库就别用timeout,后者只适合简单场景。
2.5 ArgumentCaptor:捕获参数做深度断言
有时候你不满足于“方法被调用了”,还想检查传进去的参数内容。这时候用ArgumentCaptor:
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class); verify(orderClient).save(captor.capture()); Order saved = captor.getValue(); assertEquals("PAID", saved.getStatus());captor.capture()在 verify 时占位,调用结束后用getValue()取最后一次捕获的值,getAllValues()取所有值。这里有个时机问题:capture()必须在verify调用里使用,不能在when里用。另外,如果方法被调用了多次而你只关心最后一次,用getValue();关心全部就用getAllValues()。
ArgumentCaptor和argThat的区别值得说清楚:argThat是在打桩/验证时做条件匹配,匹配不上直接失败;ArgumentCaptor是先把参数抓出来,测试代码自己决定怎么断言。前者适合“我只要符合条件的调用”,后者适合“我要检查参数的具体内容”。实际项目里,涉及复杂对象的字段校验,ArgumentCaptor更灵活,报错信息也更清楚。
2.6 静态方法与构造器的模拟
静态方法打桩是 Mockito 3.4.0 之后才官方支持的,写法是mockStatic:
try (MockedStatic<IdGenerator> mocked = mockStatic(IdGenerator.class)) { mocked.when(IdGenerator::next).thenReturn("fixed-id"); // 被测代码调用 IdGenerator.next() 会返回 "fixed-id" }注意mockStatic返回的是MockedStatic,必须用 try-with-resources 关闭,否则打桩会泄漏到其他测试,造成莫名其妙的失败。这是静态打桩最大的坑,我见过不止一次因为忘了关闭导致整个测试类随机挂。
构造器模拟用mockConstruction,同样需要关闭:
try (MockedConstruction<Connection> mocked = mockConstruction(Connection.class, (mock, context) -> when(mock.isOpen()).thenReturn(true))) { // new Connection() 返回的是 mock }这两个能力都很强,但我的态度是能不用就不用。静态方法和构造器难测,往往说明代码结构有问题,比如工具类到处new、单例满天飞。真要用,优先考虑重构,实在改不动再用这两个 API 兜底。
3. 实操流程与完整用例落地
3.1 环境准备与依赖配置
先给一份可直接用的依赖配置。以 Maven 为例,核心是mockito-core和mockito-junit-jupiter(JUnit 5 集成):
<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>版本选择上,5.x 需要 Java 11 及以上,如果项目还在 Java 8,用 4.11.0 这个最后的 4.x 版本。静态打桩在 3.4.0 引入,mockConstruction在 3.5.0 引入,用之前确认版本。JUnit 5 环境下用@ExtendWith(MockitoExtension.class),JUnit 4 用@RunWith(MockitoJUnitRunner.class),别混用。
一个完整的测试类骨架长这样:
@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderClient orderClient; @Mock private InventoryClient inventoryClient; @InjectMocks private OrderService orderService; @Test void should_deduct_inventory_when_order_paid() { when(orderClient.queryById(1L)).thenReturn(paidOrder()); when(inventoryClient.deduct(anyLong())).thenReturn(true); orderService.pay(1L); verify(inventoryClient).deduct(1L); } }@InjectMocks会自动把@Mock标注的依赖注入到被测对象里,注入方式按构造器、setter、字段的顺序尝试。这里有个坑:如果被测类有多个构造器,@InjectMocks可能选错,导致依赖注入不进去、字段为 null。排查方法是看测试报的 NPE 位置,如果 NPE 出现在被测类内部对依赖的调用上,八成是注入失败。稳妥做法是显式用构造器注入,或者干脆手动new被测对象。
3.2 一个完整业务场景的用例串联
光看零散用例不够,我把它们串成一个完整场景:下单支付流程。被测类OrderService.pay(orderId)的逻辑是——查订单、校验状态、扣库存、更新订单状态、发通知。依赖三个外部服务。
第一步,准备测试数据和打桩:
Order order = new Order(); order.setId(1L); order.setStatus("CREATED"); order.setAmount(new BigDecimal("99.00")); when(orderClient.queryById(1L)).thenReturn(order); when(inventoryClient.deduct(1L)).thenReturn(true);第二步,执行被测方法并验证核心调用:
orderService.pay(1L); verify(orderClient).queryById(1L); verify(inventoryClient).deduct(1L); verify(orderClient).updateStatus(1L, "PAID");第三步,用ArgumentCaptor检查通知内容:
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class); verify(notifyClient).send(captor.capture()); Notification sent = captor.getValue(); assertEquals("PAID", sent.getType()); assertEquals(1L, sent.getOrderId());第四步,补充异常路径用例——库存不足时不应更新状态:
when(inventoryClient.deduct(1L)).thenReturn(false); assertThrows(InsufficientStockException.class, () -> orderService.pay(1L)); verify(orderClient, never()).updateStatus(anyLong(), anyString());这一套下来,正常路径、参数内容、异常路径都覆盖了。我特别想强调第四步里的never()验证——很多人只测正常路径,不测“不该发生的事有没有发生”,结果代码里一个漏掉的return就能让状态被错误更新,测试却全绿。验证“没有发生”和验证“发生了”同等重要,这是写单测时最容易被忽略的一条经验。
3.3 连续调用与状态变化的模拟
有些场景下,同一个方法连续调用要返回不同结果,模拟“状态变化”。比如一个库存查询接口,第一次返回有货、第二次返回无货:
when(inventoryClient.query(1L)) .thenReturn(new Stock(10)) .thenReturn(new Stock(0));如果被测代码是循环调用,还可以用thenAnswer做动态返回,根据调用次数或参数计算:
AtomicInteger counter = new AtomicInteger(0); when(inventoryClient.query(anyLong())) .thenAnswer(inv -> new Stock(counter.getAndDecrement()));thenAnswer的invocation参数能拿到方法名、参数、甚至 mock 对象本身,灵活性最高。但要注意,Answer里不要写有副作用的复杂逻辑,否则测试本身变得难懂。我的原则是:能用thenReturn链式解决就别上thenAnswer,后者是“最后手段”。
还有一个高频场景是模拟“第一次抛异常、第二次成功”,用来测重试:
when(remoteClient.call(anyString())) .thenThrow(new RuntimeException("network")) .thenReturn("ok");配合被测代码的重试逻辑,能验证重试次数和最终结果。这里要注意,如果被测代码捕获异常后没有重试,测试会直接失败,这恰恰是测试的价值——它逼你确认重试逻辑真的存在。
3.4 泛型返回值与集合类型的处理
泛型方法打桩时,类型推断偶尔会出问题。比如:
public interface Repository<T> { List<T> findAll(); <R> R convert(Object source, Class<R> type); }对findAll()打桩,when(repo.findAll()).thenReturn(list)一般没问题,因为List<T>能推断出来。但对泛型方法convert打桩时,如果返回值类型不明确,编译器可能推断成Object,导致thenReturn类型不匹配。解决办法是显式指定类型参数:
when(repo.<String>convert(any(), eq(String.class))).thenReturn("result");集合类型还有个常见坑:thenReturn(Collections.emptyList())返回的是不可变空列表,如果被测代码往里add,会抛UnsupportedOperationException。要返回可变列表就用new ArrayList<>()。这个坑很隐蔽,因为打桩本身没问题,报错发生在被测代码里,容易误判成业务 bug。
4. 常见问题与排查技巧实录
4.1 打桩不生效的五大原因
打桩不生效是最高频的问题,表现是被测代码拿到null或默认值,然后 NPE。我按出现频率排了个序:
| 现象 | 原因 | 解决 |
|---|---|---|
| 返回 null | 参数不匹配(对象未重写 equals) | 用any()或argThat放宽匹配 |
| 返回 null | mock 对象和被测对象用的不是同一个实例 | 检查@InjectMocks是否注入成功 |
抛InvalidUseOfMatchersException | 匹配器和具体值混用 | 全部参数改用匹配器 |
抛UnnecessaryStubbingException | 严格模式下 stub 未被使用 | 删除无用 stub 或放宽严格模式 |
| 静态打桩无效 | 未关闭MockedStatic或版本过低 | 用 try-with-resources,确认版本 ≥ 3.4.0 |
第一条“对象未重写 equals”特别值得展开。假设queryById(OrderQuery q)的参数是个自定义对象,你没重写equals,那when(client.query(new OrderQuery(1L)))打桩后,被测代码传一个“内容相同但引用不同”的对象,equals用的是Object默认实现(引用比较),匹配失败。这种问题排查起来很费劲,因为代码看起来完全正确。我的建议是:打桩时优先用any()或argThat,别依赖具体对象的equals,除非你确认它重写了且逻辑正确。
4.2 verify 失败与调用次数不符的排查
verify失败通常报Wanted but not invoked或TooManyActualInvocations。前者是方法压根没被调,后者是调用次数超了。排查思路:
先看是不是 mock 对象不对。如果被测类内部自己new了一个依赖,而不是用注入的 mock,那 verify 的 mock 和实际调用的对象是两个实例,自然对不上。这种情况要么改造成依赖注入,要么用mockConstruction(不推荐,优先改代码)。
再看是不是调用被吞了。比如被测代码在 try-catch 里调用了方法,但异常被捕获后没继续执行,或者方法在某个条件分支里根本没走到。这时候可以在被测代码里临时加日志,或者用verify(mock, atLeast(0)).method()先确认“至少被尝试调用过”,再逐步收紧。
还有一种情况是异步调用。被测方法内部起了个线程去调依赖,主线程 verify 时子线程还没跑完,自然 verify 失败。解决办法是用timeout()或Awaitility等待,或者干脆把异步逻辑抽出来单独测。
4.3 spy 的经典陷阱与正确姿势
spy的坑我在 1.2 提过,这里展开。假设有个类:
public class Calculator { public int add(int a, int b) { return a + b; } public int doubleIt(int a) { return add(a, a); } }用 spy 打桩add:
Calculator spy = spy(new Calculator()); when(spy.add(1, 1)).thenReturn(100);这行代码执行时,when(spy.add(1, 1))会真实调用add(1, 1),返回 2,然后when拿到这个返回值 2 去做匹配。如果add里有副作用(比如计数、写日志),副作用已经发生了。更糟的是,如果add抛异常,这行直接挂。
正确姿势是用doReturn:
doReturn(100).when(spy).add(1, 1);doReturn不会触发真实调用,直接替换行为。所以记住一条铁律:spy 打桩一律用doReturn/doThrow/doAnswer,不要用when(...).thenReturn(...)。这条规则能帮你避开 spy 九成的坑。
4.4 测试隔离与静态打桩泄漏
静态打桩泄漏是个隐蔽但破坏力极强的问题。mockStatic打开后如果不关闭,打桩会一直生效,影响后续所有测试。表现是“单独跑这个测试类通过,一起跑就随机失败”,而且失败位置飘忽不定,极难排查。
根因是MockedStatic是线程绑定的,必须显式close()。用 try-with-resources 是最稳的写法:
try (MockedStatic<Clock> mocked = mockStatic(Clock.class)) { mocked.when(Clock::systemDefaultZone).thenReturn(fixedClock); // 测试逻辑 } // 出了这个块,打桩自动失效如果测试方法里有多处静态打桩,注意嵌套顺序,内层先关。另外,mockStatic和mockConstruction都不要在@BeforeEach里打开、在@AfterEach里关闭,因为一旦@BeforeEach抛异常,@AfterEach可能不执行,导致泄漏。老老实实用 try-with-resources 包在测试方法内部。
4.5 严格模式下的取舍
Mockito 5.x 默认开启严格模式(Strictness.STRICT_STUBS),好处是能发现无用打桩和参数不匹配,坏处是迁移老代码时一次性爆一堆UnnecessaryStubbingException。我的处理策略分三步:
第一步,新写的测试类保持严格模式,这是质量保障。第二步,老测试类如果失败太多,先用@MockitoSettings(strictness = Strictness.LENIENT)在类级别放宽,让测试先跑起来。第三步,逐个清理无用 stub,清理完再移除 lenient 注解。别一上来就全局 lenient,那样等于放弃了严格模式的所有好处。
还有一种情况是参数匹配器导致的“假失败”。严格模式下,如果 stub 用了any()但实际调用参数不匹配,会报PotentialStubbingProblem。这时候要么收紧匹配器,要么确认这个 stub 确实该放宽。我的经验是,PotentialStubbingProblem十有八九是真问题——要么打桩参数写错了,要么被测代码传参和预期不符,值得逐个查。
4.6 性能与可维护性的平衡
最后聊个容易被忽略的点:mock 用多了,测试会变慢、变脆。一个测试类里几十个@Mock、上百行打桩,跑起来慢不说,改一处业务逻辑要改十几处测试。我的经验是:
优先 mock 外部边界(网络、数据库、文件系统),内部协作对象尽量用真实实现。一个类如果依赖五六个服务,先想想是不是职责太重了。测试的可维护性和代码的可维护性是同一件事的两面——测试难写,往往说明设计有问题,而不是测试工具不好用。
另外,别为了追求覆盖率写“为了 mock 而 mock”的测试。一个方法如果只是简单转发调用,测它被调用了没多大意义,真正该测的是它转发时的参数转换、异常处理、边界条件。用例文档给的是工具用法,但工具用在哪、用多少,得靠对业务的理解来判断。
我在实际项目里踩过最深的坑,是早期为了“测试全覆盖”给每个依赖都打了桩,结果一个简单的业务改动要同步改二十几个测试文件,维护成本远超收益。后来调整策略,只 mock 真正的外部依赖,内部逻辑尽量走真实路径,测试数量少了三分之一,bug 发现率反而没降。这个体会可能比任何 API 用法都值钱——工具是死的,判断力是活的。