ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Mockito从入门到进阶:打桩、验证与单测实战

Mockito从入门到进阶:打桩、验证与单测实战

Mockito 这个框架,在 Java 后端开发的日常里,几乎到了“只要写单元测试就不可能绕开”的地步。很多人刚开始接触它时,唯一的感受就是“能少写一堆乱七八糟的依赖对象”,但真正用熟了之后,会发现它的价值远不止“造假的替身演员”。虚拟数据、交互验证、异常模拟、行为编排,这些东西真正掌握之后,单测写起来不仅快,而且稳,能够在没有数据库、没有外部接口、没有中间件的情况下,把核心业务逻辑牢牢锁死。

这篇文章想梳理的,就是 Mockito 从入门到进阶的主线。假设你已经知道它是 Java 里做 mock 的工具,但对内部机制和高级用法还停留在“能用但心里没底”的阶段,那这篇文章正适合你。我尽量讲清楚两个核心主题:一是虚拟数据怎么设计、怎么打桩才不会踩坑;二是验证机制怎么用,“验证”不是简单调一下 verify 就完事,它背后有交互语义、有调用次数、有执行顺序,把这套东西吃透,很多隐藏 issue 才有机会在开发阶段浮出水面。

1. 为什么需要 Mockito:单测里最稀缺的是“可控性”

单元测试的目标,是孤立的验证一段代码逻辑在给定的输入下是否产生期望的输出或行为。但现实世界的 Java 工程里,几乎没有哪个服务类可以脱离依赖独立存活。你写一个订单服务,它要调用户服务查会员等级,要调库存服务锁定库存,要调支付通道发起扣款。在测试环境里,这些依赖要么没部署,要么数据没法预置,要么响应速度不稳定。真正让人崩溃的是“不可控”——你没法保证调用某接口时,它恰好返回你想要的超时异常、空指针、金额不足这几种情况,所以单元测试的边界往往就打不出去。

Mockito 的定位就是帮你在内存中创建一个“假对象”。这听起来简单,但意义很大。它让你把被测试对象的依赖关系显式声明出来:哪个依赖给什么返回值,哪个依赖抛什么异常,哪个依赖根本不需要真正执行。被测代码在跑的时候,只会和这些事先安排好的假对象交互,整个过程完全由你控制。正因为这种可控性,单测才能在毫秒级跑完,且不依赖任何外部环境,任何人在任何机器上都能重复验证同一套逻辑。

我一直把 mock 理解为“测试中的替身演员调度系统”。真实演员是接口的实现类,你替它安排一个替身,替身按剧本给出反应。Mockito 的核心价值不是“造假”,而是让“假”变得精确、可预期、可观察。精确指的是返回值由你定义,可预期指的是每次调用按你的规则响应,可观察指的是 mock 对象记录了自己被调用的历史,测试结束时你能检查它是否符合预期。

同时要理解的是,mock 和桩(stub)之间是有细微区别的。很多初学者混用这两个词,这在讨论技术时会引发误解。Stub 重点是“状态”——给某个方法调用设置固定的返回,用来支撑被测代码往下走。Mock 重点是“行为交互”——除了设置返回值,还关注方法是否被调用过、调用了几次、调用顺序等。Mockito 其实两者都支持,所以它能同时承担“虚拟数据供给”和“交互行为验证”这两件不同的事。后面第二、第三大节会分别展开这两条线的具体玩法。

2. 虚拟数据的正确姿势:打桩前先想清楚“谁来产生值”

2.1 打桩的两种语义:when...thenReturn 和 doReturn...when

Mockito 里最经典的一段代码长这样:

when(mockUserService.getMemberLevel("u_10086")).thenReturn(MemberLevel.VIP);

这段代码的含义是:当 mock 出来的用户服务收到参数“u_10086”时,返回一个 VIP 等级。执行到这一行时,Mockito 并没有真正调用getMemberLevel方法,它只是在记录一个“行为规则”。这个设计是 Mockito 的底层核心:方法调用在这里不是真的“执行”,而是在 stubbing 阶段被拦截,写入到 mock 内部的一个规则集合里。等被测代码真正调用这个方法时,Mockito 会去查这个规则集合,按参数匹配规则返回对应的结果。

实际操作中有一个细节需要注意:**如果打桩的方法返回 void,或者你想在打桩时强制执行某个副作用(比如抛出异常),就不能用 when 语法,要用 do...when 语法。**最典型的例子是模拟 void 方法抛异常:

doThrow(new IllegalStateException("库存不足")) .when(mockStockService) .lockStock(anyString(), anyInt());

