测试中如何收集StateFlow全部发射值?解决协程未完成异常
问题分析与解决方案
问题根源
测试失败的核心原因有两点:
- ViewModel中硬编码使用
Dispatchers.IO,测试时未替换为测试调度器,导致这部分协程脱离runTest的管控,断言执行时状态更新逻辑还未完成。 - 测试代码未等待状态流的所有发射动作完成就执行断言,同时未被管控的后台协程最终触发
UncompletedCoroutinesError。
修复方案
1. 改造ViewModel,支持调度器注入(推荐)
将调度器作为参数传入ViewModel,避免硬编码,方便测试时替换:
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class TestViewmodel( private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ): ViewModel() { private val _event = MutableSharedFlow<TestEvent>() private val _state = MutableStateFlow(TestState()) val state = _state.asStateFlow() init { viewModelScope.launch { _event.collect{ handleEvent(it) } } } private fun handleEvent(event: TestEvent){ if(event == TestEvent.GetData) getTestData() } private fun getTestData(){ viewModelScope.launch(ioDispatcher){ _state.update { it.copy(loading = true) } val data = getApiData() _state.update { it.copy(loading = false) } handleApiData(data) } } private fun handleApiData(list: List<String>){ _state.update { it.copy(data = list) } } private suspend fun getApiData(): List<String>{ delay(1000) return listOf("ffefe","ffefe","fdfdfd") } fun setEvent(event: TestEvent){ viewModelScope.launch { _event.emit(event) } } } data class TestState( val loading: Boolean = false, val data: List<String> = emptyList() ) sealed interface TestEvent{ data object GetData: TestEvent }
2. 修复测试代码,用TestDispatcher管控所有协程
将所有Dispatchers替换为测试调度器,确保runTest能等待所有协程任务完成:
import io.kotest.matchers.shouldBe import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @OptIn(ExperimentalCoroutinesApi::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) class TestViewModelTest(){ private lateinit var viewModel: TestViewmodel private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() @BeforeAll fun setUp(){ Dispatchers.setMain(testDispatcher) viewModel = TestViewmodel(ioDispatcher = testDispatcher) } @AfterAll fun afterAll(){ Dispatchers.resetMain() } @Test fun testEmit() = runTest { viewModel.setEvent(TestEvent.GetData) // 跳过初始状态+2次中间更新,取最终状态 val result = viewModel.state.drop(3).first() result.data.size shouldBe 3 } @Test fun testEmitWithTurbine() = runTest { viewModel.state.test { viewModel.setEvent(TestEvent.GetData) awaitItem() // 跳过初始状态 awaitItem().loading shouldBe true // 验证loading启动 awaitItem().loading shouldBe false // 验证loading结束 awaitItem().data.size shouldBe 3 // 验证数据填充完成 } } @Test fun testEmitByGoogle() = runTest { val result = mutableListOf<TestState>() val collectionJob = launch { viewModel.state.collect { result.add(it) } } viewModel.setEvent(TestEvent.GetData) testDispatcher.scheduler.advanceUntilIdle() // 等待所有协程任务完成 result.last().data.size shouldBe 3 collectionJob.cancel() } }
3. 关键注意事项
- 全调度器替换:必须替换ViewModel中用到的所有Dispatcher(包括IO),否则后台协程会脱离测试管控。
advanceUntilIdle():强制测试调度器执行完所有待处理任务,确保状态更新全部完成后再执行断言。- 状态发射次数:初始状态是第一次发射,后续每次
update都会产生新的发射值,总共会有4次发射(初始+3次更新)。
内容的提问来源于stack exchange,提问作者Andreas1234
相关产品推荐
相关产品推荐

