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.IODispatchers.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除了发射值,还可能抛出异常或正常完成。怎么测?用catchonCompletion

@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测试的核心知识点,你可以对照着梳理自己的思路:

Flow测试知识体系 TestDispatcher • StandardTestDispatcher • UnconfinedTestDispatcher • runTest 自动使用 • advanceTimeBy 推进时间 • advanceUntilIdle 执行完 核心:替换真实Dispatcher 让测试可控、快速 Flow单元测试 • toList() 收集所有值 • catch 捕获异常 • onCompletion 监听完成 • launch + collect 异步收集 • assertThrows 验证异常 注意:delay 需要 advanceTimeBy 异常要显式处理 StateFlow测试 • .value 同步读取初始值 • drop(1) 跳过初始值 • toList() 收集状态序列 • advanceUntilIdle 同步 • 记得 cancel 收集协程 坑:更新可能在不同协程 需要手动推进时间 核心原则:用 TestDispatcher 控制时间,用 toList/collect 验证值,用 advanceUntilIdle 同步状态

9.5 总结

Flow测试其实就三件事:

  • 控制时间——用TestDispatcher和advanceTimeByadvanceUntilIdle让测试跑得飞快又可控
  • 验证值——用toList()catchonCompletion检查Flow发射了什么
  • 同步状态——测试StateFlow时别忘了advanceUntilIdle,否则可能读到旧值

我个人建议:每个ViewModel或Repository的Flow方法,至少写一个正常路径测试和一个异常路径测试。别嫌麻烦——我吃过亏,线上出bug往往就是没覆盖到的边界情况。

最后一个小建议:测试代码也是代码,保持整洁。把公共的TestDispatcher提取出来,用@Before初始化。这样每个测试方法只关注业务逻辑,不关心调度器细节。


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