mcpField
M
mocking-with-mockk

mocking-with-mockk

github.com/skydoves测试与质量33979 次安装22 周前更新
代码仓库

关于 mocking-with-mockk

这是一份面向 Kotlin 测试的 MockK 使用指南,适合需要模拟协程及 Kotlin 特性的开发者。内容涵盖挂起函数存根与验证、对象和静态模拟、参数捕获及清理全局模拟。

主要功能

  1. 01为挂起函数设置存根和验证
  2. 02模拟 object、静态函数与构造函数
  3. 03验证调用次数和顺序
  4. 04捕获调用参数
  5. 05按需选择严格或宽松模拟

适用场景

  1. 01测试包含协程的 Kotlin 代码
  2. 02模拟单例或顶层函数
  3. 03检查依赖调用及其顺序
  4. 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: …。

参考资料

评论

支持 Markdown