为什么这里必须用 doThrow 而不是when(mockStockService.lockStock(...))?原因是当你写when(mockStockService.lockStock(...))时,lockStock会被真的执行一次。对于 void 方法,它在执行时什么都不返回,自然没法放到when(...)的参数位置去“声明规则”。Mockito 的 when 本质上是“先捕获一次调用结果,再定义规则”,void 方法没有可供捕获的返回值,所以只能用从 do 开头的那组 API。同理,如果你打桩的对象方法在调用时会抛异常,用 when 语法也会在打桩那行提前把异常抛出来,导致打桩失败,这时 do 开头的方法就是更安全的选择。

另一个值得记住的经验是,“能不打桩就不要打桩”。Mockito 对未打桩的方法有一套默认策略:对象类型返回空对象,比如 List 返回空列表,Map 返回空 Map,布尔返回 false,int 返回 0。虽然可以通过RETURNS_DEEP_STUBS、RETURNS_SMART_NULLS修改默认策略,但大多数场景下,依赖默认返回能把测试类精简不少。如果你的业务代码会在依赖返回 null 时产生 NPE,那说明代码本身的健壮性存疑,单元测试正好帮你暴露出来。刻意把所有方法都打桩,除了让测试代码膨胀、可读性下降之外,没有额外的好处。

2.2 参数匹配器:从精确匹配到“任何值”与“组合条件”

写打桩时,最自然的想法是对具体参数精确匹配:when(obj.method("exact")).thenReturn(value)。但在真实业务里,被测代码传过来的参数往往不是预先能确定的常量,或一个测试里同一个方法会被多次调用、需要返回不同结果。这时候就要用参数匹配器(ArgumentMatcher)。

Mockito 内置了一批常用匹配器:any()、anyString()、anyInt()、anyList()、eq()、isNull()、isNotNull()、contains()、startsWith()等。比如:

when(stockService.queryStock(anyString(), anyInt())).thenReturn(50); when(stockService.queryStock(eq("sku_9527"), anyInt())).thenReturn(200);

这里 eq 很重要,因为当你用anyString()匹配第一个参数时,第二个参数千万不能直接写字面量100,而必须写成eq(100)。Mockito 的规则是:只要参数列表里出现一个匹配器,那么所有参数都必须使用匹配器;直接写字面量会被当成“equals(字面量)”匹配混用,直接抛异常。这个限制在初学阶段坑了我好几次,排查半天才发现是混用导致的。

有一点要特别小心:匹配器和“返回值顺序”结合时,策略到底是什么。如果你对同一个方法打了两条桩,参数分别为"a"和"b",Mockito 会走最近匹配、最后打桩优先的策略。即如果第一条规则是"a"返回 1,第二条规则是anyString()返回 2,当实际参数是"a"时,匹配到第一条规则返回 1;当实际参数是"c"时,匹配到第二条规则返回 2。如果你把两条规则顺序反过来,所有字符串参数都会命中anyString(),第二个更具体的"a"规则永远不会生效。所以写打桩时,规则顺序也是一种隐性决策,通用的放前面、具体的放后面,或者干脆只保留必要规则,尽量减少重叠匹配带来的歧义。

对于复杂参数对象,除了用eq(exactObject),更优雅的做法是用 ArgumentMatcher 接口自定义匹配逻辑。例如,要验证传入的订单对象金额大于 1000 才返回某个结果,可以写:

when(orderRepository.save(argThat(order -> order.getAmount() > 1000))) .thenReturn(savedOrder);

argThat接收一个 Predicate 风格的参数,在运行时对传入参数进行判定。这种写法特别适合那些字段多、batch 处理、按业务规则分类的场景。不过自定义匹配器会让报错信息变得不直观,如果匹配失败,Mockito 只能告诉你“这个参数不匹配”,但到底哪里不匹配就无法提示了。所以能用内置匹配器就用内置,自定义匹配器尽量用在高层次语义判断上,而不是低级字段比对。

2.3 连续调用与动态返回值:thenReturn 的进阶用法

有时一个 mock 方法在同一个测试里被调用三次,你希望第一次返回 null,第二次返回异常,第三次返回一个对象。Mockito 支持链式打桩:

when(userService.getUser("u_1")) .thenReturn(null) .thenThrow(new RuntimeException("被锁定")) .thenReturn(user);

