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

MVI架构ViewModel测试:Flow Effect顺序不符合预期排查

问题分析:MVI ViewModel测试中Effect捕获不符合预期

背景

我正在测试基于MVI构建的IncreaseLimitViewModel,被测方法会调用API并返回Flow。测试用的Fake UseCase实现如下:

class FakeSendCreditLimitClaimUseCase: SendCreditLimitClaimUseCase {

    override suspend fun invoke(
        accessToken: String?,
        agreementNumber: String,
        newCreditLimit: String,
        totalIncome: String
    ): Flow<RepositoryState<CreditLimitChangeClaim>> {
        return flowOf(
            RepositoryState.Loading(),
            RepositoryState.Complete(
                CreditLimitChangeClaim(
                    status = "NEW",
                    createDate = "12 sept 1993",
                    title = "Claim is created",
                    text = "You will get info in 7 days",
                    agreementNumber = "123456",
                )
            )
        )
    }
}

ViewModel核心逻辑(简化后):

@HiltViewModel
class IncreaseLimitViewModel @Inject constructor(
    private val sendCreditLimitClaimUseCase: SendCreditLimitClaimUseCase,
) : BaseViewModel<IncreaseLimitState, IncreaseLimitActions, IncreaseLimitEffect>() {

    private val _effect: Channel<IncreaseLimitEffect> = Channel()
    val effect = _effect.receiveAsFlow()

    override suspend fun handleAction(event: IncreaseLimitActions) {
        when (event) {
            is SendClaimAction -> makeSendClaimRequest(event)
        }
    }

    override fun sendEffect(effect: Effect) {
        viewModelScope.launch {
            _effect.send(effect)
        }
    }

    private suspend fun makeSendClaimRequest(event: SendClaimAction) {
        val accessToken = sessionRepository.getAccessToken() ?: return
        sendCreditLimitClaimUseCase.invoke(
            accessToken = accessToken,
            agreementNumber = event.agreementNumber,
            newCreditLimit = event.newCreditLimit,
            totalIncome = event.totalIncome
        ).collect { repositoryState -> 
            when (repositoryState) {
                is RepositoryState.Loading -> {
                    sendEffect(SetLoadingVisibility(true))
                }
                is RepositoryState.Complete -> {
                    sendEffect(SetLoadingVisibility(false))
                    sendEffect(NavigateToStatusScreen(claimData = repositoryState.data))
                }
            }
        }
    }
}

根据makeSendClaimRequest逻辑,预期会产生3个连续Effect:
SetLoadingVisibility(true) → SetLoadingVisibility(false) → NavigateToStatusScreen(...)

但测试结果不符合预期,测试代码:

@Test
@DisplayName("Sending claim")
fun sendClaimTest2() = runTest(UnconfinedTestDispatcher()) {
    viewModel.sendAction(IncreaseLimitActions.SendClaimAction("123", "10000", "30000"))

    val firstItem =  viewModel.effect.first()
    // 符合预期:SetLoadingVisibility(visible=true)
    assert(firstItem is IncreaseLimitEffect.SetLoadingVisibility && firstItem.visible)

    val secondItem = viewModel.effect.drop(1).first()
    // 不符合预期:实际拿到的是NavigateToStatusScreen(....)
    assert(secondItem is IncreaseLimitEffect.SetLoadingVisibility && !secondItem.visible)
    // 未收到第三个Flow项
}

问题原因分析

  • Channel的冷流特性:Channel.receiveAsFlow()返回的是冷流,每次调用first()或drop().first()都会创建新的订阅。第一次first()拿到第一个Effect后,第二次订阅时Channel里剩下的是SetLoadingVisibility(false)和NavigateToStatusScreen(...),drop(1)会跳过当前订阅后的第一个元素,直接取第二个,所以拿到的是导航Effect。
  • 异步发送的调度问题:sendEffect用viewModelScope.launch异步发送Effect,在UnconfinedTestDispatcher下,异步任务的执行时机可能和预期不一致,导致Effect发送顺序在测试中出现混乱。
  • 多次订阅导致的Effect丢失:测试中多次单独订阅Flow,无法连贯捕获完整的Effect序列,中间的Effect会因为新订阅的特性被跳过。

解决方案

方案1:一次性收集所有Effect再验证

在测试中一次性收集所有预期的Effect,按顺序逐一验证:

@Test
@DisplayName("Sending claim")
fun sendClaimTest2() = runTest(UnconfinedTestDispatcher()) {
    viewModel.sendAction(IncreaseLimitActions.SendClaimAction("123", "10000", "30000"))

    // 收集所有预期的Effect(Fake UseCase固定发送3个,所以用take(3))
    val effects = viewModel.effect.take(3).toList()
    
    // 验证第一个Effect
    assert(effects[0] is IncreaseLimitEffect.SetLoadingVisibility && (effects[0] as IncreaseLimitEffect.SetLoadingVisibility).visible)
    // 验证第二个Effect
    assert(effects[1] is IncreaseLimitEffect.SetLoadingVisibility && !(effects[1] as IncreaseLimitEffect.SetLoadingVisibility).visible)
    // 验证第三个Effect
    assert(effects[2] is IncreaseLimitEffect.NavigateToStatusScreen)
    val navigateEffect = effects[2] as IncreaseLimitEffect.NavigateToStatusScreen
    assert(navigateEffect.claimData?.agreementNumber == "123456")
}

方案2:用SharedFlow替代Channel管理Effect

修改ViewModel的Effect实现,使用SharedFlow(配置replay参数保存历史发送的Effect),这样多次订阅也能获取完整序列:

private val _effect = MutableSharedFlow<IncreaseLimitEffect>(replay = 3) // 根据需求设置replay数量
val effect = _effect.asSharedFlow()

override fun sendEffect(effect: IncreaseLimitEffect) {
    viewModelScope.launch {
        _effect.emit(effect)
    }
}

方案3:临时同步发送Effect(仅测试场景用)

如果只是为了测试临时调整,可以把sendEffect改成同步发送,避免异步调度问题,但不推荐在生产代码中使用:

override fun sendEffect(effect: IncreaseLimitEffect) {
    runBlocking(viewModelScope.coroutineContext) {
        _effect.send(effect)
    }
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.24 14:47:05