9、Flow测试:TestDispatcher、Flow的单元测试、StateFlow的测试
测试,说实话,是很多Android开发者的软肋。特别是涉及到协程和Flow的时候,总觉得无从下手。我早期做项目时也踩过不少坑,比如测试里协程不执行、时间对不上、StateFlow死活收不到值……后来摸清了套路,才发现其实没那么玄乎。
今天我们就来聊聊Flow的测试。我会从最基础的TestDispatcher讲起,然后带你写几个典型的Flow单元测试,最后专门说说StateFlow的测试要点。嗯,都是实战中一定会遇到的场景。
9.1 TestDispatcher:测试中的时间指挥官
先问一个问题:你在测试里用runBlocking跑协程,为什么有时候delay就是不生效?
原因很简单——runBlocking用的是真实时间。你写个delay(1000),测试就得真等1秒。这谁受得了?
所以Google给我们准备了TestDispatcher。它有两种常用实现:
| 类型 | 特点 | 适用场景 |
|---|---|---|
StandardTestDispatcher |
任务按顺序执行,但不会自动推进时间 | 需要手动控制协程执行顺序 |
UnconfinedTestDispatcher |
任务立即执行,不排队 | 简单测试,不需要控制时序 |
我个人习惯用StandardTestDispatcher,因为它更接近真实环境的行为。你想想看,真实App里协程也是排队执行的,对吧?
核心要点:测试协程时,一定要用TestDispatcher替换掉你代码里的Dispatchers.IO或Dispatchers.Default。否则你的测试跑在真实线程池上,既慢又不稳定。
怎么替换?最简单的方式是通过依赖注入。比如你的Repository接受一个CoroutineDispatcher参数:
class MyRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun fetchData(): List<String> {
return withContext(ioDispatcher) {
// 模拟网络请求
delay(1000)
listOf("A", "B", "C")
}
}
}
测试时传入StandardTestDispatcher(),时间就归你管了。
9.2 Flow的单元测试:从简单到复杂
Flow的测试,说白了就是验证它发射的值对不对。但有几个坑要注意。
9.2.1 最简单的Flow测试
假设你有一个返回Flow的方法:
class NumberRepository {
fun getNumbers(): Flow<Int> = flow {
emit(1)
emit(2)
emit(3)
}
}
测试怎么写?直接用toList()收集所有值:
@Test
fun `test getNumbers emits 1 2 3`() = runTest {
val repo = NumberRepository()
val result = repo.getNumbers().toList()
assertEquals(listOf(1, 2, 3), result)
}
这里用了runTest——它是Kotlin协程测试库提供的函数,内部自动使用了TestDispatcher。你不需要手动创建。
小技巧:runTest默认使用StandardTestDispatcher。如果你想让测试跑得更快,可以传入UnconfinedTestDispatcher,但要注意它可能跳过某些中间状态。
9.2.2 测试带延迟的Flow
我在项目中遇到过这样的场景:一个搜索功能,用户输入后延迟300ms才发起请求。用Flow实现就是:
fun search(query: String): Flow<List<Result>> = flow {
delay(300)
emit(repository.search(query))
}
测试时怎么跳过这300ms?用advanceTimeBy:
@Test
fun `test search delays 300ms`() = runTest {
val results = mutableListOf<List<Result>>()
// 启动收集,但不阻塞
val job = launch {
search("hello").collect { results.add(it) }
}
// 手动推进时间
advanceTimeBy(300)
// 此时应该已经发射了值
assertTrue(results.isNotEmpty())
job.cancel()
}
为什么要用launch而不是直接collect?因为collect是挂起函数,会一直阻塞到Flow结束。用launch启动一个协程去收集,主测试线程就可以继续推进时间了。
注意:advanceTimeBy只会推进虚拟时间,不会真的等300ms。所以你的测试跑得飞快——这就是TestDispatcher的威力。
9.2.3 测试异常和完成事件
Flow除了发射值,还可能抛出异常或正常完成。怎么测?用catch和onCompletion:
@Test
fun `test flow throws exception`() = runTest {
val flow = flow<Int> {
emit(1)
throw RuntimeException("Oops")
}
val result = flow
.catch { emit(-1) }
.toList()
assertEquals(listOf(1, -1), result)
}
我曾经犯过一个错误:直接调用flow { ... }.toList(),结果异常没被捕获,测试直接挂了。后来才意识到,toList()会把异常抛出来,所以要么用catch,要么用assertThrows。
9.3 StateFlow的测试:别被初始值骗了
StateFlow和普通Flow最大的区别是什么?它有一个初始值,而且只保留最新值。这两个特性在测试里很容易出问题。
9.3.1 测试StateFlow的初始值
假设你有一个ViewModel:
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow(0)
val state: StateFlow<Int> = _state.asStateFlow()
fun increment() {
_state.value++
}
}
测试初始值很简单:
@Test
fun `test initial state is 0`() {
val viewModel = MyViewModel()
assertEquals(0, viewModel.state.value)
}
注意这里没有用runTest——因为读取value是同步操作,不需要协程。
9.3.2 测试StateFlow的状态变化
测试状态变化时,有个常见的坑:你调用increment()后立刻读取value,可能读到的是旧值。为什么?因为StateFlow的更新是原子操作,但如果你在协程里更新,主线程可能还没看到新值。
解决办法:用drop(1)跳过初始值,然后收集后续变化:
@Test
fun `test state changes after increment`() = runTest {
val viewModel = MyViewModel()
// 启动收集,跳过初始值
val job = launch {
viewModel.state.drop(1).collect { /* 这里处理变化 */ }
}
viewModel.increment()
// 等待协程执行完毕
advanceUntilIdle()
assertEquals(1, viewModel.state.value)
job.cancel()
}
这里的关键是advanceUntilIdle()——它会执行所有排队的协程任务,直到没有任务可执行。这样就能保证increment()里的更新已经生效了。
避坑指南:我曾经在测试里直接写viewModel.increment(); assertEquals(1, viewModel.state.value),结果有时候通过有时候不通过。后来才发现是协程调度的问题。记住:StateFlow的更新可能发生在另一个协程里,你需要用advanceUntilIdle来同步。
9.3.3 测试StateFlow的并发更新
如果你的ViewModel里有多个协程同时更新StateFlow,测试起来会更复杂。比如:
fun loadData() {
viewModelScope.launch {
_state.value = Loading
val data = repository.fetchData()
_state.value = Success(data)
}
}
测试时,你可能想验证中间状态Loading是否出现过。怎么做?用toList()收集所有状态:
@Test
fun `test loading state appears`() = runTest {
val viewModel = MyViewModel()
val states = mutableListOf<UiState>()
val job = launch {
viewModel.state.toList(states)
}
viewModel.loadData()
advanceUntilIdle()
// 验证状态序列
assertEquals(3, states.size) // 初始、Loading、Success
assertTrue(states[1] is UiState.Loading)
assertTrue(states[2] is UiState.Success)
job.cancel()
}
这里toList()会一直收集直到Flow结束。但StateFlow不会自动结束,所以记得cancel掉收集协程。
9.4 知识体系总览
下面这张图总结了Flow测试的核心知识点,你可以对照着梳理自己的思路:
9.5 总结
Flow测试其实就三件事:
- 控制时间——用TestDispatcher和
advanceTimeBy、advanceUntilIdle让测试跑得飞快又可控 - 验证值——用
toList()、catch、onCompletion检查Flow发射了什么 - 同步状态——测试StateFlow时别忘了
advanceUntilIdle,否则可能读到旧值
我个人建议:每个ViewModel或Repository的Flow方法,至少写一个正常路径测试和一个异常路径测试。别嫌麻烦——我吃过亏,线上出bug往往就是没覆盖到的边界情况。
最后一个小建议:测试代码也是代码,保持整洁。把公共的TestDispatcher提取出来,用@Before初始化。这样每个测试方法只关注业务逻辑,不关心调度器细节。
公众号:蓝海资料掘金营,微信deep3321