27、ViewModel单元测试:状态管理测试、业务逻辑测试、LiveData测试

ViewModel 的单元测试,说白了就是验证你的「状态机」和「业务逻辑」是否按预期工作。我在项目中见过太多人只测 UI,结果 ViewModel 里藏着各种边界 bug,等到集成测试才发现,那成本可就高了。

今天我们就来聊聊怎么把 ViewModel 测透。我会从状态管理、业务逻辑、LiveData 三个维度展开,每个点都带上我踩过的坑。

为什么 ViewModel 值得单独测?

你想想看,ViewModel 是 UI 和数据的桥梁。它不依赖 Android 组件(比如 Activity、Fragment),所以纯 JVM 测试就能跑。这意味着:

  • 速度快:不用启动模拟器,毫秒级反馈
  • 隔离性好:只测逻辑,不测 UI 渲染
  • 容易定位:失败时直接知道是状态机问题,还是数据流问题

我个人习惯把 ViewModel 测试放在「单元测试」层级,而不是「集成测试」。因为它的依赖(比如 Repository)都可以 mock 掉,测试起来非常干净。

状态管理测试:验证 StateFlow 的正确性

现在 Compose 项目里,ViewModel 通常用 StateFlowMutableStateFlow 来管理 UI 状态。测试的核心就是:给输入,验证输出状态

举个例子,一个简单的计数器 ViewModel:

class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CounterUiState())
    val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()

    fun increment() {
        _uiState.update { it.copy(count = it.count + 1) }
    }

    fun decrement() {
        _uiState.update { it.copy(count = it.count - 1) }
    }
}

data class CounterUiState(val count: Int = 0)

测试怎么写?我建议用 kotlinx-coroutines-test 提供的 runTest

class CounterViewModelTest {

    @Test
    fun `increment should increase count by 1`() = runTest {
        val viewModel = CounterViewModel()

        viewModel.increment()

        val state = viewModel.uiState.first()
        assertEquals(1, state.count)
    }

    @Test
    fun `decrement should decrease count by 1`() = runTest {
        val viewModel = CounterViewModel()

        viewModel.decrement()

        val state = viewModel.uiState.first()
        assertEquals(-1, state.count)
    }
}
小技巧:用 .first() 获取 StateFlow 的当前值,它会等待第一个值发射。如果你只想取快照,可以用 .value,但 .first() 更安全,能确保协程执行完毕。

我曾经在项目里犯过一个错:直接用 .value 取值,结果因为协程还没执行完,拿到的是旧状态。后来统一改用 .first(),再也没出过这种问题。

业务逻辑测试:复杂场景的验证

业务逻辑测试,说白了就是测「if-else 分支」和「数据转换」。比如一个登录 ViewModel:

class LoginViewModel(
    private val authRepository: AuthRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    fun login(username: String, password: String) {
        if (username.isBlank() || password.isBlank()) {
            _uiState.update { it.copy(error = "用户名或密码不能为空") }
            return
        }

        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val result = authRepository.login(username, password)
                _uiState.update { it.copy(isLoading = false, isSuccess = result) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, error = e.message) }
            }
        }
    }
}

测试时,我们需要覆盖三个分支:

  1. 输入校验失败:空用户名或密码
  2. 登录成功:Repository 返回 true
  3. 登录失败:Repository 抛出异常

代码示例:

class LoginViewModelTest {

    private val authRepository = mock<AuthRepository>()

    @Test
    fun `login with empty username should show error`() = runTest {
        val viewModel = LoginViewModel(authRepository)

        viewModel.login("", "password")

        val state = viewModel.uiState.first()
        assertEquals("用户名或密码不能为空", state.error)
        assertFalse(state.isLoading)
    }

    @Test
    fun `login with valid credentials should succeed`() = runTest {
        `when`(authRepository.login("user", "pass")).thenReturn(true)
        val viewModel = LoginViewModel(authRepository)

        viewModel.login("user", "pass")

        val state = viewModel.uiState.first { !it.isLoading }
        assertTrue(state.isSuccess)
        assertFalse(state.isLoading)
    }

