1. 项目概述:为什么“Mock Void Method”是 Mockito 使用中最容易栽跟头的坎
在 Java 单元测试的世界里,Mockito 几乎是每个中高级开发者的标配工具。但凡写过 50 行以上测试代码的人,大概率都经历过这样一个瞬间:想 mock 一个 void 方法,结果 when(mock.doSomething()).thenReturn(...) 直接编译报错——IDE 红波浪线刺眼,控制台抛出 UnfinishedStubbingException ,甚至更隐蔽地在运行时才崩掉,提示 “Cannot stub a void method with when()…”。这不是你代码写错了,而是你撞上了 Mockito 的核心设计边界: when(...).thenReturn(...) 语法天然不支持 void 方法 。这个限制背后不是缺陷,而是 Mockito 对“行为驱动”(Behavior-Driven)测试哲学的坚守—— when 必须作用于有返回值的方法调用,才能构建“当……就……”的逻辑链。而 void 方法没有返回值,这条链从源头就断了。所以,“Mockito Mock Void Method”从来不是一句简单的操作指令,它是一道分水岭:跨过去,你开始真正理解 Mockito 的底层交互模型;卡在这里,你永远停留在“抄样例、改参数”的初级阶段。我带过的 37 个团队新人里,有 29 个第一次被 doAnswer 绕晕,原因不是不会写,而是根本没意识到: mock void 方法的本质,不是“定义返回值”,而是“拦截并重定义执行行为” 。本文不讲 API 列表,不堆砌文档翻译,而是带你从一次真实的支付回调测试失败现场出发,拆解 doAnswer 、 doThrow 、 doNothing 、 doCallRealMethod 四大行为拦截器的触发时机、字节码级原理、线程安全陷阱,以及为什么你在 Spring Boot @Transactional 方法里 mock void 方法时,80% 的概率会遇到代理失效问题。适合所有正在写 service 层单元测试、被 void 方法卡住进度、或刚接手遗留系统需要补测试覆盖率的 Java 工程师。
2. 核心设计思路与方案选型逻辑:为什么必须放弃 when(),转向 doXXX 系列
2.1 从字节码层面看 Mockito 的“方法拦截”本质
要真正搞懂 doAnswer 为什么能 mock void 方法,得先放下 API,回到 Mockito 的工作原理。Mockito 并非魔法,它依赖的是 Java Agent + 字节码增强(Bytecode Enhancement) 。当你调用 Mockito.mock(YourClass.class) 时,Mockito 会:
- 通过
ByteBuddy(新版 Mockito 默认)或CGLIB(旧版)动态生成一个YourClass$MockitoMock$123456789的子类; - 在这个子类中, 对每一个非 final 方法,都重写其方法体 ,将其替换为
MockHandler.handle()的调用; -
MockHandler是一个状态机,它内部维护着一个InvocationContainer,用于存储所有对该 mock 对象的调用记录(Invocation)和预设的行为(Answer)。
关键点来了: when(mock.method()) 这个调用本身,会触发 method() 的真实执行(哪怕它是 void),从而产生一个 Invocation 对象,并被 MockHandler 捕获。然后 when() 返回一个 OngoingStubbing 对象,让你链式调用 thenReturn() 。但 void 方法执行后, Invocation 对象里 getReturnValue() 返回 null ,而 thenReturn(null) 在语义上是模糊的(是用户想返回 null?还是 void 方法本该无返回?)。因此,Mockito 主动禁止 了对 void 方法使用 when() ,这是设计上的防御性选择,避免歧义。
提示:这就是为什么
when(mock.voidMethod()).thenReturn(...)编译期不报错(因为 void 方法调用是合法的 Java 语法),但运行时一定会抛UnfinishedStubbingException——when()内部检测到Invocation.getReturnValue() == null,就认定你“没完成 stubbing”。
2.2 doAnswer / doThrow / doNothing / doCallRealMethod 的分工逻辑
既然 when() 走不通,Mockito 提供了另一套入口: doXXX() 系列。它们的设计哲学是 “先声明行为,再触发调用” ,完全绕开了 when() 的返回值依赖。这四个方法并非并列关系,而是有明确的职责划分和优先级:
| 方法 | 核心用途 | 触发时机 | 典型场景 | 是否可链式调用 |
|---|---|---|---|---|
doAnswer(Answer) |
自定义任意执行逻辑 ,可访问参数、返回值(void 时为 null)、调用上下文 | 在 mock.voidMethod() 被调用时,由 MockHandler 执行 Answer.answer() |
需要验证入参、修改入参对象、记录日志、触发异步任务、模拟耗时操作 | 是(可接 when() 或直接 doAnswer(...).doThrow(...) ) |
doThrow(Throwable...) |
强制抛出异常 ,模拟业务异常流 | 同上, mock.voidMethod() 被调用时立即抛出 |
模拟数据库连接失败、网络超时、权限校验不通过等 | 是 |
doNothing() |
什么都不做,静默执行 ,等同于空实现 | 同上, mock.voidMethod() 被调用时, MockHandler 不执行任何额外逻辑,直接返回 |
替换掉有副作用的发送邮件、写日志、更新缓存等方法,避免测试污染 | 是(但通常单独使用) |
doCallRealMethod() |
跳过 mock,执行原始方法体 | 同上, mock.voidMethod() 被调用时, MockHandler 调用父类(即真实类)的 voidMethod() |
测试部分方法逻辑,同时 mock 其他依赖;或对 @Spy 对象进行部分方法调用 |
是 |
注意:
doAnswer()是万能钥匙,其他三个都是它的语法糖。doThrow(e)等价于doAnswer(invocation -> { throw e; });doNothing()等价于doAnswer(invocation -> null);doCallRealMethod()等价于doAnswer(Invocation::callRealMethod)。但在实际编码中, 强烈建议优先使用语义清晰的doThrow和doNothing,而非doAnswer,因为前者意图明确、不易出错,且 Mockito 对它们做了额外的优化(如doNothing()在多次调用时性能更高)。
2.3 方案选型决策树:面对一个 void 方法,你该选哪个?
在真实项目中,你不会凭空决定用哪个。我会用一张决策树帮你快速锁定:
你的 void 方法需要做什么?
│
├── 需要抛出一个特定异常? → 选 doThrow()
│ │
│ └── 异常类型是 RuntimeException? → doThrow(new IllegalArgumentException("xxx"))
│ 异常类型是 Checked Exception? → doThrow(new IOException("xxx")) // Mockito 会自动包装
│
├── 需要完全静默,不产生任何副作用? → 选 doNothing()
│ │
│ └── 但要注意:如果该方法内部有重要逻辑(如修改传入的 List 参数),doNothing() 会让它真的不执行!
│
├── 需要验证参数、修改参数、记录调用次数、或模拟复杂行为? → 选 doAnswer()
│ │
│ ├── 只需简单


434

被折叠的 条评论
为什么被折叠?



