18、ViewModel的测试:测试ViewModel逻辑、Mock依赖、验证LiveData输出
说实话,很多开发者写ViewModel的时候挺爽的,一到写测试就头疼。我见过不少项目,ViewModel里逻辑堆得满满的,但测试覆盖率几乎是零。你想想看,没有测试的ViewModel,就像没系安全带的赛车手——跑得快,但出事也快。
今天我们就来聊聊怎么给ViewModel写测试。我会从最基础的环境搭建开始,到Mock依赖、验证LiveData输出,一步步带你走通。嗯,这里要注意,测试不是写给别人看的,是写给自己用的。
为什么ViewModel需要测试?
我个人习惯把ViewModel看作是UI和数据的"中间人"。它负责处理业务逻辑、管理UI状态。如果这个中间人出了问题,整个页面都会崩。我在项目中遇到过好几次,因为ViewModel里一个简单的条件判断写反了,导致线上用户数据展示错误。从那以后,我就把ViewModel测试列为了必选项。
核心观点:ViewModel的测试,本质上是在验证"输入→处理→输出"这条链路是否正确。输入是用户操作或数据源变化,处理是业务逻辑,输出是LiveData暴露的状态。
测试环境搭建
首先,你需要在build.gradle里加上测试依赖。这里我推荐用JUnit 4 + Mockito + Arch Testing。为什么选这三件套?因为JUnit负责跑测试,Mockito负责模拟依赖,Arch Testing负责验证LiveData和协程的行为。
// app/build.gradle
dependencies {
// 单元测试
testImplementation "junit:junit:4.13.2"
testImplementation "org.mockito:mockito-core:4.6.1"
testImplementation "org.mockito:mockito-inline:4.6.1"
// Arch Testing - 用于测试LiveData和协程
testImplementation "androidx.arch.core:core-testing:2.1.0"
// 协程测试
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4"
}
这里有个坑,我提醒一下:mockito-inline 这个库是用来支持mock final类的。Kotlin的类默认是final的,不加这个你mock不了。我曾经因为这个折腾了半天,最后发现是依赖没加全。
测试ViewModel的核心思路
说白了,测试ViewModel就三步:
- 准备依赖——把Repository、UseCase等外部依赖mock掉
- 执行操作——调用ViewModel的方法,模拟用户点击或数据加载
- 验证输出——检查LiveData的值是否符合预期
我们来看一个具体的例子。假设有一个简单的登录ViewModel:
class LoginViewModel(
private val loginRepository: LoginRepository
) : ViewModel() {
private val _loginState = MutableLiveData<LoginUiState>()
val loginState: LiveData<LoginUiState> = _loginState
fun login(username: String, password: String) {
viewModelScope.launch {
_loginState.value = LoginUiState.Loading
try {
val result = loginRepository.login(username, password)
_loginState.value = LoginUiState.Success(result)
} catch (e: Exception) {
_loginState.value = LoginUiState.Error(e.message ?: "登录失败")
}
}
}
}
sealed class LoginUiState {
object Loading : LoginUiState()
data class Success(val token: String) : LoginUiState()
data class Error(val message: String) : LoginUiState()
}
这个ViewModel的逻辑很清晰:调用login方法,先发Loading状态,然后请求网络,成功返回Success,失败返回Error。我们要测试的就是这个流程。
编写测试代码
先看完整的测试代码,我再逐行解释:
class LoginViewModelTest {
// 使用JUnit的Rule来管理LiveData的异步行为
@get:Rule
val archRule = InstantTaskExecutorRule()
// 模拟依赖
@Mock
private lateinit var loginRepository: LoginRepository
private lateinit var viewModel: LoginViewModel
@Before
fun setup() {
MockitoAnnotations.openMocks(this)
viewModel = LoginViewModel(loginRepository)
}
@Test
fun `login with valid credentials should return success`() = runTest {
// 准备:mock登录成功
val mockToken = "test_token_123"
`when`(loginRepository.login("admin", "123456"))
.thenReturn(mockToken)
// 执行:调用登录
viewModel.login("admin", "123456")
// 验证:先检查Loading状态
assertThat(viewModel.loginState.value).isInstanceOf(LoginUiState.Loading::class.java)
// 等待协程执行完毕
advanceUntilIdle()
// 验证:再检查Success状态
val state = viewModel.loginState.value
assertThat(state).isInstanceOf(LoginUiState.Success::class.java)
assertThat((state as LoginUiState.Success).token).isEqualTo(mockToken)
}
@Test
fun `login with invalid credentials should return error`() = runTest {
// 准备:mock登录失败
`when`(loginRepository.login("wrong", "wrong"))
.thenThrow(RuntimeException("用户名或密码错误"))
// 执行
viewModel.login("wrong", "wrong")
// 等待协程
advanceUntilIdle()
// 验证
val state = viewModel.loginState.value
assertThat(state).isInstanceOf(LoginUiState.Error::class.java)
assertThat((state as LoginUiState.Error).message).isEqualTo("用户名或密码错误")
}
}
关键点解析
1. InstantTaskExecutorRule
这个Rule是Arch Testing提供的。它的作用是让LiveData的更新变成同步操作。如果没有它,LiveData的postValue会在主线程上异步执行,测试里拿不到最新的值。我刚开始写测试时没加这个,结果死活验证不过,后来才发现是异步的问题。
2. runTest 和 advanceUntilIdle
这两个是协程测试的核心。runTest会创建一个测试环境,让协程可以同步执行。advanceUntilIdle会等待所有协程任务完成。为什么要用这个?因为ViewModel里用了viewModelScope.launch,如果不等待,测试代码跑完了协程还没执行完,你拿到的LiveData值还是旧的。
3. Mockito的when和thenReturn
这里mock了loginRepository的login方法。当传入特定参数时,返回预设的值或抛出异常。这样我们就能控制测试场景,不需要真的去调网络接口。
小技巧:测试方法名建议用反引号括起来,写成自然语言的形式。比如 `login with valid credentials should return success`。这样测试失败时,错误信息一看就懂,不用再去猜这个testLogin1是测什么的。
测试LiveData的多种方式
除了直接拿value,还有几种常用的验证方式:
| 方式 | 适用场景 | 示例 |
|---|---|---|
| 直接取值 | 同步更新,或使用InstantTaskExecutorRule | viewModel.loginState.value |
| Observer观察 | 需要验证多次状态变化 | viewModel.loginState.observeForever { ... } |
| getOrAwaitValue | 需要等待异步结果 | 自定义扩展函数,内部用CountDownLatch |
我个人最常用的是第一种,配合InstantTaskExecutorRule,简单直接。但如果要验证状态变化的顺序(比如先Loading再Success),就需要用Observer来记录了。
@Test
fun `login should emit loading then success`() = runTest {
// 准备
val states = mutableListOf<LoginUiState>()
val observer = Observer<LoginUiState> { states.add(it) }
viewModel.loginState.observeForever(observer)
`when`(loginRepository.login("admin", "123456")).thenReturn("token")
// 执行
viewModel.login("admin", "123456")
advanceUntilIdle()
// 验证顺序
assertThat(states[0]).isInstanceOf(LoginUiState.Loading::class.java)
assertThat(states[1]).isInstanceOf(LoginUiState.Success::class.java)
// 清理
viewModel.loginState.removeObserver(observer)
}
避坑指南
我曾经踩过的坑:
- 忘记加@RunWith(MockitoJUnitRunner::class)——如果不加这个注解,@Mock不会生效,你会拿到NullPointerException。
- 在测试里直接new ViewModel——如果ViewModel有依赖,一定要通过构造函数传进去,不要用ViewModelProvider。测试就是要隔离外部依赖。
- 忽略协程的调度器——如果ViewModel里用了Dispatchers.IO,测试里要替换成TestDispatcher,否则协程不会在测试线程执行。
关于调度器的问题,我再多说一句。如果你的ViewModel里显式指定了调度器:
viewModelScope.launch(Dispatchers.IO) {
// ...
}
那测试里需要这样处理:
@Before
fun setup() {
Dispatchers.setMain(StandardTestDispatcher())
// 其他初始化
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
或者更优雅的方式,把调度器作为依赖注入到ViewModel里。这样测试时就能轻松替换了。
知识体系总览
下面这张图总结了ViewModel测试的核心要素和流程:
总结
ViewModel测试其实没那么复杂。记住三个关键点:用InstantTaskExecutorRule让LiveData同步,用runTest管理协程,用Mockito隔离外部依赖。把这三点吃透了,大部分场景都能覆盖。
我在项目中推行ViewModel测试时,团队里有人觉得写测试浪费时间。后来有一次重构,改了一个ViewModel的内部逻辑,跑了一遍测试发现三个用例都挂了——这说明测试确实在保护我们。从那以后,没人再质疑测试的必要性了。
嗯,今天就聊到这里。测试这件事,写多了就习惯了。关键是迈出第一步,从最简单的用例开始写起。