MockK 和 Mockito 的对比,Kotlin 项目怎么选
MockK 和 Mockito 的对比,Kotlin 项目怎么选
从一个编译错误说起
去年迁移一个老项目到 Kotlin 1.9 的时候,我遇到了一个挺典型的 Mockito 报错。大概长这样:
org.mockito.exceptions.base.MockitoException:
Cannot mock/spy class com.example.UserRepository
Mockito cannot mock this class: class com.example.UserRepository.表面看是 Mockito 的 mock 失败,实际根因是 Kotlin 1.9 里 kotlin-reflect 的版本和项目里引用的 mockito-core 不兼容。修这个花了小半天,最后发现是 mockito-inline 的版本没跟上。这种版本扯皮在 Mockito + Kotlin 的组合里不算少见,尤其项目里如果还混着 Java 模块,依赖树稍微复杂一点就容易踩坑。
这件事让我重新想了想:如果项目主要是 Kotlin 代码,Mockito 到底是不是最优解?MockK 这些年社区声量不小,但迁移成本、团队熟悉度、和现有 CI 流程的契合度都是现实问题。这篇文章我想把这两个库放在实际工程场景里仔细比比,不是列优缺点那种,而是聊聊具体写测试时会碰到的差异。
Mockito 的 Kotlin 适配史
Mockito 本身是 Java 世界的标准答案,GitHub 上 14k+ star,Spring Boot 默认集成,Stack Overflow 上的问题库深不见底。但原生 Mockito 对 Kotlin 的支持经历过一段挺尴尬的过渡期。
早期的标准做法是用 mockito-kotlin 这个第三方包装库,作者是 Niek Haarman,后来这个库合并到了 Mockito 官方组织下面,变成 org.mockito.kotlin:mockito-kotlin。这个库主要解决几个 Kotlin 特性和 Mockito 的摩擦点:
`any()` 的 null 安全问题。 Mockito 的 any() 在 Java 里匹配任意参数包括 null,但 Kotlin 的非空类型会让 any() 直接抛空指针。mockito-kotlin 重写了 any() 的实现,用 reified 类型参数来绕过这个问题。写法上从:
Mockito.any(String::class.java)变成:
any<String>()看起来小改动,但代码可读性好很多,尤其链式调用长的时候。
`when` 是 Kotlin 关键字。 原生 Mockito 的 Mockito.when() 在 Kotlin 里要写成 __PLACEHOLDER_CODE_2__when__PLACEHOLDER_CODE_3____PLACEHOLDER_CODE_4__Mockito.when,很丑。mockito-kotlin 提供了 whenever` 这个别名,算是基本的人道主义关怀。
默认 final 类的问题。 Kotlin 的类默认是 final 的,而 Mockito 2 之前不能 mock final 类。解决方案是加 open 关键字,或者用 Mockito 的 inline mock maker。mockito-kotlin 文档里长期建议配合 mockito-inline 使用,但 inline mock maker 基于 ByteBuddy 的动态代理,启动时有明显的 instrumentation 开销,大型项目里测试类多了能感知到启动变慢。
Mockito 5 之后,inline mock maker 变成默认,这件事算是翻篇了。但历史包袱还在——很多老项目的 Gradle 配置里还留着 mock-maker-inline 的文件,迁移时如果没清理干净会出诡异问题。
MockK 的设计起点
MockK 是纯粹的 Kotlin 原生库,GitHub 地址 mockk/mockk,目前 5k+ star,维护者主要是俄罗斯开发者。它的设计完全围绕 Kotlin 的语言特性展开,没有 Java 兼容性的历史包袱。
最核心的差异是 MockK 用 Kotlin 的编译期插件和运行时代理结合,而不是 ByteBuddy 那种 JVM 层面的字节码操作。这带来几个直接后果:
不需要 `open` 类。 MockK 能直接 mock final 类、final 方法、密封类(sealed class),甚至 Kotlin 的 object 单例。这在 Kotlin 项目里是巨大的体验提升,不用为了测试把类改成 open,保持代码的语义完整性。
对协程的一等支持。 Mockito 的协程支持一直比较别扭。mockito-kotlin 提供了 runBlocking 的包装,但本质上是在测试线程里阻塞等待。MockK 有专门的 coEvery、coVerify、coAnswers,直接对应 suspend 函数:
coEvery { repository.fetchData() } returns flowOf(mockData)
coVerify { presenter.loadItems() }这种写法在测试 ViewModel 或者 Repository 层时很自然。Mockito 要达到同样效果,通常需要引入额外的 kotlinx-coroutines-test 依赖,手动处理 Dispatchers 的替换,代码量明显更多。
可验证的静态方法和顶层函数。 MockK 的 mockkStatic 能 mock Kotlin 的顶层函数(top-level function)和 Java 的静态方法,这是 Mockito 很难做到的。比如你的代码里调了 System.currentTimeMillis() 或者某个 DateUtils.format() 的顶层函数,MockK 可以直接控制返回值:
mockkStatic(System::class)
every { System.currentTimeMillis() } returns 12345678LMockito 要实现类似效果,需要 PowerMock 或者 Mockito 的 mockStatic(3.4.0+),但后者对 Kotlin 顶层函数的支持基本为零,因为 Kotlin 顶层函数编译成 Java 后是静态方法放在 FileNameKt 类里,Mockito 的静态 mock 对这种命名和结构感知很弱。
写测试时的具体差异
说语法特性可能有点抽象,我挑几个实际写测试的场景具体对比。
构造 mock 对象的方式。 Mockito 是 mock(MyClass::class.java) 或者 @Mock 注解 + MockitoAnnotations.openMocks(this)。MockK 是 mockk<MyClass>() 或者 @MockK + MockKAnnotations.init(this)。看起来差不多,但 MockK 的 mockk() 支持更细粒度的配置,比如 relaxed = true 表示所有未 stub 的方法返回默认值,而不是抛异常:
val relaxedMock = mockk<MyClass>(relaxed = true)Mockito 要达到类似效果需要 mock(MyClass::class.java, RETURNS_SMART_NULLS) 或者自定义 Answer,配置层级不太一样。
参数匹配器。 Mockito 的参数匹配器是调用时混在参数列表里的,比如:
verify(repository).save(any(), eq("expected"), anyOrNull())如果参数列表长,匹配器和实际值的视觉区分度不高。MockK 用 withArg 或者 match 把匹配逻辑包成 lambda,可读性稍好:
verify {
repository.save(
withArg { assertEquals("name", it.name) },
eq("expected"),
match { it == null )
}
`
MockK 的 `slot` 机制也很实用,可以捕获参数做后续断言:
val slot = slot<User>()
every { repository.save(capture(slot)) } returns Unit
// 执行被测代码后
assertEquals("expected_name", slot.captured.name)
Mockito 有 `ArgumentCaptor`,但语法更冗长,需要 `ArgumentCaptor.forClass(User::class.java)` 然后 `captor.capture()`,类型推导也不如 MockK 的 `slot<User>()` 直接。
**验证调用的精确度。** Mockito 的 `verify` 默认验证一次调用,`verify(mock, times(2))` 验证两次,`verifyNoInteractions` 验证零次。MockK 对应的是 `verify { ... }`(默认一次)、`verify(exactly = 2) { ... }`、`verify { mock wasNot Called }`。
MockK 有个 `verifySequence` 和 `verifyOrder`,用来验证调用顺序,比 Mockito 的 `InOrder` 写法简洁:
verifySequence {
cache.get("key")
api.fetch("key")
cache.put("key", any())
}
Mockito 的 `InOrder` 需要显式声明参与验证的 mock 列表,然后 `inOrder.verify(mock1).call1()` 这样链式写,mock 多了容易漏。
## MockK 的坑和局限
说了这么多 MockK 的好处,该泼冷水了。实际项目里用 MockK,有几个地方确实让人头疼。
**编译速度。** MockK 依赖 Kotlin 的 IR(Intermediate Representation)后端做代码生成,大型项目里编译测试代码的时间比 Mockito 长。我测过一个 30 万行 Kotlin 代码的项目,测试模块大概 800 个测试类,MockK 全量编译测试比 Mockito 慢 15-20%。日常增量编译感知不明显,但 CI 流水线里的全量测试阶段,这个时间差会被放大。GitHub Actions 的 runner 性能本来就一般,20% 的增量可能意味着多等几分钟。
**与某些 Gradle 插件的冲突。** MockK 的 agent 机制在 Android 项目里和 Jacoco 覆盖率报告、Firebase Performance Monitoring 的插件插件、甚至某些版本的 R8 都有兼容性问题。MockK 的 GitHub issue 里搜 "Jacoco" 能找到不少案例,典型症状是覆盖率数据缺失或者测试进程崩溃。
一个具体的 workaround 是在 `testOptions` 里关掉 R8 的测试代码优化:
android {
testOptions {
unitTests {
all {
jvmArgs '-XX:-UseParallelGC'
}
}
}
}
但这个配置不是万能药,具体项目可能需要调 JVM 参数或者 MockK 版本。MockK 1.13.x 之后修复了一部分 Android 兼容性问题,但边缘 case 还是存在。
**错误信息的可读性。** MockK 的验证失败信息有时候很冗长,尤其 `verify` 的调用次数不匹配时,堆栈里会混着 MockK 内部的反射调用帧,定位实际失败点需要多看几眼。Mockito 的错误信息经过这么多年打磨,确实更直白一些。比如同样是验证失败,Mockito 会告诉你 "Wanted but not invoked",MockK 可能抛出一长串 `io.mockk.MockKException` 带着内部状态 dump。
**社区生态和工具链。** Mockito 有 IDEA 的插件支持,比如自动生成 mock 的意图动作、快速修复建议。MockK 基本没有 IDE 专门优化,全靠手写。Stack Overflow 上的问题数量差距也很明显,Mockito 相关问题 20 万+,MockK 可能几千条。遇到奇葩问题,Mockito 更容易搜到现成答案。
**版本稳定性。** MockK 的 release 节奏不算稳定,1.12.x 到 1.13.x 之间有过一段比较长的停滞期,Kotlin 1.8 发布后有用户反映兼容性问题迟迟没修。相比之下 Mockito 的 release 更规律,有 Sonatype 的商业支持在背后,长期维护的确定性更强。
## Mockito 的协程困境
回到 Mockito 这边,它最大的短板在 Kotlin 协程。不是完全不能用,但处处透着"后补的"感觉。
`mockito-kotlin` 提供了 `runBlocking` 的测试辅助,但本质是阻塞当前线程。如果你的被测代码里用了 `Dispatchers.IO` 或者 `Dispatchers.Default`,测试里需要手动替换:
@Before
fun setup() {
Dispatchers.setMain(StandardTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
然后 `mockito-kotlin` 的 `runBlockingTest` 才能正常工作。这个 `StandardTestDispatcher` 是 `kotlinx-coroutines-test` 里的,又多一层依赖。
更麻烦的是 `Flow` 的测试。Mockito 能 mock 返回 `Flow` 的方法,但验证 `Flow` 的收集过程很别扭。MockK 有 `flowOf`、`coEvery` 配合 ` turbine` 库(app.cash.turbine:turbine,Cash App 开源的 Flow 测试工具)很顺滑,Mockito 这边同样的测试代码量通常翻倍。
我见过一些团队的做法是:业务代码全 Kotlin + 协程,但测试层为了用 Mockito,把 Repository 接口改成回调式或者阻塞式的测试替身。这种妥协很糟糕,测试代码和实际运行模式脱节,测了个寂寞。
## 混合项目的现实选择
很多项目不是纯 Kotlin,尤其 Android 项目里可能还有 Java 模块、JNI 层、或者依赖的第三方库是 Java 写的。这种混合场景怎么选?
我的实际经验是:如果项目里 Kotlin 代码占比超过 70%,且新模块全部 Kotlin,那 MockK 值得作为默认选择。遗留的 Java 测试可以保留 Mockito,不用强行迁移。Gradle 模块级别的依赖隔离做得很好,`testImplementation` 按模块声明,互不干扰。
但如果项目 Kotlin 占比不到一半,或者团队里大部分人对 Kotlin 特性不够熟悉,Mockito + `mockito-kotlin` 是更稳妥的选择。学习成本低,出问题能搜到答案,和现有 Java 测试基础设施的兼容性好。
有个中间路线是 Mockito 4.x 之后支持的 `mockito-inline` 静态 mock,配合 Kotlin 的 `@JvmStatic` 能用,但顶层函数还是搞不定。如果你的代码风格是大量顶层工具函数,MockK 的 `mockkStatic` 几乎是唯一解。
## 具体版本和依赖配置
写这篇文章时我查的最新版本:
MockK:io.mockk:mockk:1.13.8,Android 测试用 mockk-android:1.13.8。GitHub 上 release 页面显示 1.13.9 在准备中但还没发。MockK 是 Apache 2.0 协议,完全免费。
Mockito:org.mockito.kotlin:mockito-kotlin:5.2.1,对应 mockito-core 5.x。Mockito 也是 MIT 协议。注意 `mockito-kotlin` 的版本号和 `mockito-core` 是分开的,5.2.1 的 `mockito-kotlin` 实际依赖的是 Mockito 5.x 的核心库。
Gradle 配置里有个常见错误:同时引了 `mockito-core` 和 `mockito-kotlin`,结果版本解析冲突。建议只引 `mockito-kotlin`,让它传递依赖正确的 `mockito-core` 版本。
Android 项目里如果用了 Hilt 做依赖注入,Hilt 的测试辅助 `hilt-android-testing` 内部依赖的是 Mockito。这时候如果主项目切 MockK,要注意 `hilt-android-compiler` 的测试代码生成会不会和 MockK 的 agent 冲突。我遇到过一次,Hilt 生成的测试 Application 类在 MockK 的 mock 初始化阶段抛 `ClassCastException`,最后是靠升级 Hilt 到 2.48+ 解决的。
## 性能测试的一点数据
为了写这篇文章,我在一个空项目里做了组简单的对比测试,给想了解性能差异的人参考。
测试环境:OpenJDK 17,Kotlin 1.9.20,Gradle 8.4,MacBook Pro M1 Pro。
场景:mock 一个包含 10 个方法的接口,stub 其中 3 个,验证调用 1000 次。各跑 100 轮取平均。
Mockito 5.2 + mockito-kotlin 5.2.1:单轮平均 12ms
MockK 1.13.8:单轮平均 28ms
差距主要来自 MockK 的代理初始化开销。但这个测试极度简化,实际项目里网络、数据库、文件 IO 会淹没这部分差异。只有在纯单元测试、大量 mock 对象、零实际 IO 的场景下,MockK 的性能劣势才会显现。
另一个维度是内存占用。MockK 的 `relaxed mock` 会为所有方法生成默认返回值,对象图比 Mockito 的等效配置稍大。在 Android 的 instrumentation test 里,如果同时存在几百个 relaxed mock,偶尔能碰到 `OutOfMemoryError`,调大 test runner 的 heap 或者减少 relaxed mock 的使用可以缓解。
## 个人倾向和团队决策
我自己近两年的新项目全部用 MockK。原因很简单:写 Kotlin 测试时,MockK 的 API 设计和语言特性贴合度太高,用回 Mockito 会有明显的"翻译损耗"。尤其是处理 `suspend` 函数和 `Flow` 时,MockK 的代码量通常少 30-40%,可读性差距更大。
但如果是接手一个老项目,或者团队里有人强烈偏好 Mockito,我不会强行推迁移。测试库的选择属于"重要但不紧急"的决策,为了换工具而换工具,引入的回归风险可能超过收益。
有个判断标准可以参考:打开项目的测试目录,如果超过 30% 的测试类里有 `` `when` `` 这种反引号转义,或者大量 `open class` 纯粹为了测试而存在,那 MockK 的迁移价值就很高。这些代码异味(code smell)累积多了,说明 Mockito 在 Kotlin 项目里的适配成本已经不可忽视。
反过来,如果项目测试写得很少,或者主要是集成测试、UI 测试,单元测试库的选择影响有限,不用纠结。
## 最后说一个 MockK 的隐藏功能
MockK 的 `spyk` 和 `mockkObject` 组合,能实现对 Kotlin `object` 的部分 mock。这个需求在测试全局状态管理时偶尔会遇到,比如某个 `object NetworkMonitor` 负责监听网络状态,测试时想替换它的某个方法,但保留其他行为:
mockkObject(NetworkMonitor)
every { NetworkMonitor.isConnected() } returns false
// 其他方法仍走真实实现