第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) }
}
注意事项
• 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功能对比

特性coEverycoVerify
目的设置挂起函数的行为(打桩)断言挂起函数被调用情况
运行时机测试执行前(配置阶段)测试执行后(验证阶段)
语法coEvery { } returns/throws/answerscoVerify { } + 可选参数
是否依赖协程作用域否(但在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、打桩、执行到验证的协程测试路径:

挂起函数Mock测试流程 定义包含suspend函数的接口/类 使用 mockk<T>() 创建Mock对象 coEvery { mock.suspendFun() } returns/throws (配置预期的返回值或异常) 在 runBlocking / runBlockingTest 中执行被测逻辑 (触发挂起函数调用) coVerify { mock.suspendFun() } 断言调用 clearMocks / unmockkAll 清理 关键:coEvery(打桩)不要求协程作用域,coVerify(验证)必须在协程作用域内

4.5 常见错误与调试

  1. “No overloads for ‘coEvery’ with these arguments”
    原因:目标函数不是suspend函数。检查函数声明中是否包含suspend关键字。
  2. “coVerify requires coroutine context”
    解决方法:将断言移动到runBlockingTest或runBlocking块内。
  3. “验证通过但实际并未调用”
    常见于coEvery中使用了any()但验证时用精确参数。使用coVerify { mock.suspendFun(any()) }匹配任意参数。
  4. 挂起函数返回值类型为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,建议使用coEvery配合coAnswers模拟延迟或超时场景,验证协程的超时处理逻辑是否健壮。