27、ViewModel单元测试:状态管理测试、业务逻辑测试、LiveData测试
ViewModel 的单元测试,说白了就是验证你的「状态机」和「业务逻辑」是否按预期工作。我在项目中见过太多人只测 UI,结果 ViewModel 里藏着各种边界 bug,等到集成测试才发现,那成本可就高了。
今天我们就来聊聊怎么把 ViewModel 测透。我会从状态管理、业务逻辑、LiveData 三个维度展开,每个点都带上我踩过的坑。
为什么 ViewModel 值得单独测?
你想想看,ViewModel 是 UI 和数据的桥梁。它不依赖 Android 组件(比如 Activity、Fragment),所以纯 JVM 测试就能跑。这意味着:
- 速度快:不用启动模拟器,毫秒级反馈
- 隔离性好:只测逻辑,不测 UI 渲染
- 容易定位:失败时直接知道是状态机问题,还是数据流问题
我个人习惯把 ViewModel 测试放在「单元测试」层级,而不是「集成测试」。因为它的依赖(比如 Repository)都可以 mock 掉,测试起来非常干净。
状态管理测试:验证 StateFlow 的正确性
现在 Compose 项目里,ViewModel 通常用 StateFlow 或 MutableStateFlow 来管理 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) }
}
}
}
}
测试时,我们需要覆盖三个分支:
- 输入校验失败:空用户名或密码
- 登录成功:Repository 返回 true
- 登录失败: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!!
}
InstantTaskExecutorRule 会把主线程上的任务立即执行,避免异步等待。
我个人建议:新项目直接用 StateFlow,LiveData 只用于遗留代码。因为 StateFlow 在 Compose 里天然支持,测试也更简洁。
知识体系总览
下面这张图总结了 ViewModel 测试的三个维度:
避坑指南
最后分享几个我踩过的坑:
- 忘记加
@get:Rule:测试 LiveData 时,如果没加InstantTaskExecutorRule,会报主线程异常。我曾经排查了半小时才发现。 - StateFlow 的初始值:StateFlow 总是有初始值,所以
.first()不会挂起。但如果你用.drop(1).first()跳过初始值,记得确认协程已经发射了新值。 - Mock 的粒度:不要 mock 整个 ViewModel,只 mock 它的依赖(比如 Repository)。否则你测的是 mock 框架,不是业务逻辑。
- 协程超时:在
runTest里,如果协程死循环或死锁,测试会一直挂起。建议设置超时时间,比如@Test(timeout = 2000)。
好了,ViewModel 测试的核心就这些。记住:状态管理测「值」,业务逻辑测「分支」,LiveData 测「同步」。把这三点做好,你的 ViewModel 质量就有保障了。