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

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")

问题原因与解决方案

核心问题

  1. 时序与调度器特性冲突:使用UnconfinedTestDispatcher时,协程会在调用线程立即执行。调用doOperation()后,loading=true和loading=false两次状态发射会在awaitItem()调用前就完成。此时第一个awaitItem()会等待下一个未消费的状态值,但没有新值产生,导致超时。
  2. 断言逻辑错误:断言时使用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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.07 20:05:53