4、ViewModel单元测试:ViewModel生命周期与测试要点、测试LiveData/StateFlow、协程测试基础(runBlocking、TestCoroutineDispatcher)、模拟Repository层、验证UI状态更新。

好,咱们今天聊聊 ViewModel 的单元测试。

说实话,很多 Android 开发者写单元测试,最头疼的就是 ViewModel。为什么?因为 ViewModel 跟生命周期绑得太紧了,里面还塞了 LiveData、StateFlow、协程……一不留神就测出一堆莫名其妙的错误。

我个人习惯是:把 ViewModel 当成一个普通的 Kotlin 类来测。别被它那些花里胡哨的特性吓到,拆开了看,核心逻辑其实很单纯。

ViewModel 生命周期与测试要点

先说说生命周期。ViewModel 的生命周期比 Activity/Fragment 长,它会在配置变更时存活下来。但在单元测试里,我们不需要关心这个——因为测试环境里没有 Activity 重建。

测试 ViewModel 时,我一般直接 new 一个实例出来。比如:

class MyViewModelTest {
    private lateinit var viewModel: MyViewModel

    @Before
    fun setup() {
        viewModel = MyViewModel()
    }
}

嗯,这里要注意:不要在测试里调用 ViewModel 的 onCleared(),除非你明确想测试清理逻辑。我见过有人每个测试都手动调 onCleared(),结果把 LiveData 的观察者全清掉了,数据死活收不到。

避坑指南:我曾经在测试里忘记重置 ViewModel,导致多个测试用例共享同一个实例,LiveData 的 value 被污染。后来我养成了习惯:每个测试方法都用 @Before 重新创建 ViewModel。

测试 LiveData / StateFlow

LiveData 的测试有个经典套路:用 getOrAwaitValue() 或者 observeForever。我个人更推荐用 InstantTaskExecutorRule,它能让你同步执行 LiveData 的回调。

@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()

@Test
fun testLiveDataUpdate() {
    viewModel.loadData()
    val result = viewModel.userName.getOrAwaitValue()
    assertEquals("张三", result)
}

StateFlow 呢?它比 LiveData 更「冷」一些。测试 StateFlow 时,我习惯用 flow.take(1).first() 或者直接收集一次。但要注意:StateFlow 需要协程作用域,后面会讲。

小技巧:如果你用 StateFlow 替代 LiveData,记得在 ViewModel 里用 stateIn(scope, SharingStarted.WhileSubscribed(5000), initialValue)。这样测试时更容易控制订阅时机。

协程测试基础

协程测试是 ViewModel 单元测试的重头戏。说白了,就是让异步变同步。

最基础的工具是 runBlocking。它能在测试线程里阻塞等待协程完成。比如:

@Test
fun testCoroutineWithRunBlocking() = runBlocking {
    viewModel.fetchData()
    assertEquals("成功", viewModel.status.value)
}

runBlocking 有个问题:它用的是真实调度器,测试会很慢。所以我更推荐 TestCoroutineDispatcher(在 kotlinx-coroutines-test 1.6+ 里叫 StandardTestDispatcher)。

@OptIn(ExperimentalCoroutinesApi::class)
class MyViewModelTest {
    private val testDispatcher = TestCoroutineDispatcher()

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }

    @Test
    fun testWithTestDispatcher() = testDispatcher.runBlockingTest {
        viewModel.fetchData()
        assertEquals("成功", viewModel.status.value)
    }
}

为什么会这样?因为 TestCoroutineDispatcher 能让你手动控制时间,比如 advanceTimeBy()pauseDispatcher()。我曾在项目里用它测试一个延迟 3 秒的倒计时,直接 advanceTimeBy(3000) 就跳过去了,不用真的等 3 秒。

注意:从 kotlinx-coroutines-test 1.6 开始,TestCoroutineDispatcher 被标记为过时,推荐用 StandardTestDispatcher + runTest。但老项目里还是经常见到前者,你都得会。

模拟 Repository 层

ViewModel 通常依赖 Repository。测试时我们不能真的去访问网络或数据库,所以要用 Mock 框架。我个人习惯用 MockK,它跟 Kotlin 配合得最好。

class MyViewModelTest {
    private val mockRepo = mockk<UserRepository>()
    private lateinit var viewModel: MyViewModel

    @Before
    fun setup() {
        every { mockRepo.getUser() } returns flowOf(User("张三"))
        viewModel = MyViewModel(mockRepo)
    }

    @Test
    fun testLoadUser() = runBlockingTest {
        viewModel.loadUser()
        assertEquals("张三", viewModel.userName.getOrAwaitValue())
    }
}

这里有个关键点:Mock 的粒度要合适。我见过有人把 Repository 里的每个方法都 mock 了,结果测试代码比业务代码还长。其实你只需要 mock 当前 ViewModel 用到的那些方法就够了。

我的经验:如果 Repository 返回的是 Flow,尽量用 flowOf() 或者 kotlinx.coroutines.flow.MutableStateFlow 来模拟。这样测试里可以直接收集,不用额外处理异步。

验证 UI 状态更新

最后一步,验证 ViewModel 是否正确地更新了 UI 状态。通常我们用一个密封类来表示 UI 状态:

sealed class UiState {
    object Loading : UiState()
    data class Success(val data: String) : UiState()
    data class Error(val message: String) : UiState()
}

测试时,直接断言 LiveData 或 StateFlow 的值:

@Test
fun testLoadingState() {
    viewModel.loadData()
    assertTrue(viewModel.uiState.getOrAwaitValue() is UiState.Loading)
}

@Test
fun testSuccessState() {
    every { mockRepo.getData() } returns flowOf("测试数据")
    viewModel.loadData()
    val state = viewModel.uiState.getOrAwaitValue()
    assertTrue(state is UiState.Success)
    assertEquals("测试数据", (state as UiState.Success).data)
}

你想想看,如果 UI 状态更新错了,比如 Loading 之后直接跳到了 Error,那用户看到的就是一个错误页面。这种 bug 在单元测试里就能抓出来,不用等到跑 UI 测试。

核心要点:ViewModel 单元测试的本质是「输入 -> 输出」验证。输入是 Repository 的 Mock 数据,输出是 LiveData/StateFlow 的值。中间的逻辑(协程、状态转换)由测试框架帮你同步执行。

知识体系图

下面这张图总结了 ViewModel 单元测试的核心流程:

ViewModel 单元测试核心流程 测试用例入口 准备:Mock Repository + 创建 ViewModel 执行:调用 ViewModel 方法 验证:断言 LiveData/StateFlow 值 关键工具: • InstantTaskExecutorRule • TestCoroutineDispatcher • MockK / Mockito • runBlocking / runTest 核心原则:让异步变同步,让依赖变可控

这张图把整个测试流程串起来了。你写测试的时候,就按这个顺序来:准备 Mock -> 执行操作 -> 验证结果。别跳步,也别在验证阶段再去 Mock 数据。

最后说一句:ViewModel 单元测试写好了,你的 UI 层代码会稳很多。我见过太多项目,UI 层出 bug 了才回头查 ViewModel,结果发现是状态更新逻辑写错了。与其那样,不如一开始就把 ViewModel 测透。
公众号:蓝海资料掘金营,微信deep3321