使用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
相关产品推荐
相关产品推荐

