关于 mocking-with-mockk
这是一份面向 Kotlin 测试的 MockK 使用指南,适合需要模拟协程及 Kotlin 特性的开发者。内容涵盖挂起函数存根与验证、对象和静态模拟、参数捕获及清理全局模拟。
主要功能
- 01为挂起函数设置存根和验证
- 02模拟 object、静态函数与构造函数
- 03验证调用次数和顺序
- 04捕获调用参数
- 05按需选择严格或宽松模拟
适用场景
- 01测试包含协程的 Kotlin 代码
- 02模拟单例或顶层函数
- 03检查依赖调用及其顺序
- 04捕获并断言传入参数
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill mocking-with-mockk安装后在 Claude Code 中输入/mocking-with-mockk确认已加载。
详细介绍
使用 MockK 进行模拟——Kotlin 优先的模拟工具集
MockK 是 Kotlin 优先的 Mockito 替代方案。它的主要优势包括:原生支持挂起函数(使用 coEvery / coVerify,无需折腾 runBlocking)、一流的单例 / object / 静态方法 / 构造函数模拟,以及 Kotlin 原生语法(every { … } returns x)。代价是:androidx 本身不使用 MockK(在 AOSP 检出目录中运行 grep -r "io.mockk",结果为零——请参阅 ../mocking-with-mockito/SKILL.md,了解主流做法)。Google 之外的新 Kotlin 代码通常会选择 MockK;Google 内部或遵循 AOSP 惯例的新 Kotlin 代码则会选择 Mockito。两者都适用。
何时使用此技能
- 代码库仅使用 Kotlin,且以挂起函数 / 协程为主,并且用户希望以简洁的方式对挂起函数进行桩设定。
- 用户需要模拟 Kotlin
object(单例)、伴生对象、顶层函数(文件名带Kt后缀),或某个类的所有新建实例。 - 用户希望默认采用严格验证,并可通过
relaxed = true/relaxUnitFun = true按模拟对象选择退出。 - 用户希望使用
slot<T>()捕获参数,并通过slot.captured读取(比 Mockito 的ArgumentCaptor更简洁)。 - 用户正在为
viewModelScope、Flow收集器或类似LaunchedEffect的挂起协作者编写测试。
何时不要使用此技能
- 代码库混用 Java / Kotlin,或已统一采用 Mockito。请使用
../mocking-with-mockito/SKILL.md。没有理由就不要迁移——两个框架都适用。 - 用户要遵循 AOSP / androidx 惯例(这些项目只使用 Mockito)。请使用
../mocking-with-mockito/SKILL.md。 - 行为比交互更重要,例如带有缓存逻辑的
Repository——改为编写 fake。请参阅../../../fundamentals/doubles/picking-test-doubles/SKILL.md(Google 在 /test-doubles 中明确更推荐使用 fake)。 - 测试运行器 / Gradle 矩阵尚未配置——请从
../../runner/configuring-junit4-on-android/SKILL.md开始。 - 用户要测试随时间推移的
Flow发射——将 MockK 与 Turbine 配合使用。请参阅../../coroutines/testing-flows-with-turbine/SKILL.md。 - 用户需要
runTest语义——请参阅../../coroutines/testing-coroutines-with-runtest/SKILL.md。
前置条件
- 已完成
../../runner/configuring-junit4-on-android/SKILL.md中的基础测试接线。 - 如果被测代码会触及
Dispatchers.Main,则已安装MainDispatcherRule——请参阅测试运行器技能。 - 对于插桩式 MockK(Android 运行时),设备必须为 API 21 或更高版本(MockK 通过 dexmaker 支持至 API 21)。
工作流
- 1. 添加 MockK 坐标。 固定使用特定的 1.14.x 版本(撰写本文时的最新版本):
dependencies {
// JVM unit tests (src/test/)
testImplementation("io.mockk:mockk-jvm:1.14.0")
// Instrumented tests (src/androidTest/) — different artifact + agent
androidTestImplementation("io.mockk:mockk-android:1.14.0")
androidTestImplementation("io.mockk:mockk-agent:1.14.0")
}
mockk-android 会替换字节码生成后端,让 MockK 能在原版 ByteBuddy 无法运行的 Dalvik/ART 中运行。对于 src/test/ 中的 Robolectric 测试,请使用 mockk-jvm(而不是 mockk-android)——Robolectric 运行在 JVM 上。
- 2. 通过
MockKAnnotations.init(this)或直接构造mockk<T>()来接入 Mock。 对于包含许多 Mock 的测试,注解方式更清晰;对于一次性 Mock,直接构造更合适:
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.impl.annotations.InjectMockKs
@RunWith(AndroidJUnit4::class)
class UserViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@MockK lateinit var repo: UserRepository
@RelaxedMockK lateinit var logger: Logger
@SpyK var realClock: Clock = SystemClock()
@InjectMockKs lateinit var subject: UserViewModel
@Before fun setUp() = MockKAnnotations.init(this, relaxUnitFun = true)
@After fun tearDown() = unmockkAll() // see step 5 — cleanup global hooks
}
MockKAnnotations.init 不会自行安装全局钩子(mockkStatic / mockkObject / mockkConstructor);但如果测试类添加了其中任何一种钩子,就需要使用 @After unmockkAll(),以防影响同一测试套件中的其他测试。JUnit5 的 MockKExtension 会自动执行清理,JUnit4 则不会。
直接构造:
val car = mockk<Car>(
name = "carA",
relaxed = false,
relaxUnitFun = true, // Unit-returning calls auto-relax; reference returns still throw
moreInterfaces = arrayOf(Comparable::class),
)
- 3. 普通函数使用
every { … } returns …进行桩替换,挂起函数使用coEvery { … } returns …。 这是相较于 Mockito 最显著的易用性优势——挂起函数的桩替换不需要用runBlocking { whenever(...) }包裹:
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.verify
every { car.drive(Direction.NORTH) } returns Outcome.OK
every { car.brake() } throws IllegalStateException("frozen")
every { car.gear } returnsMany listOf(1, 2, 3) // sequence
every { car.compute(any()) } answers { firstArg<Int>() * 2 }
// Suspend — the big win:
coEvery { repo.fetchUser(1) } returns User("Jane")
coEvery { repo.observeUser(1) } returns flowOf(User("Jane"))
- 4. 使用
verify { … }、coVerify { … }和严格变体验证交互。 MockK 的验证器比 Mockito 的更丰富:
verify { car.drive(Direction.NORTH) }
verify(exactly = 2) { car.drive(any()) }
verify(exactly = 0) { car.brake() } // = never
verify(atLeast = 1, atMost = 3) { car.drive(any()) }
verifyAll { /* must list every interaction — exhaustive, unordered */ }
verifySequence { /* every interaction in this exact order */ }
verifyOrder { /* listed interactions occurred in this order, others may also have happened */ }
coVerify(exactly = 1) { repo.fetchUser(1) }
confirmVerified(car) // strict: nothing else was invoked
confirmVerified 是用于捕获未显式检查的额外交互的安全网——将它与 verifyAll/verifySequence 搭配使用,可让测试受到全面约束。
- 5. 使用
slot<T>()捕获参数。 这比 Mockito 的参数捕获器更简洁——slot.captured是一个属性:
import io.mockk.slot
val slot = slot<User>()
every { repo.save(capture(slot)) } returns Unit
subject.register(User("Jane"))
assertEquals("Jane", slot.captured.name)
// Multiple captures over a sequence of calls:
val users = mutableListOf<User>()
every { repo.save(capture(users)) } returns Unit
// ... many calls ...
assertEquals(3, users.size)
-
6. 谨慎选择
relaxed = true或relaxUnitFun = true。 MockK 默认是严格模式——任何未设置桩替换的调用都会抛出MockKException。两种放宽模式如下:relaxed = true— 每个未设置桩替换的调用都会返回类型默认值(0、false、空字符串;引用类型则返回嵌套 Mock)。这能减少样板代码,但也会掩盖 Bug。relaxUnitFun = true— 仅自动放宽返回Unit的调用;返回引用类型的调用仍会抛出异常。对于日志记录器、指标和框架回调协作者,这是推荐的折中方案。
val logger = mockk<Logger>(relaxed = true) // any call -> default
logger.info("anything") // no every{} needed
val n = logger.lineCount // returns 0
val analytics = mockk<Analytics>(relaxUnitFun = true) // safer middle ground
analytics.track(Event("foo")) // OK — Unit return is relaxed
analytics.session // throws — reference return needs every{}
- 7. 使用
mockkObject/mockkStatic/mockkConstructor模拟单例 / 静态对象 / 构造函数,并且务必清理。 这些操作会安装全局钩子,除非显式取消模拟,否则钩子会在当前测试结束后继续生效。测试间的状态泄漏是 MockK 最常见的陷阱。
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.mockkConstructor
import io.mockk.unmockkAll
@After fun tearDown() = unmockkAll() // belt-and-braces cleanup
@Test fun mocksObject() {
mockkObject(MySingleton)
every { MySingleton.flag } returns true
// ... assertions ...
}
@Test fun mocksTopLevel() {
// Kotlin top-level functions compile as static methods on the file's *Kt class.
mockkStatic("com.example.UtilsKt")
every { hashSomething(any()) } returns "deadbeef"
}
@Test fun mocksJavaStatic() {
mockkStatic(System::class)
every { System.currentTimeMillis() } returns 0L
}
@Test fun mocksConstructor() {
mockkConstructor(OkHttpClient::class)
every { anyConstructed<OkHttpClient>().newCall(any()) } returns fakeCall
}
JUnit5 的 MockKExtension 会自动清理,但 JUnit4 测试必须在 @After 中调用 unmockkAll()。
- 8. 确认测试能够运行,并且严格模式下的失败确实会触发。 运行
./gradlew :<module>:testDebugUnitTest --tests <YourTestClass>。如果一个测试对严格 Mock 使用了verifyAll/verifySequence/confirmVerified并且通过,就表示每次交互都已得到检查。如果 MockK 抛出MockKException: no answer found for: …,说明 SUT 调用了测试未设置桩替换的方法——可以为其设置桩替换、放宽 Mock,或修复 SUT。
模式
模式:错误写法与正确写法 — 挂起函数的桩替换
// WRONG
every { mock.suspendFn() } returns x
// WRONG because: every { } produces a non-suspend stub. At call time MockK throws
// MockKException because the actual call carries a Continuation argument that
// every {} did not match against. This is the #1 trip-up when migrating from Mockito.
// RIGHT
coEvery { mock.suspendFn() } returns x
coVerify { mock.suspendFn() }
模式:错误写法与正确写法 — 静态 / 对象 Mock 在测试间泄漏
// WRONG
@Test fun firstTest() {
mockkStatic(System::class)
every { System.currentTimeMillis() } returns 0L
// ... assertions ...
// No unmockkStatic, no unmockkAll, no JUnit5 extension.
}
@Test fun secondTest() {
// System.currentTimeMillis() is STILL mocked from firstTest. Bizarre failures
// depending on test ordering follow.
}
// WRONG because: mockkStatic / mockkObject / mockkConstructor install global hooks.
// JUnit4 does not auto-clean MockK state.
// RIGHT — JUnit4 with explicit teardown
@After fun tearDown() = unmockkAll()
@Test fun firstTest() {
mockkStatic(System::class)
every { System.currentTimeMillis() } returns 0L
// ...
}
// secondTest now starts with a clean slate.
模式:错误写法与正确写法 — 选择放宽模式
// WRONG
val service = mockk<PaymentService>(relaxed = true)
service.charge(amount = 100, account = "acct-7")
// Test passes because charge() silently returns a relaxed default. No verify call,
// no every{} stub. The intended interaction was never asserted, and a behaviour
// regression would not surface here.
// RIGHT
val service = mockk<PaymentService>() // strict
every { service.charge(any(), any()) } returns ChargeResult.OK
service.charge(amount = 100, account = "acct-7")
verify(exactly = 1) { service.charge(eq(100), eq("acct-7")) }
confirmVerified(service)
当测试确实不关心返回值时,relaxed = true 适用于日志记录器 / 指标。对于领域服务,建议使用默认的严格模式,并显式设置 every / verify。
强制规则
- 必须在桩接
suspend函数时使用coEvery { … } returns …(不要使用every { … } returns …)。验证suspend调用时,必须使用coVerify { … }(不要使用verify { … })。 - 必须为
src/test/依赖io.mockk:mockk-jvm,并为src/androidTest/依赖io.mockk:mockk-android+io.mockk:mockk-agent。混用这些制品会导致 Agent 崩溃。 - 必须在使用
mockkObject、mockkStatic或mockkConstructor的 JUnit4 测试中,于@After方法调用unmockkAll()。测试之间的泄漏是 MockK 最常见的陷阱。 - 必须在不确定时优先使用
relaxUnitFun = true,而不是relaxed = true。这种折中方式隐藏的错误要少得多。 - 必须在使用
@MockK/@RelaxedMockK/@SpyK/@InjectMockKs时,于@Before中调用MockKAnnotations.init(this, relaxUnitFun = …)。否则,lateinit字段永远不会初始化。 - 必须在项目使用 JUnit 5 时,优先使用 JUnit5
MockKExtension,而不是手动清理——它会自动处理静态 / 对象模拟的 teardown。 - 必须遵循代码库其余部分所采用的模拟方式。未经团队协商,不要在标准化使用 Mockito 的模块中引入 MockK,反之亦然。
- 必须优先为行为复杂的协作者使用 fake(参见 developer.android.com/training/testing/fundamentals/test-doubles:“fakes ... are preferred”)。使用 MockK 验证交互,而不是重新实现行为。
- 不得模拟
inline函数或inline class(值类)——MockK 无法拦截它们。请改用包装或重构。 - 不得依赖
mockkStatic访问生产代码中的时间(System.currentTimeMillis、Clock.system)。请改为注入Clock抽象——无需改写字节码,生产代码也可测试。 - **推荐:**将
verifySequence(或verifyAll)与confirmVerified(mock)结合使用,构成完全受约束的测试,确保任何意外交互都会导致测试失败。
验证
-
./gradlew :<module>:testDebugUnitTest --tests <YourTestClass>执行通过。 -
every { mock.<suspend-fn>() } returns ...会在运行时触发MockKException("no answer found")——让测试失败来暴露问题,而不是依赖 grep。(搜索常见的 suspend 函数名片段会产生太多误报——loadConfig、fetchSync、observeForever通常并非挂起函数。) - 每个使用
mockkStatic/mockkObject/mockkConstructor的测试文件,都有@After fun tearDown() = unmockkAll(),或继承MockKExtension(JUnit5)。 - 没有文件同时导入
io.mockk.*和org.mockito.*——每个模块选择一种框架。 -
@MockK/@RelaxedMockK/@SpyK/@InjectMockKs字段均配有一个调用MockKAnnotations.init(this, ...)的@Before方法。 - 测试中的生产代码路径不依赖
mockkStatic(System::class)获取时间——应改为注入Clock抽象。 - 严格模式失败确实会触发:移除桩接后,运行 SUT 时会触发
MockKException: no answer found for: …。
参考资料
- MockK 官方网站:https://mockk.io/
- MockK API 参考:https://mockk.io/ANDROID.html
- MockK + 协程指南:https://mockk.io/#coroutines
- JUnit5 MockKExtension:https://mockk.io/#junit5
- Android Developers — 测试替身:https://developer.android.com/training/testing/fundamentals/test-doubles
- Android Developers — 本地测试:https://developer.android.com/training/testing/local-tests
- Mockito 与 MockK 的选择背景:参见
../mocking-with-mockito/SKILL.md——androidx 本身只使用 Mockito(400+ 个文件导入org.mockito,没有文件导入io.mockk)。 - 有关与 MockK 的
coEvery配套的协程测试基础设施,请参见../../coroutines/testing-coroutines-with-runtest/SKILL.md。 - 有关测试
Flow发射,请参见../../coroutines/testing-flows-with-turbine/SKILL.md。