You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Mockk测试含suspendCoroutine的登录函数失败问题排查

解决suspendCoroutine改造后Mockk单元测试slot未初始化异常

问题背景

原有loginOld函数及对应Mockk单元测试运行正常,改用suspendCoroutine封装loginNew后,测试执行onResultLogin.captured.invoke(responseAuthorized)时抛出kotlin.UninitializedPropertyAccessException: lateinit property captured has not been initialized异常。

核心原因

  • loginOld是同步调用logInUseCase.execute,测试中的coJustRun能立刻捕获到onResult的slot;
  • loginNew通过viewModelScope.launch启动协程,且使用withContext(Dispatchers.IO)切换线程,测试中调用viewModel.loginNew()后,协程调度存在延迟,此时logInUseCase.execute还未被调用,slot自然未被捕获,直接访问captured就会触发未初始化异常。

解决方案

1. 改造ViewModel:注入可测试的调度器

将硬编码的Dispatchers.IO改为依赖注入,测试时替换为StandardTestDispatcher,确保协程执行完全受测试控制。

class LoginViewModel(
    private val logInUseCase: LogInUseCase,
    // 注入IO调度器,默认用Dispatchers.IO
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
    private val viewModelScope: CoroutineScope = this.viewModelScope
) : ViewModel() {
    fun loginNew() {
        viewModelScope.launch {
            val loginParams = LogInUseCase.Params("email", "password")
            val loginResult = withContext(ioDispatcher) { loginUseCase(loginParams) }
            // 处理登录结果
        }
    }

    private suspend fun loginUseCase(params: LogInUseCase.Params): AuthorizedResponse {
        return suspendCoroutine { continuation ->
            logInUseCase.execute(
                params, 
                viewModelScope, 
                showErrorOnUI = false,
                onResult = { authorizedResponse ->
                    continuation.resume(authorizedResponse)
                }
            )
        }
    }
}

2. 调整测试逻辑:确保协程执行完成后再访问slot

在测试中使用runTest的advanceUntilIdle()方法,强制等待所有协程任务执行完毕,确保logInUseCase.execute被调用、slot被捕获后,再执行后续断言。

@Test
fun `Test login`() = runTest {
    // 创建测试专用调度器
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val viewModel = LoginViewModel(
        logInUseCase = mockk(),
        ioDispatcher = testDispatcher,
        viewModelScope = CoroutineScope(testDispatcher)
    )

    val onResultLogin = slot<(AuthorizedResponse?) -> Unit>()
    coJustRun {
        logInUseCase.execute(
            any(),
            any(),
            any(),
            onResult = capture(onResultLogin),
            onError = any()
        )
    }

    // 触发登录
    viewModel.loginNew()
    // 等待所有协程执行到Idle状态,此时logInUseCase.execute已被调用,slot完成捕获
    advanceUntilIdle()

    // 构造测试响应并触发回调
    val responseAuthorized = AuthorizedResponse(
        "access"
        // 填充其他必要字段
    )
    onResultLogin.captured.invoke(responseAuthorized)

    // 等待回调处理完成
    advanceUntilIdle()

    // 验证useCase调用次数
    coVerify(exactly = 1) {
        logInUseCase.execute(
            any(),
            any(),
            any(),
            onResult = any(), // 无需再次capture,避免覆盖原有slot
            onError = any()
        )
    }
}

关键修改点

  • 用StandardTestDispatcher统一ViewModel和测试的协程调度,消除线程切换带来的异步问题;
  • 调用loginNew()后立即执行advanceUntilIdle(),确保协程执行到调用logInUseCase.execute的步骤;
  • coVerify中不再重复capture(onResultLogin),避免覆盖已捕获的slot,改用any()即可验证调用逻辑。

内容的提问来源于stack exchange,提问作者Alexey Simchenko

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 02:32:49