也可以写成多参版本:thenReturn(null, user),表示第一次返回 null、第二次以及后续返回 user。它的内存逻辑是维护了一个 Iterator,每次调用按顺序消费下一个规则。这种链式写法的可读性比连续写多条 when 好很多,尤其适合“模拟依赖不稳定”的场景。但注意,这种顺序消费规则只对“没有更精确匹配规则”的调用生效。如果你既有链式规则又有精确参数规则,匹配优先级会按参数精确度而不是链式顺序决定,所以设计时要避免规则冲突。

更自由的动态返回值通过thenAnswer实现。它允许基于入参实时计算结果:

when(calculatorService.calculate(anyDouble(), anyDouble())) .thenAnswer(invocation -> { double a = invocation.getArgument(0); double b = invocation.getArgument(1); return a * b + 100; });

thenAnswer的入参是 InvocationOnMock,通过它你能拿到调用对象、方法、参数列表,甚至能调用真实方法。这对模拟“依赖内部状态”的场景很有用。比如一个 getUser 方法,要求传入 id 为 null 时抛异常,id 存在时返回对应的用户对象,而用户对象需要从某个内存 Map 里查,这时 thenAnswer 比写一堆 thenReturn 链要灵活得多。实际项目中,我倾向把复杂动态逻辑封装成独立的 Answer 类,而不是在测试里写一大段 lambda,因为一旦 lambda 太复杂,测试的意图就被冲淡了。

3. 验证机制的核心:从“返回值正确”到“交互行为正确”

3.1 verify 的本质:记录、匹配与断言

单测里有一类 bug 特别隐蔽:方法返回了正确结果,但内部交互不符合预期,比如本该调用缓存服务却直接查了数据库,本该调用三次批量接口却只调了一次。Mockito 的 verify 就是为这类问题设计的。每个 mock 对象从创建开始就在内部记录了一份“调用日志”:哪个方法、什么参数、调用了几次、顺序如何。verify 做的事情是回放这些日志,按照你给定的条件进行匹配,匹配失败则抛出 VerificationException。

最简单的验证是检查某个方法是否被调用:

verify(mockUserService).getUser("u_1");

等价于验证getUser("u_1")被调用且仅调用了一次。想验证调用次数时:

verify(mockUserService, times(3)).getUser("u_1"); verify(mockUserService, atLeastOnce()).getUser(anyString()); verify(mockUserService, atMost(2)).getUser(anyString()); verify(mockUserService, never()).deleteUser(anyString());

times(n)里的 n 必须精确匹配,多一次少一次都报错。atLeastOnce和atMost则给出边界条件。这里有一个容易忽略的点:never()等价于times(0),它用于验证某些“不应该发生”的调用,比如灰度开关关闭时不应该上报监控数据,这时verify(mockMonitor, never()).report(...)是有效且低成本的安全网。

验证时同样可以使用参数匹配器。规则和打桩时一致:只要出现一个匹配器,所有参数都必须用匹配器。一个比较常见的错误是验证时用anyString(),但业务代码传了 null,anyString()不匹配 null 值,验证就会失败。此时要根据业务需要选择nullable(String.class)或者isNull()。这类“null 值边界”在验证阶段极容易遗漏,因为代码逻辑在测试数据设置正确时能跑通,但线上真实场景接受到了 null 就崩。

3.2 验证异常与状态改变的交叉场景

verify 并不只验证“有没有调用”,它对测试的决策价值在于:**它能确认某个分支是否被执行。**比如被测代码处理逻辑是这样的:调用外部接口后,如果返回码是 200,就更新本地状态并发送一条事件;如果返回码是 500,则记录异常日志并抛业务异常。你分别写两个测试:

  • 第一个测试打桩外部接口返回码为 200,执行被测逻辑后,verify(mockEventPublisher).publish(any())。
  • 第二个测试打桩外部接口返回码为 500,执行被测逻辑后,verify(mockEventPublisher, never()).publish(any()),同时assertThrows(BusinessException.class, () -> service.doSomething())。

这样一套组合下来,分支覆盖就非常清楚。测试框架能保证“只要走某条分支,就一定有对应的行为发生”,而 verify 就是这个行为是否发生的证据。实际写测试的时候,我习惯把“打桩目标”和“验证目标”分开识别:打桩目标通常是外部依赖,验证目标通常是被测代码自身暴露出的协作对象。如果一个对象只用做打桩、从来不需要验证,那它很可能只是被测对象的内部工具,不需要暴露在测试视野里。

另一个容易踩坑的细节是 verify 方法的重载与超时验证。Mockito 支持timeout(),例如verify(mock, timeout(1000)).asyncCall()。但 timeout 和after()更适用于异步场景,普通同步单测中不推荐使用,因为 timeout 会导致测试时长不可控。专跑异步测试时,稳妥的方案是 mock 的回调里用 CountDownLatch 或其他同步工具,把异步转成同步来做断言,而不是把 verify 放在 time interval 里轮询。

