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就三步:

  1. 准备依赖——把Repository、UseCase等外部依赖mock掉
  2. 执行操作——调用ViewModel的方法,模拟用户点击或数据加载
  3. 验证输出——检查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 • runTest / TestDispatcher • MockitoAnnotations.openMocks • @Mock 注解 执行操作 • 调用ViewModel方法 • 模拟用户交互 • 触发数据加载 • advanceUntilIdle() 验证输出 • LiveData.value 断言 • Observer 记录状态 • 状态顺序验证 • 异常分支覆盖 常用测试依赖 JUnit 4 — 测试框架基础 Mockito — 模拟外部依赖 Arch Testing — LiveData/协程支持 核心原则 隔离外部依赖 → 控制输入 → 验证LiveData输出 → 覆盖正常和异常路径

总结

ViewModel测试其实没那么复杂。记住三个关键点:用InstantTaskExecutorRule让LiveData同步,用runTest管理协程,用Mockito隔离外部依赖。把这三点吃透了,大部分场景都能覆盖。

我在项目中推行ViewModel测试时,团队里有人觉得写测试浪费时间。后来有一次重构,改了一个ViewModel的内部逻辑,跑了一遍测试发现三个用例都挂了——这说明测试确实在保护我们。从那以后,没人再质疑测试的必要性了。

嗯,今天就聊到这里。测试这件事,写多了就习惯了。关键是迈出第一步,从最简单的用例开始写起。

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