使用最新coroutines-test API测试ViewModel时存在不稳定问题
问题描述
我用最新的coroutines-test API编写ViewModel测试用例,但结果不稳定:正常运行时常失败,调试模式下却能通过。测试代码如下:
@Test fun `when balanceOf() is called with existing parameter model state is updated with correct value`() = runTest { Dispatchers.setMain(StandardTestDispatcher()) fakeWalletRepository.setPositiveBalanceOfResponse() assertThat("Model balance is not default", subj.uiState.value.wallet.getBalance().toInt() == 0) assertThat("Errors queue is not empty", subj.uiState.value.errors.isEmpty()) assertThat("State is not default", subj.uiState.value.status == Status.NONE) subj.balanceOf("0x6f1d841afce211dAead45e6109895c20f8ee92f0") advanceUntilIdle() assertThat("Model balance is not updated with correct value", subj.uiState.value.wallet.getBalance().toLong() == 42L) assertThat("Errors queue is not empty", subj.uiState.value.errors.isEmpty()) assertThat("State is not set as BALANCE", subj.uiState.value.status == Status.BALANCE) }
按我的理解,StandardTestDispatcher在调用advanceUntilIdle前不会执行协程,UnconfinedTestDispatcher会立即执行。advanceUntilIdle应该会等待所有协程执行完毕,但测试里的assertThat好像存在竞态条件,导致结果不确定。
我以为advanceUntilIdle能保证所有协程完成工作,那竞态条件会不会出现在.collect{}或者state.update {}调用里?(我觉得advanceUntilIdle也应该等这些操作完成)
相关ViewModel代码:
fun balanceOf(owner: String) { logger.d("[start] balanceOf()") viewModelScope.launch { repository.balanceOf(owner) .flowOn(Dispatchers.IO) .collect { value -> logger.d("collect get balance result") processBalanceOfResponse(value) } } logger.d("[end] balanceOf()") }
状态更新代码:
state.update { it.wallet.setBalance(value.data) it.copy(wallet = it.wallet, status = Status.BALANCE) }
问题根源与修复方案
1. 核心问题分析
测试不稳定的根本原因有两个:
- 调度器配置混乱:你在
runTest内部手动设置Dispatchers.setMain(StandardTestDispatcher()),但runTest自带的测试调度器和你手动创建的不是同一个实例,advanceUntilIdle只能控制前者,导致协程调度不受控。 - 真实线程未被拦截:
flowOn(Dispatchers.IO)会把流的处理切换到真实IO线程,advanceUntilIdle无法等待真实线程的任务完成——调试模式下线程执行慢,测试代码有足够时间等结果,所以能通过;正常运行时IO线程任务还没完成,断言就执行了,导致失败。
2. 修复步骤
(1)统一配置测试调度器
不要在runTest内部手动设置主调度器,而是提前替换所有调度器为测试用实例:
private val testDispatcher = StandardTestDispatcher() @Before fun setup() { // 替换主调度器和IO调度器 Dispatchers.setMain(testDispatcher) Dispatchers.IO.setDispatcher(testDispatcher) } @After fun teardown() { // 恢复默认调度器 Dispatchers.resetMain() Dispatchers.IO.resetDispatcher() } @Test fun `when balanceOf() is called with existing parameter model state is updated with correct value`() = runTest(testDispatcher) { fakeWalletRepository.setPositiveBalanceOfResponse() // 初始状态断言 assertThat("Model balance is not default", subj.uiState.value.wallet.getBalance().toInt() == 0) assertThat("Errors queue is not empty", subj.uiState.value.errors.isEmpty()) assertThat("State is not default", subj.uiState.value.status == Status.NONE) subj.balanceOf("0x6f1d841afce211dAead45e6109895c20f8ee92f0") advanceUntilIdle() // 结果断言 assertThat("Model balance is not updated with correct value", subj.uiState.value.wallet.getBalance().toLong() == 42L) assertThat("Errors queue is not empty", subj.uiState.value.errors.isEmpty()) assertThat("State is not set as BALANCE", subj.uiState.value.status == Status.BALANCE) }
(2)优化ViewModel的协程写法
把flowOn(Dispatchers.IO)移到仓库层,ViewModel里不需要额外指定线程切换——仓库层的IO操作应该自己用withContext(Dispatchers.IO)处理,这样ViewModel的流调度完全受测试调度器控制:
// ViewModel中的balanceOf方法 fun balanceOf(owner: String) { logger.d("[start] balanceOf()") viewModelScope.launch { repository.balanceOf(owner) .collect { value -> logger.d("collect get balance result") processBalanceOfResponse(value) } } logger.d("[end] balanceOf()") } // 仓库层示例 suspend fun balanceOf(owner: String): Flow<Result<Balance>> = withContext(Dispatchers.IO) { // 实际的IO操作逻辑 }
(3)修正状态更新逻辑
如果wallet是可变对象,it.wallet.setBalance(value.data)直接修改对象的操作可能导致状态更新不被感知——即使调用了it.copy,但wallet还是原来的实例,状态变化不会触发通知。建议把wallet改成不可变数据类:
// 定义不可变的Wallet数据类 data class Wallet(val balance: Long = 0) { fun withBalance(newBalance: Long) = copy(balance = newBalance) } // 状态更新代码改为 state.update { it.copy( wallet = it.wallet.withBalance(value.data), status = Status.BALANCE ) }
这样每次更新都会生成新的Wallet实例,状态变化能被正确捕获,测试断言也不会因为对象可变出现异常。
内容的提问来源于stack exchange,提问作者Joe Dow