3.3 InOrder 与交互顺序:当“先做什么”成为业务规则

有些业务逻辑对调用顺序有硬性要求。例如用户注册时必须先检查账号是否存在、再发送验证码、最后写入数据库,顺序错了就是安全漏洞。Mockito 提供一个轻量级顺序验证工具 InOrder:

InOrder inOrder = inOrder(mockUserRepo, mockSmsService); inOrder.verify(mockUserRepo).existsByPhone("13800000000"); inOrder.verify(mockSmsService).sendCode("13800000000", anyString()); inOrder.verify(mockUserRepo).save(any(User.class));

这段验证的价值在于,如果被测代码在执行过程中调整了顺序(比如先存库再发验证码),测试立刻失败,提醒你业务语义被破坏了。多 mock 对象之间的顺序也可以跨对象验证,InOrder 的构造参数传入所有要验证的 mock 即可。注意 InOrder 和普通 verify 是独立的验证通道,同一个 mock 既可以通过普通 verify 验证次数,也可以通过 inOrder 验证顺序,二者不冲突。

需要提醒的是,顺序验证要克制使用。不是所有方法调用都有顺序语义,有些调用本来就是无序的,例如多个统计上报。给无序调用强行加上顺序验证,会让测试变得脆弱——没错,今天的代码也许是有序的,但明天某个无关重构打乱了调用顺序,本来不影响正确性,测试却红了,这会白白消耗团队时间。我个人的判断标准是:顺序是不是业务规则的一部分?如果是(比如“先鉴权后操作”),就验证;如果不是(比如“记录日志和写审计表”),就让它们保持在无序状态。

3.4 验证失败时如何快速定位:自定义描述与错误消息

Mockito 的验证失败信息通常长这样:

Argument(s) are different! Wanted: mockService.getUser("u_1"); Actual invocations have different arguments: mockService.getUser("u_2");

大多数情况下这个信息已经够用。但复杂场景下,比如同一个方法被调了 20 次,或者参数是一个字段很多的 DTO,光看差异信息很难定位问题。这时可以在 verify 时使用描述性错误提升可读性:

verify(mockService, description("缓存加载时应该只调用一次远程接口")).times(1).load(anyString());

当验证失败时,Mockito 会先打出这句描述,再打出具体差异信息。这个description()是成本极低的调试辅助,特别适合跑在 CI 里别人看不到你本地调试信息的场景。写测试代码的人要想象自己是未来接手这个测试的同事,看到一行 verify 失败时,能不能根据失败信息快速判断是业务逻辑变了还是 mock 配置错了。良好的描述信息,能显著降低团队在单测维护上的痛苦。

4. 高级实践技巧:Spy、静态方法与 BigDecimal 这类特殊边缘

4.1 Spy:保留真实行为,只替换局部依赖

Mock 和 Spy 最直观的区别在于:Mock 对象的所有方法默认不执行真实逻辑;而 Spy 对象默认执行真实逻辑,只有显式打桩的方法才会被替换。说得更直白一点,Spy 是“基于真实对象的部分 mock”。创建 Spy 的推荐方式是对真实对象实例进行包装:

UserService realService = new UserService(); UserService spyService = spy(realService); doReturn(vipUser).when(spyService).getUser("u_vip");

大多数场景里,Spy 的价值在于测试那些“内部方法”和“外部方法”混杂的旧代码。例如 Service 里有一个私有工具方法计算折扣,一个公有方法调用它。原则上应该把折扣计算抽出来做纯函数测试,但老代码不好拆时,可以用 Spy 只替换公有的部分,保留折扣计算的真实逻辑。这种“缝合”式测试在遗留系统维护中很常见,也是 Mockito 比一些“纯 mock 世界观”框架更务实的原因。

但 Spy 有一些明显的坑。第一个坑是对象初始化依赖的外部资源:如果你spy(new UserService()),但 UserService 的构造函数里连接了数据库,那 Spy 创建时还是会真连接数据库,测不起来。这种就得用无参构造或者反射设置字段,或者把对象设计成可依赖注入的结构。第二个坑是when(spyObj.method())语法:它对 spy 对象使用 when 时,方法会被真实执行一次,所以如果方法有副作用,必须用doReturn().when()避免真实调用。这一点我在写代码时几乎已经形成了肌肉记忆:凡是 spy,打桩一律 do 开头,绝不用 when 开头。

