JUnit 4.12 + Mockito 单元测试:依赖隔离与 mock/stub/verify 实战 简介JUnit 4.12与Mockito整合压缩包面向Java开发者聚焦单元测试场景帮助解决测试方法编写与外部依赖隔离两大核心问题。包体约3.55MB内含3个核心文件——JUnit 4.12.jar、Mockito.jar及Mockito离线文档其中jar文件提供注解、断言、参数化测试、模拟对象与行为验证等能力文档则收录API参考与最佳实践。已有622人学习适合初入单元测试或希望系统掌握Mockito的工程师。实际使用时可通过Test、assertEquals快速校验方法输出借助Mock与when/thenReturn构造隔离环境用verify确认调用关系还可利用参数化测试覆盖多组输入场景从而在不依赖数据库或外部服务的前提下完成可靠测试。配套文档能帮助初学者理解模拟对象的创建与配置并结合JUnit搭建高效用例有助于在开发早期发现问题提升回归测试效率和代码可维护性。1. JUnit 4.12 Mockito 这套组合先解决单元测试里最棘手的事JUnit 4.12 Mockito 这套组合解决的是单元测试里最棘手的一类问题被测类和数据库、外部网关、缓存服务耦合在一起导致用例要么起不来要么跑一次好、跑一次坏。单元测试能不能推进很大程度上不取决于你多会写断言而取决于你多快能把依赖隔离掉。JUnit 4.12 提供测试组织和断言框架Mockito 负责把依赖替换成完全可控的“假实现”让代码在没有网络、没有数据库的情况下也能把核心分支原原本本跑一遍。适合正在接手老工程、想把测试补起来的人也适合刚接触 mock 概念、想直接把一套可复现写法带回项目的开发者。下面按我自己的落地顺序来先讲选型和环境再讲 mock、stub、verify 的配合方式最后把高频翻车现场和两个进阶写法一起列出来。2. JUnit 4.12 Mockito 选型与依赖配置版本兼容矩阵和最小可跑样例2.1 为什么还选 JUnit 4.12老工程迁移成本与 Mockito 版本选择JUnit 5 发布这么多年以后新项目选测试框架基本没有悬念但存量工程里大量代码还挂在 JUnit 4 上。这不是懒而是迁移成本太具体Spring 的测试注解、各类 Rule、第三方组件的 runner 都绑定在 JUnit 4 的能力上一换就是连带改造。JUnit 4.12 是 4 系里比较晚的维护版本语法上覆盖了 Test、Before、After、Rule、RunWith 这一整套和 maven-surefire-plugin 配合也省心。它要求 Java 7 以上如果你的工程恰好是 Java 8那兼容性上没有负担。Mockito 的版本选择要比 JUnit 更细一点。1.10.x 还能在 Java 7 上跑功能比较老但对 final 类完全没招2.x 把内部实现重写了一遍Java 8 是它的主战场同时多了 mockito-inline 这种实验模块可以处理一部分 final 场景。我一般按这个原则选Java 8 的工程直接上 2.x还在 Java 7 的老项目锁 1.10.19 就够。给一张我常用的选择表运行环境JUnitMockito主要限制Java 74.121.10.x不能 mock final 类静态方法需要额外方案Java 84.122.x静态方法默认仍不行需要 mockito-inlineJava 114.122.x 近几个版本若用 inline 模块启动参数视 JDK 发行版确认这个表不是让你抄版本号而是提醒你Mockito 的能力边界和 Java 版本强相关。如果你在 Java 11 上用了老 2.x 再遇到怪异的字节码错误先怀疑版本而不是怀疑自己的代码。另外还要搞清楚 JUnit 4.12 和 Spring Test 的边界。Spring 的 SpringBootTest 负责拉起 Spring 上下文Mockito 负责在上下文里替换 Bean两者是互补关系不是替代关系。如果一个测试类里既需要真实 Spring 上下文又需要 mock 单个 Bean常见做法是 RunWith(SpringJUnit4ClassRunner.class) 配合 MockBean而 Mockito 自身的注解在那条路径下不会自动起作用需要手动 initMocks。这一点在新手期很容易忽略结论是纯单元测试用 Mockito 自己的 runner集成测试再走 Spring 的 runner别混着。2.2 Maven 依赖配置pom.xml 和第一个测试用例依赖长这样project dependencies dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.12/version scopetest/scope /dependency dependency groupIdorg.mockito/groupId artifactIdmockito-core/artifactId version2.28.2/version scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version2.22.2/version /plugin /plugins /build /project这段配置有三个容易被忽略的点。第一JUnit 4.12 默认带 hamcrest-core 1.3所以 assertThat 可以直接用不用再额外引 hamcrest-library除非你要用更偏门的匹配器第二mockito-core 已经把所有运行期依赖收进来了不用手动引 objenesis第三surefire 2.22.2 对 JUnit 4 的 provider 支持很成熟如果你还在用 2.12 这种老版本建议升上来否则很多测试报告和超时参数会失效。依赖配好后写一个最小的单元测试用例。假设我有一个订单服务依赖订单 DAO我要测订单查询逻辑是不是真的生效RunWith(MockitoJUnitRunner.class) public class OrderServiceTest { Mock private OrderDao orderDao; InjectMocks private OrderService orderService; Test public void findOrderById_shouldReturnMappedOrder() { Order order new Order(); order.setId(1L); when(orderDao.findById(1L)).thenReturn(order); Order result orderService.findOrderById(1L); assertNotNull(result); assertEquals(1L, result.getId().longValue()); verify(orderDao).findById(1L); } }这段代码里 RunWith 不能省它负责启动 Mockito 的注解处理器把 Mock 和 InjectMocks 初始化好。Mock 生成一个替身默认方法返回 null、0、false 这类“零值”所以任何你关心返回值的依赖方法都需要先 stub 再断言。InjectMocks 把替身注入到 OrderService注入顺序是构造器优先、setter 其次、字段最后如果构造器参数正好是 mock 的类型会优先走构造器否则走 setter。verify 在这里验证 orderDao.findById(1L) 确实被调用了一次这个在第三章会细讲。注意这个测试类没有写 Before因为 Mockito 的注解初始化只需要在类上声明一次每个测试方法执行前 runner 会自动重置替身状态。2.3 从 main 到 test确认依赖真生效的三种验证手段配置写完第一件事不是继续写用例而是先确认这套依赖真的把测试跑起来了。我一般会用三个手段验证。第一命令行只跑单个测试类mvn test -DtestOrderServiceTestsurefire 会按类名过滤如果它一个用例都没执行先看 target/surefire-reports 有没有报告文件。很多“测试没生效”其实是类名没以 Test 结尾被 surefire 的默认 include 规则过滤掉了。第二加一个覆盖率插件比如 jacoco-maven-plugin跑完 mvn test 后打开 target/site/jacoco/index.html。如果 OrderService 的行覆盖是 0%说明用例根本没进到被测类问题不在断言而在 InjectMocks 没注入成功或者测试类压根没被 surefire 加载。第三故意把断言语义改反比如把 assertEquals(1L, ...) 改成 assertEquals(2L, ...)用例应当变红。如果它还是绿的说明你断言的字段和被测代码实际操作的字段不是同一个对象多半是对象是新建的、没走同一个引用。这三条说起来土但都是我实际排查时的体力活能帮你少浪费很多时间。后面所有章节的示例我都默认你已经用这三步确认过依赖是通的。3. Mockito 核心用法mock、stub、verify 怎么配合才能把单测写快3.1 mock 与 stub先把依赖换成“假人”再让假人按剧本返回工程落地时我习惯把 Mockito 的用法拆成三件事mock 是造替身stub 是给替身指定行为verify 是确认替身有没有被按要求交互。三件事别混着看单测才好维护。先看 mock 和 stub。mock 的造法有两种一种是用 Mock 注解一种是用 Mockito.mock(OrderDao.class) 直接创建。注解方式更简洁配合 InjectMocks 也更自然直接创建通常用在“被测类不是靠注入而是靠工厂方法拿依赖”的老代码上。stub 的典型写法是 when-thenReturn但它有一个前提被 stub 的方法不能是 void否则 when 里的表达式没法执行。例如Order order new Order(); order.setId(1L); when(orderDao.findById(1L)).thenReturn(order); when(orderDao.update(order)).thenReturn(1);这两个 stub 分别对应“查得到订单”和“更新影响一行”的场景。when 的参数是真实方法调用Mockito 通过把它记录成方法签名和入参后面调用时按入参匹配返回。这里的 1L 是精确匹配如果测试里传了 2L就走不到这条 stub替身会返回默认 null后续逻辑大概率 NPE。所以写 stub 前要先想清楚用精确匹配还是模糊匹配。模糊匹配用 anyInt()、anyString()、any(Order.class)我一般把模糊匹配单独讲。void 方法是新手最容易翻车的点。例如 dao.delete(1L) 返回 void写 when(orderDao.delete(1L)).thenReturn(...) 会直接报错正确写法是doNothing().when(orderDao).delete(1L);doNothing 明确告诉替身这个方法调用时什么都不做。如果不 stubvoid 方法默认也不做什么所以这个写法多数用在你想强调“这里不该有副作用”的场景或者配合 doThrow 测异常路径doThrow(new DataAccessException(db down)).when(orderDao).delete(1L);它表示当 delete 被调用时抛出指定异常用来测业务代码对异常的处理。注意 doThrow、doNothing 都属于 do 系列它们和 when-thenReturn 的差别在于do 系列不提前执行方法本体而 when 系列会先执行一次方法。这一点对普通 mock 影响不大但对 spy 和部分有副作用的代码影响极大第四章会专门展开。还有一类场景被测类不是通过注解注入而是内部 new 出来的依赖例如 OrderService 里直接 new 了一个 PriceService。这时候 Mockito 帮不上忙常见做法是把内部 new 改成构造函数参数注入或者提供一个包级可见的 setter。我在老代码上就吃过这个亏最后把 new 改成注入单测才真正跑起来。3.2 verify 交互验证测的不只是返回值还有“你到底调没调”stub 只回答“依赖给我什么”verify 回答“业务代码到底有没有按预期调用依赖”。两者组合起来才能覆盖 if-else 分支和异常分支。最常见的写法是次数验证verify(orderDao, times(1)).findById(1L); verify(orderDao, never()).delete(anyLong()); verify(orderDao, atLeastOnce()).update(any(Order.class));times(1) 表示恰好一次never 表示一次都没有atLeastOnce 表示至少一次。我自己的习惯是核心链路用 times(1) 精确约束异常分支用 never 兜底。因为异常分支最容易出现“代码不小心把关键调用跳过了”的情况而断言只看返回值往往看不出来。举例一个下单方法里正常情况会扣库存、写订单、更新用户积分如果最后一步抛了异常前面的调用都已经发生了返回值可能还是成功只有 verify 才能把这种“调了一半”的状态暴露出来。如果业务代码对依赖的调用顺序有要求比如必须先减库存再扣余额就要用 inOrderInOrder inOrder inOrder(stockDao, accountDao); inOrder.verify(stockDao).deduct(stockId, 1); inOrder.verify(accountDao).debit(userId, amount);inOrder 验证两个 mock 之间的调用先后不满足时会直接报 VerificationInOrderFailure。注意 inOrder 只对它列出的 mock 生效没有列出的 mock 上的调用不计入。另外还有一个容易误用的方法verifyNoMoreInteractions(mock)。它的意思是“除了前面验证过的交互不允许再多任何交互”。看着很好用但它和查日志、写审计这类辅助方法很容易冲突我一般只在隔离得很干净的纯逻辑模块里用业务模块里用了反而容易产出脆弱的单测。3.3 参数匹配器与 ArgumentCaptor当入参没法精确定值时怎么断言写单测时常碰到两类诉求一类是不关心具体参数是什么只想关注行为另一类是参数是个复杂对象想断言对象内部字段。分别对应参数匹配器和 ArgumentCaptor。when(priceService.queryPrice(anyString())).thenReturn(new BigDecimal(99.00)); verify(orderService).sendMessage(eq(1L), anyString());这里 anyString() 表示任意字符串eq(1L) 表示长整型必须精确等于 1L。Mockito 有一条死规则一旦某个方法的参数里用了匹配器所有参数都得用匹配器否则抛 InvalidUseOfMatchersException。所以后面的固定值也得用 eq(...) 包起来。这不是玄学是 Mockito 内部通过栈帧识别参数表达式的机制决定的记住规则就行别纠结原理。ArgumentCaptor 用在“我想拿到实际传进去的参数再断言”的场景ArgumentCaptorOrder captor ArgumentCaptor.forClass(Order.class); verify(orderDao).save(captor.capture()); Order captured captor.getValue(); assertEquals(A001, captured.getCode()); assertTrue(captured.getAmount().compareTo(new BigDecimal(99.00)) 0);capture 表示捕获 save 方法这一次调用传入的 OrdergetValue 取出后继续断言内部字段。这个方法特别适合测那些“入参是从数据库读出来再加工”的代码。注意 captor 要配合 verify 使用不要放在 when 里否则拿到的不是业务代码实际传入的那个对象。另外如果 save 方法被调用了不止一次captor.getAllValues() 能拿到所有传入对象可以按次数逐个断言。4. 单测里 Mockito 常见问题排查五个高频翻车现场和处理手段4.1 静态方法 mock 不生效mockito-core 默认做不到的事别硬来现象测试里写了 when(UserUtils.getUserName(anyLong()))编译能过但运行时直接报错常见是 MissingMethodInvocationException 或者 NotAMockException。原因静态方法压根不是实例方法默认的 mockito-core 没有可替换的实例入口它处理不了静态代码。解决思路有两个一是把静态方法调用包到一个可注入的实例里改成依赖注入的方式再 mock这是我最推荐的做法二是确实绕不开时引入 mockito-inline 模块并在 pom 里单独配置但它是实验能力版本和 JVM 参数都得一起确认。我在业务代码里更倾向第一种把静态方法包起来的重构成本很低测试代码也不依赖额外模块后续维护省心得多。4.2 final 类与 final 方法 mock 失败版本和 JVM 选择都要对现象mock 一个标了 final 的类或者某个方法标了 finalMockito 直接报 Unable to mock final class。原因Mockito 默认通过生成子类的方式实现 mockfinal 类不让继承final 方法不让重写所以替身根本造不出来。解决先确认工程里这个 final 是不是真的必要能改代码就改成普通类不能改就上 mockito-inline并检查 Java 版本是否匹配在较新的 JDK 上 inline 模块还可能被 JVM 的安全配置挡住这也是个隐藏前提。我在旧项目里见过连着三个人在这个坑里来回踩最后都是靠“把 final 去掉”解决的因为那个类本来就没有必须 final 的理由。4.3 InvalidUseOfMatchersException参数匹配器混用是高频事故现象一个用例里同时出现 when(service.doSomething(anyString(), 固参)).thenReturn(x)运行直接抛 InvalidUseOfMatchersException。原因只要方法里有任何一个参数用了匹配器所有参数都必须用匹配器固定字符串也得用 eq(固参) 包起来。这个异常是 Mockito 内部通过栈帧推断“参数是不是匹配器”时抛出来的和业务逻辑没关系纯粹是写法问题。解决when(service.doSomething(anyString(), eq(固参))).thenReturn(x);还有一个隐藏点两个匹配器中间夹一个原始值也一样报比如 anyString()、1L、anyInt() 这种排列。我把它放在这里是因为它在团队里出现频率极高新人往往看不懂异常栈最常见的反应是去怀疑代码写错了实际就是少了个 eq 而已。4.4 spy 的 when 调用把真方法跑了一遍doReturn 才是后悔药现象用 Spy 对一个真实对象做部分 mockwhen(spy.getUserName()).thenReturn(fake)实际取到的却是真实结果甚至抛了真实方法的异常。原因spy 和 mock 不同默认调真实方法而 when(spy.getUserName()) 这行代码本身就先执行了一次真实方法副作用就发生在这一步。解决改用 doReturn。doReturn(fake).when(spy).getUserName();do 系列不会先执行方法本体它是先打桩再调用。这条在维护老代码时特别有用比如真实方法里有数据库查询用 when 写会把库真查一遍一个单元测试把测试库连崩的情况我都见过。那以后我看到 spy 上的 stub第一反应就是换 doReturn这个习惯帮我避开了很多坑。4.5 用例之间互相污染mock 状态和 static 全局变量现象单个用例都能过整个测试类一起跑就挂而且每次挂的用例不一样顺序还会影响结果。原因Mockito 的 mock 状态没有清理干净常见于同时用了 Spring 测试上下文和 Mockito或者被测代码里引用了静态配置、单例缓存。解决用 RunWith(MockitoJUnitRunner.class) 或 MockitoJUnitRunner.Silent让框架在每个用例结束后重置如果还是被静态状态污染在 After 里显式 reset 掉关键 mock并把被测类的单例字段改成可注入。我经历过一次最典型的翻车两个用例共享同一个静态内存缓存第一个用例写入的数据在第二个用例里被断言命中单测从“用例独立”变成了“按顺序耦合”排查了整整半天最后就是加了一层 After 清理解决的。这五个问题对照着看你会发现大多数翻车不是 Mockito 本身的问题而是默认行为和期待不一致。排查时先看异常类名再把“谁 mock、谁 spy、谁静态、谁 final”列一张清单问题基本能定位一半。5. 进阶技巧用自定义 Answer 和回调参数把最难测的分支按顺序验出来5.1 自定义 Answer同一个方法按参数返回不同结果有些方法没法用 thenReturn 一次写死比如价格服务要根据产品编码返回不同价格或者一个查询方法按页码返回不同列表。这时候用 thenAnswerwhen(priceService.getPrice(anyString())).thenAnswer(invocation - { String productCode invocation.getArgument(0); if (A001.equals(productCode)) { return new BigDecimal(99.00); } if (A002.equals(productCode)) { return new BigDecimal(199.00); } throw new ProductNotFoundException(productCode); });invocation 对象是一次真实调用的完整记录getArgument(0) 取第一个参数。这里把“参数 → 返回值”的映射写成一个小脚本一次覆盖多个分支不用为每个分支各写一个用例。注意 throw 分支会真实抛异常正好可以用来测业务代码的异常处理比 when-thenThrow 灵活。5.2 回调参数验证像测真实调用一样测异步回调我遇到最头疼的一类代码是异步回调方法把请求丢给 RPC 客户端结果在 callback 里返回。传统 mock 策略对这类代码很苍白因为 stub 的返回值根本不会被用到。自定义 Answer 可以反客为主在替身里把回调参数取出来主动执行它doAnswer(invocation - { Callback callback invocation.getArgument(1, Callback.class); callback.onSuccess(TXN123); return null; }).when(rpcClient).request(anyString(), any(Callback.class)); orderService.submit(A001); assertEquals(SUCCESS, orderService.getStatus());这里 request 的第二个参数是回调接口Answer 里手动调用 onSuccess把异步调用变成同步执行让业务代码里的状态流转真实发生然后直接断言业务对象的状态。这样一来异步分支不再是个黑匣子。从那以后我每次给新模块写单元测试都强制按固定次序走先列依赖清单再写 mock 和 stub用例跑完后补一轮 verify最后用自定义 Answer 和回调验证把难点分支补齐。这套流程帮我少翻了很多次车。上面这套可直接导入的 Demo 工程模板我也整理好了pom、源码、测试用例都齐需要的话顺着资源区拿到就能跑起来。希望帮到你。本文还有配套的精品资源点击获取