ViewModel单元测试(JUnit5、CoroutineDispatcher、Turbine、Mockk)不符合预期
Turbine与StateFlow配合测试的问题排查
我正在尝试理解Turbine与StateFlow的配合使用方式,但编写的测试代码报错,只有修改写法后才能成功,特此寻求帮助。
相关业务代码
HelloWorldViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class HelloWorldViewModel( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val doOperationUseCase: DoOperationUseCase ) : ViewModel() { private val _state: MutableStateFlow<HelloWorldState> = MutableStateFlow(HelloWorldState()) val state: StateFlow<HelloWorldState> = _state.asStateFlow() fun doOperation() { viewModelScope.launch(dispatcher) { _state.emit(state.value.copy(loading = true)) doOperationUseCase() _state.emit(state.value.copy(loading = false)) } } }
HelloWorldState
data class HelloWorldState(val loading: Boolean = false)
DoOperationUseCase
class DoOperationUseCase { suspend operator fun invoke(): Result<List<String>> { delay(500) return Result.success(listOf("1")) } }
初始测试代码(报错版本)
import app.cash.turbine.test import io.mockk.coEvery import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension 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.AfterEach import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockKExtension::class) class HelloWorldViewModelTest { private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() @MockK lateinit var doOperationUseCase: DoOperationUseCase private lateinit var classUnderTest: HelloWorldViewModel @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) classUnderTest = HelloWorldViewModel(testDispatcher, doOperationUseCase) } @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test fun doOperation() = runTest { coEvery { doOperationUseCase() } returns Result.success(listOf("")) assertEquals(HelloWorldState(), classUnderTest.state.value) // 初始状态校验 classUnderTest.state.test { classUnderTest.doOperation() assertEquals(classUnderTest.state.value.copy(loading = true), awaitItem()) // 预期调用UseCase前的状态 assertEquals(classUnderTest.state.value.copy(loading = false), awaitItem()) // 预期调用UseCase后的状态 } } }
报错信息
No value produced in 3s app.cash.turbine.TurbineAssertionError: No value produced in 3s
修改后可行的测试代码
@Test fun doOperation() = runTest { coEvery { doOperationUseCase() } returns Result.success(listOf("")) classUnderTest.state.test { classUnderTest.doOperation() assertEquals(classUnderTest.state.value.copy(loading = false), awaitItem()) } }
依赖配置
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") testImplementation("org.junit.platform:junit-platform-console:1.8.2") testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.2") testImplementation("app.cash.turbine:turbine:1.0.0") testImplementation("io.mockk:mockk:1.13.8") testImplementation("com.google.truth:truth:1.1.4")
问题原因与解决方案
核心问题
- 时序与调度器特性冲突:使用
UnconfinedTestDispatcher时,协程会在调用线程立即执行。调用doOperation()后,loading=true和loading=false两次状态发射会在awaitItem()调用前就完成。此时第一个awaitItem()会等待下一个未消费的状态值,但没有新值产生,导致超时。 - 断言逻辑错误:断言时使用
classUnderTest.state.value.copy(...),但此时state.value已经是最新的状态(比如已经变为loading=false),导致断言的预期值与实际发射值不匹配。
修正方案
方案1:直接断言预期状态实例
避免依赖当前state.value,直接创建预期的HelloWorldState实例进行断言,同时正确处理StateFlow的初始值:
@Test fun doOperation() = runTest { coEvery { doOperationUseCase() } returns Result.success(listOf("")) classUnderTest.state.test { // 先获取初始状态 assertEquals(HelloWorldState(), awaitItem()) classUnderTest.doOperation() // 断言加载中状态 assertEquals(HelloWorldState(loading = true), awaitItem()) // 断言加载完成状态 assertEquals(HelloWorldState(loading = false), awaitItem()) } }
方案2:改用StandardTestDispatcher控制时序
使用StandardTestDispatcher可以手动推进协程执行,确保状态发射与断言的时序一致:
@OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(MockKExtension::class) class HelloWorldViewModelTest { private val testDispatcher: TestDispatcher = StandardTestDispatcher() @MockK lateinit var doOperationUseCase: DoOperationUseCase private lateinit var classUnderTest: HelloWorldViewModel @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) classUnderTest = HelloWorldViewModel(testDispatcher, doOperationUseCase) } @AfterEach fun tearDown() { Dispatchers.resetMain() } @Test fun doOperation() = runTest(testDispatcher) { coEvery { doOperationUseCase() } returns Result.success(listOf("")) classUnderTest.state.test { classUnderTest.doOperation() // 推进时间到加载中状态发射完成 advanceUntilIdle() assertEquals(HelloWorldState(loading = true), awaitItem()) // 推进时间到加载完成状态发射完成 advanceUntilIdle() assertEquals(HelloWorldState(loading = false), awaitItem()) } } }
方案3:调整测试逻辑,忽略初始值
如果不需要校验初始值,可以直接断言后续的状态变化,同时注意Turbine会捕获所有发射的新值:
@Test fun doOperation() = runTest { coEvery { doOperationUseCase() } returns Result.success(listOf("")) classUnderTest.state.test { classUnderTest.doOperation() // 跳过初始值,直接获取第一个状态变化 skipItems(1) assertEquals(HelloWorldState(loading = true), awaitItem()) assertEquals(HelloWorldState(loading = false), awaitItem()) } }
内容的提问来源于stack exchange,提问作者Ibrahim Disouki
相关产品推荐
相关产品推荐