4.2 静态方法与构造方法的 mock:MockedStatic 与 MockedConstruction

从某个版本开始,Mockito 支持 mock 静态方法,这解决了不少“工具类直接调用”的问题。基本写法:

try (MockedStatic<IdGenerator> mockedStatic = mockStatic(IdGenerator.class)) { mockedStatic.when(IdGenerator::nextId).thenReturn("mock_id_001"); // 业务逻辑 }

注意MockedStatic必须声明在 try-with-resources 块里,作用域限定在块内,块结束自动释放 mock,避免影响其他测试。这一点常被忽略,如果你在测试方法级别忘记关闭,静态方法 mock 会漂移到别的测试用例中,造成诡异的测试相互污染。在写测试基类时,建议将MockedStatic的生命周期严格对齐到测试方法的边界,而不是类级别。

构造方法的 mock 用MockedConstruction,适用于工厂模式、依赖注入我没有办法处理的场景。比如被测代码内部new ExternalCalculator(),这个 ExternalCalculator 来自第三方库,无法直接替换,则:

try (MockedConstruction<ExternalCalculator> mockedConstruction = mockConstruction(ExternalCalculator.class)) { ExternalCalculator calc = new ExternalCalculator(); when(calc.add(1, 2)).thenReturn(99); // 业务逻辑里 new 出来的是同一个构造 mock }

这个特性用起来舒服,但负担也明显:会让被测代码里的“新建对象”行为变得不再直观,测试阅读者需要额外寻找构造 mock 的位置。建议只在真的无法重构依赖注入的老代码中使用,新代码优先推荐构造器注入,直接通过 @Mock + @InjectMocks 来装配会清爽得多。

静态与构造 mock 让 Mockito 在“遗留代码”和“第三方库依赖”场景下活得更滋润,但我仍建议把它当作“最后的手段”。理由很简单:过度使用会让单测与实现细节耦合更紧,重构时测试会先崩,反而不利于代码演进。把可测性设计在代码结构里,才是长期最优解。

4.3 Object 类型参数、BigDecimal 和泛型的匹配细节

Java 的泛型在 Mockito 里有一套自己的语法。比如 mock 一个List<String>:

List<String> mockList = mock(List.class);

通常没问题,但如果要打桩when(mockList.get(0)).thenReturn("hello"),编译器可能提示类型不匹配,因为 mock(List.class) 丢掉泛型信息。此时有两个选择:直接使用@Mock List<String> mockList;,让注解处理器帮你处理泛型;或者使用ArgumentMatchers.anyList()时配合anyList()返回值是List<T>,让类型推断生效。在 Java 7 类型推断不够强的年代这很痛苦,现代 JDK 下配合注解基本无感。

BigDecimal 是另一个高频特殊对象。Mockito 的默认RETURNS_DEFAULTS对 BigDecimal 返回null,不是 0,所以如果你打桩一个方法返回 BigDecimal,必须显式给值,不能指望像 int 那样拿到 0。另一个常见问题是equals语义:BigDecimal 的equals比较值和精度,new BigDecimal("1.0")与new BigDecimal("1.00")不相等,所以参数匹配eq(new BigDecimal("1.0"))时如果业务代码传的是 1.00 就会匹配失败。实际处理时,优先用argThat或compareTo方式作匹配,按数值比较而不是严格 equals。

泛型和特殊数值类型听起来细节,但往往就是这些细节让同一套测试在开发环境绿、在 CI 上红。排查久了你会明白,Mockito 匹配的是运行时参数的实际 equals 或匹配器逻辑,不是编译期的类型兼容。

5. 与 JUnit 5、Spring Boot 单测的整合方式

5.1 JUnit 5 集成:junit-jupiter 与 MockitoExtension

现代 Java 工程几乎都在 JUnit 5 体系下,Mockito 官方提供了mockito-junit-jupiter模块,让 Mockito 和 JUnit 5 无缝协作。引入依赖后,可以用@ExtendWith(MockitoExtension.class)激活 Mockito 注解支持。

@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private StockService stockService; @Mock private UserService userService; @InjectMocks private OrderService orderService; @Test void testCreateOrder() { when(stockService.lockStock(anyString(), anyInt())).thenReturn(true); when(userService.getUser(anyString())).thenReturn(vipUser()); Order result = orderService.createOrder("sku_1001", 2); assertNotNull(result); verify(stockService).lockStock("sku_1001", 2); verify(userService).getUser(anyString()); } }

@InjectMocks会按类型和名称把 mock 字段注入到被测实例里,支持构造器注入、Setter 注入和字段注入。写测试时推荐优先用构造器注入,因为构造器会把依赖关系显式化,Mockito 注入时也更稳定。如果被测类没有构造器而是大量字段注入,@InjectMocks也能工作,但测试一旦跑起来报注入失败,排查难度会大不少。

一个极其重要的细节:默认情况下,MockitoExtension 引入的是严格打桩检测(Strict Stubs),这意味着如果测试里定义了某个打桩但从未被实际调用,测试会直接失败。这是把双刃剑。好处是它能提醒你打桩冗余,帮测试瘦身;坏处是有些“防御性打桩”会在分支切换时变得无用,反而让测试变红。如果你确定某个打桩就是备而不用,可以在打桩后调用lenient():

lenient().when(stockService.queryStock("sku_1")).thenReturn(100);

严格模式不是灾难,它会强迫你反思每一根桩是否真的必要,长期看对测试维护是好事。刚切换到 JUnit 5 + MockitoExtension 的团队,往往会先被严格模式误杀一波,调整代码后就会习惯。

5.2 Spring Boot 测试中如何切掉外部依赖

Spring Boot 项目里常见的是@SpringBootTest加载整个上下文。但单测一旦拉起了 Spring 容器,就很容易碰到数据库、Redis、消息队列等真实资源。想让单测跑得快且对环境零依赖,需要谨慎对待 Spring Test 上下文。并非所有测试都推荐@SpringBootTest;纯单元测试可以直接用纯 Mockito,只测单个类的逻辑,不加载任何 Spring 上下文。骨架如下:被测类直接 new,依赖用 @Mock 装配,根本不跑 Spring 容器。只有在测 Controller 层、过滤器、切面等需要考虑 Spring 容器组件的场景,才建议@WebMvcTest配合@MockBean或@MockitoBean来切掉外部依赖。

@MockitoBean是 Spring Boot 测试中声明 mock Bean 的注解,它会把对应的 mock 替换掉容器中的真实 Bean,让被测 Controller 调用时命中 mock。用起来非常简单:

@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private OrderService orderService; @Test void testCreate() throws Exception { when(orderService.createOrder(any())).thenReturn(orderResult()); mockMvc.perform(post("/orders") .contentType(MediaType.APPLICATION_JSON) .content("{\"skuId\":\"sku_1001\",\"num\":2}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.orderId").value("order_001")); } }

