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 的观察者全清掉了,数据死活收不到。
测试 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 需要协程作用域,后面会讲。
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 秒。
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 用到的那些方法就够了。
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 单元测试的核心流程:
这张图把整个测试流程串起来了。你写测试的时候,就按这个顺序来:准备 Mock -> 执行操作 -> 验证结果。别跳步,也别在验证阶段再去 Mock 数据。