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

