3. MockK 基本用法:mock、spy、relaxed mock、slot、capture、every/coEvery、verify/coVerify
本章围绕 MockK 协程测试核心构造展开,覆盖 mock 对象创建、spy 部分模拟、relaxed mock 自动桩、参数捕获(slot/capture)、行为定义(every/coEvery)和验证(verify/coVerify)。掌握这些基础 API 是后续协程挂起函数测试的基石。
3.1 核心概念对比
| 构造 | 用途 | 是否默认返回默认值 | 适用场景 |
|---|---|---|---|
mockk<T>() |
创建完整 mock 对象 | 否(需显式定义行为) | 全模拟、隔离被测类 |
spyk(obj) |
创建 spy 对象(包装真实实例) | 是(未桩方法委托给真实对象) | 部分模拟、保留真实逻辑 |
mockk<T>(relaxed = true) |
创建 relaxed mock | 是(返回类型默认值) | 快速 mock、无需逐一定义返回值 |
slot<T>() |
参数捕获槽 | — | 捕获调用时传入的具体参数 |
capture(slot) |
在 every/verify 中捕获参数 | — | 配合 slot 验证参数内容 |
3.2 创建 mock 与行为定义
使用 mockk() 创建普通 mock,必须通过 every 或 coEvery 定义返回值。
interface UserRepository {
suspend fun findById(id: Int): User?
fun getName(id: Int): String
}
val repo = mockk<UserRepository>()
every { repo.getName(1) } returns "Alice"
coEvery { repo.findById(1) } returns User(1, "Alice")
println(repo.getName(1)) // Alice
runBlocking { println(repo.findById(1)) } // User(1, "Alice")
注意:coEvery 用于挂起函数,every 用于普通函数。若在协程环境中使用 every 定义挂起函数,MockK 会抛出异常。
3.3 relaxed mock:自动桩
设置 relaxed = true 后,未定义行为的调用返回“类型默认值”(数字 0、布尔 false、字符串空、集合空、可空类型 null)。
val relaxedRepo = mockk<UserRepository>(relaxed = true)
// 无需 every 即可调用,返回默认值
println(relaxedRepo.getName(99)) // ""
runBlocking { println(relaxedRepo.findById(99)) } // null
注意: relaxed mock 不会验证参数匹配,所有未捕获的调用都返回默认值。如果希望验证参数,请配合 slot 和 capture 使用。
3.4 spy:保留真实对象的部分逻辑
spyk 将真实实例包装为 spy,未桩方法委托给真实实现;已桩方法覆盖原行为。
class UserService {
fun getDisplayName(id: Int): String {
return "User:$id"
}
}
val service = spyk(UserService())
every { service.getDisplayName(1) } returns "Admin"
println(service.getDisplayName(1)) // Admin(桩覆盖)
println(service.getDisplayName(2)) // User:2(委托真实)
提示: spy 适合处理复杂依赖的遗留代码,但应优先使用 mock 以保持测试纯粹性。
3.5 参数捕获:slot 与 capture
使用 slot 创建捕获槽,在 every 或 verify 中通过 capture 捕获实际入参。
val slotName = slot<Int>()
every { repo.getName(capture(slotName)) } answers {
"captured: ${slotName.captured}"
}
repo.getName(42)
println(slotName.captured) // 42
同样可用于 verify 阶段的参数验证:
val captor = slot<Int>()
verify { repo.getName(capture(captor)) }
assertEquals(42, captor.captured)
3.6 every / coEvery 行为定义
| 函数 | 作用 | 返回值语法 | 协程支持 |
|---|---|---|---|
every { mock.method() } |
定义普通函数返回值 | returns value 或 answers { … } |
否(仅非挂起函数) |
coEvery { mock.suspendMethod() } |
定义挂起函数返回值 | returns value 或 coAnswers { … } |
是(协程上下文) |
every { mock.method() } just Runs |
定义 void 函数(Unit 返回) | just Runs |
否 |
coEvery { repo.findById(any()) } returns User(0, "default")
coEvery { repo.findById(gt(100)) } coAnswers {
User(call.args[0] as Int, "large id")
}
// 协程 answers 块使用 coAnswers
coEvery { repo.findById(any()) } coAnswers {
val id = call.args[0] as Int
if (id < 0) null else User(id, "User$id")
}
3.7 verify / coVerify 验证交互
验证函数是否被调用、调用次数、参数匹配。
// 普通函数验证
verify(exactly = 1) { repo.getName(1) }
// 挂起函数验证
coVerify(exactly = 1) { repo.findById(1) }
// 验证从未被调用
verify(inverse = true) { repo.getName(99) }
// 验证至少一次
verify(atLeast = 1) { repo.getName(any()) }
// 验证顺序
verifyOrder {
repo.getName(1)
repo.getName(2)
}
关键规则:
coVerify必须放在runTest或runBlocking块内。- 默认验证至少一次(
atLeast = 1),显式使用exactly或atMost控制次数。 verifyAll验证所有已定义的调用是否发生过。
3.8 核心流程:从 mock 创建到验证
3.9 常见错误与调试
| 错误 | 原因 | 修复 |
|---|---|---|
every { mock.suspendFun() } 编译报错 |
every 不支持挂起函数 | 替换为 coEvery |
verify 在 runTest 外无法验证 |
协程作用域不匹配 | 将 verify 放在 runTest/runBlocking 块内 |
| slot.captured 为 null | 未调用 capture(slot) 或未执行调用 | 检查 every 或 verify 中是否使用了 capture |
| relaxed mock 返回空集合而非预期值 | 未定义行为导致返回默认空集合 | 使用 every 显式定义特定参数返回值 |
3.10 性能与最佳实践
- 优先 relaxed mock:对于只关心交互次数而不需精确返回值的测试,使用 relaxed mock 减少样板代码。
- 限制 spy 使用:spy 会执行真实代码,可能引入副作用,仅在模拟遗留代码时使用。
- slot 复用前清空:多次测试共用 slot 实例时,每次用前调用
slot.clear()避免残留值。 - coEvery 块中避免阻塞:
coAnswers内部应使用非阻塞代码,否则影响协程性能。 - 使用
any()匹配器:当参数不重要时用any()减少桩定义条数。
本节要点速查:
mockk创建 mock,spyk包装真实对象。every/coEvery定义普通/挂起函数行为。slot+capture捕获参数用于断言。verify/coVerify验证交互次数与参数。- relaxed mock 自动提供默认值,减少样板代码。