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

使用最新coroutines-test API测试ViewModel时存在不稳定问题

协程测试中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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.06 12:21:03