    @Test
    fun `login with network error should show error message`() = runTest {
        `when`(authRepository.login("user", "pass")).thenThrow(RuntimeException("网络异常"))
        val viewModel = LoginViewModel(authRepository)

        viewModel.login("user", "pass")

        val state = viewModel.uiState.first { !it.isLoading }
        assertEquals("网络异常", state.error)
        assertFalse(state.isLoading)
    }
}
注意:测试协程时,记得在 runTest 块里使用 first { predicate } 来等待特定状态。直接 .first() 可能拿到中间状态(比如 isLoading=true),导致断言失败。

嗯,这里要注意:viewModelScope 在测试中默认使用 UnconfinedTestDispatcher,所以协程会立即执行。但如果你用了 Dispatchers.IO 切换线程,就需要用 StandardTestDispatcher 手动控制时间。

LiveData 测试:老项目的兼容方案

虽然现在新项目都用 StateFlow,但很多老项目还在用 LiveData。测试 LiveData 需要一点额外工作:

  • 使用 InstantTaskExecutorRule 让 LiveData 同步执行
  • getOrAwaitValue() 扩展函数获取值

示例 ViewModel:

class OldCounterViewModel : ViewModel() {
    private val _count = MutableLiveData(0)
    val count: LiveData<Int> = _count

    fun increment() {
        _count.value = (_count.value ?: 0) + 1
    }
}

测试代码:

class OldCounterViewModelTest {

    @get:Rule
    val rule = InstantTaskExecutorRule()

    @Test
    fun `increment should increase count`() {
        val viewModel = OldCounterViewModel()

        viewModel.increment()

        assertEquals(1, viewModel.count.getOrAwaitValue())
    }
}

getOrAwaitValue() 是一个常用扩展,实现如下:

fun <T> LiveData<T>.getOrAwaitValue(): T {
    var value: T? = null
    val latch = CountDownLatch(1)
    val observer = Observer<T> { v ->
        value = v
        latch.countDown()
    }
    observeForever(observer)
    latch.await(2, TimeUnit.SECONDS)
    removeObserver(observer)
    return value!!
}
核心要点:LiveData 测试的关键是「同步化」。InstantTaskExecutorRule 会把主线程上的任务立即执行,避免异步等待。

我个人建议:新项目直接用 StateFlow,LiveData 只用于遗留代码。因为 StateFlow 在 Compose 里天然支持,测试也更简洁。

知识体系总览

下面这张图总结了 ViewModel 测试的三个维度:

ViewModel 单元测试知识体系 ViewModel 测试 状态管理测试 业务逻辑测试 LiveData 测试 StateFlow 值验证 状态转换测试 协程同步 分支覆盖 Mock Repository 异常处理 InstantTaskExecutorRule getOrAwaitValue 遗留项目兼容 核心原则:隔离依赖、同步执行、覆盖分支、验证状态 推荐使用 StateFlow + runTest 组合,兼顾简洁与性能

避坑指南

最后分享几个我踩过的坑:

  • 忘记加 @get:Rule:测试 LiveData 时,如果没加 InstantTaskExecutorRule,会报主线程异常。我曾经排查了半小时才发现。
  • StateFlow 的初始值:StateFlow 总是有初始值,所以 .first() 不会挂起。但如果你用 .drop(1).first() 跳过初始值,记得确认协程已经发射了新值。
  • Mock 的粒度:不要 mock 整个 ViewModel,只 mock 它的依赖(比如 Repository)。否则你测的是 mock 框架,不是业务逻辑。
  • 协程超时:在 runTest 里,如果协程死循环或死锁,测试会一直挂起。建议设置超时时间,比如 @Test(timeout = 2000)

好了,ViewModel 测试的核心就这些。记住:状态管理测「值」,业务逻辑测「分支」,LiveData 测「同步」。把这三点做好,你的 ViewModel 质量就有保障了。

公众号:蓝海资料掘金营,微信deep3321