值得一提:@MockitoBean是较新版本中重新命名的方案(早期叫@MockBean),新版本 Spring 官方推荐前者,因为它语义更明确。如果你的项目版本不支持,退回@MockBean也可以。这两种注解本质上都是 Mockito 创建的 mock 替换了容器中的 Bean。

Spring 测试和纯 Mockito 测试在我心目中的边界很清晰:纯 Mockito 解决的是“类级逻辑”,Spring 测试解决的是“语境装配、序列化、HTTP 层、过滤器链”。一个经验法则是:尽量把业务逻辑放到 Service 层,Service 层的每个方法尽量能被纯 Mockito 测试;Controller 层只做参数接收和响应转换,用 @WebMvcTest 做轻量级验证。这样整个测试金字塔结构才合理,不会因为加载 Spring 上下文导致一套测试跑几分钟。

5.3 测试隔离:为什么不要共享 Mockito 的 Mock 状态

JUnit 默认每个测试方法创建新的测试类实例,所以 @Mock 字段默认也是每个方法一份,互相不干扰。但如果你在类级别定义了static Mock,或者使用@BeforeAll去初始化 mock,那 mock 的调用记录就会跨测试共享。跨测试共享的 mock 是测试相互污染的经典来源:前一个测试打桩的规则还在,后一个测试的 verify 会接收到前一个测试的调用记录,导致断言失败。

实践中,我见过不少同事为了“提升速度”在类里静态初始化 Mock 对象,结果反而因为状态污染浪费更多时间排查。Mockito 的 mock 对象创建成本极低,完全没必要做静态共享。如果要复用一些通用的打桩规则,可以封装在 private 方法里返回 mock:

private UserService mockVipUserService() { UserService mock = mock(UserService.class); when(mock.getLevel(anyString())).thenReturn("VIP"); return mock; }

然后每个测试方法独立创建使用,既保留复用性,又避免状态交叉。这个折中方案在实际项目中效果不错,测试代码看起来更整洁,也更容易追溯每个测试的依赖来源。

6. 常见问题与避坑经验:我把这些年踩过的雷集中写出来

