第4章 协程挂起函数Mock:coEvery与coVerify
挂起函数(suspend function)是Kotlin协程的核心单元,但在单元测试中直接调用挂起函数需要协程作用域,且传统Mock框架无法处理挂起函数的协程上下文。MockK提供了coEvery和coVerify两个扩展函数,专门针对suspend函数进行打桩(stub)和验证(verify),自动处理协程的挂起与恢复,无需手动构造Continuation实例。
核心原理
coEvery和coVerify内部通过MockK的协程拦截器将挂起函数的Continuation封装成MockK可识别的对象,从而实现与普通函数一致的打桩/验证体验。调用coEvery时返回的MockKStubScope支持returns、throws、answers等链式配置。4.1 打桩suspend函数:coEvery
使用coEvery对挂起函数设置预期行为,包括正常返回值或抛出异常。所有配置必须在coEvery块内完成。
基本语法
coEvery { mockObject.suspendMethod(args) } returns expectedValue
coEvery { mockObject.suspendMethod(args) } throws SomeException()
coEvery { mockObject.suspendMethod(args) } answers { arg1 + arg2 }
完整示例
interface UserRepository {
suspend fun fetchUser(id: Int): User
suspend fun deleteUser(id: Int)
}
class UserService(private val repo: UserRepository) {
suspend fun getUserName(id: Int): String {
val user = repo.fetchUser(id)
return user.name
}
}
@Test
fun `test coEvery returns`() = runBlockingTest {
val mockRepo = mockk<UserRepository>()
val user = User(1, "Alice")
coEvery { mockRepo.fetchUser(1) } returns user
val service = UserService(mockRepo)
val name = service.getUserName(1)
assertEquals("Alice", name)
}
@Test
fun `test coEvery throws`() = runBlockingTest {
val mockRepo = mockk<UserRepository>()
coEvery { mockRepo.fetchUser(any()) } throws RuntimeException("DB error")
val service = UserService(mockRepo)
assertFailsWith<RuntimeException> { service.getUserName(1) }
}
注意事项
•
• 若Mock对象本身不是接口或类,需使用
• 同一个挂起函数可多次
•
coEvery只能用于suspend函数,对非挂起函数会报编译错误。• 若Mock对象本身不是接口或类,需使用
mockk<T>()创建,spyk同样支持coEvery。• 同一个挂起函数可多次
coEvery覆盖,使用coEvery { ... } returns many { listOf(a, b) }实现多次调用不同返回值。4.2 验证suspend函数调用:coVerify
coVerify用于断言挂起函数是否被调用、调用次数及参数,必须在协程作用域(如runBlockingTest)内执行。
常用验证模式
coVerify { mockObject.suspendMethod(1) } // 至少调用一次
coVerify(exactly = 2) { mockObject.suspendMethod(1) } // 精确次数
coVerify(atLeast = 1, atMost = 3) { mockObject.suspendMethod(1) }
coVerify(inverse = true) { mockObject.suspendMethod(any()) } // 从未调用
验证顺序与参数匹配
coVerifySequence {
mockRepo.fetchUser(1)
mockRepo.deleteUser(1)
}
coVerify {
mockRepo.fetchUser(match { it > 0 })
}
常见错误:缺少协程作用域
如果在
如果在
@Test函数中直接调用coVerify而未使用runBlocking或runBlockingTest,会抛出org.mockk.exception.MockKException: coVerify requires coroutine context。务必包裹协程作用域。4.3 coEvery与coVerify功能对比
| 特性 | coEvery | coVerify |
|---|---|---|
| 目的 | 设置挂起函数的行为(打桩) | 断言挂起函数被调用情况 |
| 运行时机 | 测试执行前(配置阶段) | 测试执行后(验证阶段) |
| 语法 | coEvery { } returns/throws/answers | coVerify { } + 可选参数 |
| 是否依赖协程作用域 | 否(但在runBlocking内也可用) | 是(必须在runBlocking或runBlockingTest内) |
| 支持参数匹配 | 支持any()、eq()、match { } | 同上 |
| 验证顺序 | 不支持 | 支持coVerifySequence和coVerifyOrder |
性能优化建议
• 避免在
• 对于大量挂起函数测试,建议使用
•
• 避免在
coEvery中使用复杂的answers块,优先使用returns或throws。• 对于大量挂起函数测试,建议使用
@TestInstance(TestInstance.Lifecycle.PER_CLASS)配合clearMocks避免泄漏。•
coVerify默认检查mock对象的所有已记录调用,若只关心特定调用使用exactly = 1提升可读性。4.4 挂起函数Mock完整流程
下图展示了从定义接口、创建Mock、打桩、执行到验证的协程测试路径:
4.5 常见错误与调试
- “No overloads for ‘coEvery’ with these arguments”
原因:目标函数不是suspend函数。检查函数声明中是否包含suspend关键字。 - “coVerify requires coroutine context”
解决方法:将断言移动到runBlockingTest或runBlocking块内。 - “验证通过但实际并未调用”
常见于coEvery中使用了any()但验证时用精确参数。使用coVerify { mock.suspendFun(any()) }匹配任意参数。 - 挂起函数返回值类型为Unit时:
coEvery { mock.suspendVoid() } just Runs表示正常结束。
调试技巧
• 在
• 使用
• 在
runBlockingTest开头添加println(mock.toStr())可查看当前mock对象的所有已记录调用。• 使用
coVerify(exactly = 0) { ... }验证某挂起函数未被调用,常用来排查不应执行的分支代码。4.6 实战:完整用例(异常+验证)
data class Order(val id: Int, val amount: Double)
interface PaymentGateway {
suspend fun charge(order: Order): Boolean
}
class PaymentService(private val gateway: PaymentGateway) {
suspend fun processOrder(orderId: Int, amount: Double): Boolean {
val order = Order(orderId, amount)
return gateway.charge(order)
}
}
@Test
fun `test payment fails and verify`() = runBlockingTest {
val mockGateway = mockk<PaymentGateway>()
coEvery { mockGateway.charge(any()) } throws IllegalStateException("Insufficient funds")
val service = PaymentService(mockGateway)
assertFailsWith<IllegalStateException> { service.processOrder(1, 100.0) }
coVerify(exactly = 1) { mockGateway.charge(Order(1, 100.0)) }
}
@Test
fun `test payment success and verify multiple invocations`() = runBlockingTest {
val mockGateway = mockk<PaymentGateway>()
coEvery { mockGateway.charge(any()) } returns true
val service = PaymentService(mockGateway)
val result1 = service.processOrder(1, 50.0)
val result2 = service.processOrder(2, 75.0)
assertTrue(result1)
assertTrue(result2)
coVerify(exactly = 2) { mockGateway.charge(any()) }
}
稳定性注意
在持续集成环境中,若测试涉及网络或数据库的挂起函数mock,建议使用
在持续集成环境中,若测试涉及网络或数据库的挂起函数mock,建议使用
coEvery配合coAnswers模拟延迟或超时场景,验证协程的超时处理逻辑是否健壮。