6.1 “when 不生效”或“打桩被忽略”的排查套路

最经典的翻车现场:明明when(mock.foo()).thenReturn("bar")写了,执行被测代码后 foo() 返回的却是 null。排查思路按顺序来。

第一,检查打桩的参数匹配器。如果业务代码传了 null 而打桩用anyString(),匹配不上直接返回默认 null。替换成nullable(String.class)看问题是否消失。第二,确认被测代码里用的对象和你打桩的 mock 是同一个实例。Spring 容器注入的、反射创建的、工厂新建的对象,都极有可能不是同一个实例,这时打桩打到天上也没用。第三,确认@InjectMocks有没有成功注入。可以在测试里直接打印被测对象里依赖对象的 hashCode,和 mock 的 hashCode 对比,不一致就是注入失败。排查重构之后经常遇到这种问题,改动类字段名后@InjectMocks找不到对应名字,静默失败。

这么多排查路线里,我最推荐先看“是不是同一个实例”,因为 mock 不同实例这个问题在所有问题里占比最高,尤其是构造函数中 new 出来的依赖,mock 根本没法替换。老规矩:被测类内部直接 new 的依赖,要么改成构造器注入,要么用 MockedConstruction,否则测试怎么都会别扭。

6.2 “MultipleMatchingStubs”和“UnnecessaryStubbingException”的含义

这两个异常都是严格模式下的保护机制。UnnecessaryStubbingException意味着某根桩从打桩到测试结束都没有被调用,代码里存在冗余。我的建议是:直接删除多余打桩,如果因为分支条件导致测试绕过了某根桩,反而要审视测试是不是没有覆盖到预期分支。

MultipleMatchingStubs相对更罕见,表示同一方法同一参数匹配到了多条打桩规则,Mockito 不确定该用哪个。出现这种错误时,先检查是否有两个anyString()打桩了同一个方法,再检查是否混用了精确匹配和模糊匹配。这类规则冲突不一定在运行时立刻报错,等真正依赖到该方法的场景出现时,失败信息会很模糊。平时保持打桩规则简洁,不要为了“xx场景”一次性打满全部可能。

6.3 验证失败却找不到调用日志?可能是 void 方法或 lambda 内部调用

有些公共代码在 lambda 里调 mock 对象,因为 lambda 延迟执行,验证时机可能在 lambda 真正执行前。这种情况下 verify 失败不一定意味着方法没被调用,而是断言放在 lambda 执行之前。解决办法是确保被测代码完全执行(比如调用 get() 或 join())、再执行 verify。如果是 lambda 里调用取得返回值后再断言,注意执行顺序。这也解释了为什么异步代码的测试那么难写:verify是同步的,而内部逻辑是异步的,竞态条件直接导致偶发失败。常见解法是引入Awaitility这类轮询等待库,或把异步回调改成可同步执行的注入接口。

7. 工具选型:为什么是 Mockito 而不是其他方案

7.1 Mockito 与 EasyMock、PowerMock 的取舍

Mockito 并非唯一 mock 框架。EasyMock 是老牌框架,它的 API 是“录制-回放-验证”流程,先录制期望行为,然后切换到 replay 状态执行被测代码,最后验证。Mockito 用的是更轻量的“打桩即规则”模式,不需要显式的 replay 切换,测试流程更顺手,学习曲线更低。从可读性角度看,Mockito 的when(...).thenReturn(...)与自然语言接近,更容易让不熟悉框架的人猜出含义。

PowerMock 是老项目中为了解决静态方法、私有方法、构造函数 mock 而生的扩展,在 Mockito 早期版本确实有必要。但随着 Mockito 逐步引入 MockedStatic、MockedConstruction、Spy 增强,大部分 PowerMock 场景已经可以被 Mockito 直接覆盖。PowerMock 的强制字节码修改方案在 JDK 升级后经常遇到模块系统兼容问题,所以新项目几乎没有理由选择 PowerMock。如果你正在维护老项目,优先尝试把 PowerMock 的用法替换为 Mockito 原生方案,能显著降低构建环境兼容风险。

7.2 当依赖是“不容易 mock 的接口”时怎么办

有些依赖是 final 类,或者第三方库里没有接口、类方法还是 final 的。Mockito 对一个 final 类、final 方法的 mock 支持经历了变化——现代版本在绝大多数环境下已经能 mock final 类和方法,原因是 Mockito 的 inline mock maker 通过字节码插桩绕过 final 限制。但配套要求是运行时允许字节码 agent 自附加 (-XX:+EnableDynamicAgentLoading或对应 JDK 参数配置)。如果你的测试环境是 Java 17+ 且遇到 final mock 失败,先检查 JVM 启动参数是否允许动态 agent 自带。

另一种麻烦是“接口方法极大”的依赖,比如一个接口有几十个方法,而业务逻辑只用其中两个。Mockito mock 会为所有方法提供默认行为,不需要你逐个打桩,这是它好用的地方。真正麻烦的是那些在构造/初始化阶段就启动线程、连接资源的类,mock 也无法阻止真实初始化。这种场景需要先重构依赖的初始化时机,或者用mockConstruction绕过构造阶段。熟练了以后,你会把这看成“设计可测性”的信号——如果一个类连测试都难以 mock,它的依赖边界可能已经过度耦合了。

7.3 团队协作中的 Mockito 规范

单测代码的代码评审往往被轻视,但 mock 用法的规范性直接影响测试可维护性。我在团队里推广过几条不成文的约定,效果不错,这里整理出来:

  • 一个测试最多创建 3 个 mock,多的 mock 往往意味着被测类承担了太多职责。
  • 不轻易使用verify(..., times(0))之外的弱断言,弱断言会让回归漏洞从 CI 溜走。
  • 打桩规则不让 mock 返回“魔法值”,比如1、"abcd"这类无业务含义的量,最好用有语义的测试常量表示。
  • 所有 mock 资源尽量在测试方法内部或try-with-resources块内创建,不用类级静态共享。

这些规范不复杂,但执行到位以后,新人看老测试代码可以很快理解被测业务,这是很大的隐性收益。

8. 一段完整的“订单创建”示例:从 mock 工具类到验证穿透

前面分散讲了太多点,最后放一个相对完整的例子,把打桩、验证、异常处理、Spy 综合起来。假设我们要测试一个OrderService.createOrder方法,基本信息如下:

  • 调用 StockService.lockStock 锁定库存,返回 boolean。
  • 调用 UserService.getUser 查询用户,得到用户等级。
  • 调用 PaymentService.pay 执行扣款。
  • 如果库存不足,直接抛 BizException。
  • 如果用户是 VIP,订单标记加急。

测试代码如下(示意结构,字段与细节按个人工程调整):

@ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock StockService stockService; @Mock UserService userService; @Mock PaymentService paymentService; @InjectMocks OrderService orderService; @Test void createOrder_whenStockNotEnough_shouldThrowBizException() { when(stockService.lockStock("sku_A", 10)).thenReturn(false); BizException e = assertThrows(BizException.class, () -> orderService.createOrder(new CreateOrderCmd("sku_A", 10))); assertEquals("库存不足", e.getMessage()); verify(paymentService, never()).pay(any()); verify(userService, never()).getUser(anyString()); } @Test void createOrder_whenVipUser_shouldMarkAsUrgent() { when(stockService.lockStock(anyString(), anyInt())).thenReturn(true); when(userService.getUser(anyString())).thenReturn(vipUser()); when(paymentService.pay(anyString(), any(BigDecimal.class))).thenReturn(true); Order order = orderService.createOrder(new CreateOrderCmd("sku_A", 10)); assertTrue(order.isUrgent()); verify(stockService).lockStock("sku_A", 10); verify(userService).getUser(anyString()); verify(paymentService).pay("sku_A", new BigDecimal("199.00")); } }

第一个测试验证了失败的静默路径:支付不被调用、用户不查询。第二个验证了成功路径及 VIP 加急语义。两个测试分别锁住库存失败分支和成功分支,后续有人把库存逻辑改坏、把 VIP 判断去掉,测试都会立刻报警。这就是“虚拟数据 + 交互验证”双线并用的直观体现:桩数据控制分支走向,验证断言控制行为合规。

如果你对这个结构不满意,还可以用@ParameterizedTest扩展成多组参数,覆盖普通用户、黑名单用户、金额为 0 等边界,Mockito 和 JUnit 5 在这方面的协作非常顺手。

最后分享一个个人习惯:写完测试第一版后,我会故意把被测代码里的某个关键 return 改成错误值、或者删掉某行调用,看测试会不会变红。如果测试没有如预期报错,说明这个测试的断言和打桩可能只是摆设。用这种方式定期“挑战”自己的测试代码,比引入任何覆盖率门禁都更能保证单测质量。Mockito 给了你精确控制虚拟数据和验证交互的能力,但最终测试值不值得信任,仍然取决于你是否真的从“行为正确性”角度设计每一个 verify 和每一个 thenReturn。

RELATED READING

延伸阅读

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