15、协程测试:runTest、TestDispatcher、延迟测试

协程测试,说实话,是很多Android开发者的痛点。我自己带团队时发现,大家写业务代码时协程用得飞起,一到测试就懵了——「这玩意儿异步的,怎么测?」。其实没那么玄乎,Kotlin官方早就给我们准备好了三件套:runTestTestDispatcher、还有延迟测试的套路。今天咱们就把它彻底讲透。

15.1 为什么协程测试需要特殊处理?

先想一个问题:普通单元测试是同步的,一行代码跑完再跑下一行。但协程里动不动就delay(1000)withContext(Dispatchers.IO),你总不能真等一秒吧?

我刚开始写协程测试时,就踩过这个坑。测试一个网络请求的重试逻辑,里面有个3秒的延迟。我傻傻地等了3秒……后来发现,测试框架根本不需要真实时间,它只需要「假装时间过去了」就行。

这就是TestDispatcher的核心思想——虚拟时间。它让协程的调度和延迟都变成可控的,测试代码可以「快进」时间,瞬间跳过所有延迟。

15.2 runTest:测试协程的入口

runTest是Kotlin 1.6引入的测试利器。它本质上是一个协程构建器,专门为测试设计。你想想看,以前我们测协程得手动runBlocking,然后各种joinawait,代码又臭又长。现在一个runTest全搞定。

核心用法:

@Test
fun `测试用户信息加载`() = runTest {
    // 这里面的协程代码,全部在虚拟时间中运行
    val viewModel = UserViewModel(repository)
    viewModel.loadUser()
    
    // 不需要等待,直接断言
    assertEquals("加载成功", viewModel.state.value)
}

我个人习惯把runTest当作测试函数的「容器」。它自动处理了协程的启动、等待、异常传播。而且它默认使用StandardTestDispatcher,这个调度器有个特点——它不会自动执行协程,需要你手动触发或者等它自己调度。

小技巧:如果你想让协程立即执行,可以用UnconfinedTestDispatcher。但要注意,它可能会让测试顺序变得不可预测。我一般只在简单的场景用。

15.3 TestDispatcher:掌控协程调度

TestDispatcher有两种主要实现:

调度器 行为特点 适用场景
StandardTestDispatcher 协程不会自动执行,需要调用advanceUntilIdle()runCurrent() 需要精细控制执行顺序的测试
UnconfinedTestDispatcher 协程在启动时立即执行,类似Dispatchers.Unconfined 简单场景,不关心执行顺序

这里我要重点说一下StandardTestDispatcher。它为什么默认不执行协程?说白了,是为了让你能精确控制测试的每一步。比如你有一个协程在后台加载数据,另一个协程在更新UI。如果你用UnconfinedTestDispatcher,两个协程可能乱序执行,测试结果就不稳定了。

我曾经在一个支付模块的测试里,就因为用了UnconfinedTestDispatcher,导致偶发性失败。排查了半天,发现是协程执行顺序和预期不一致。换成StandardTestDispatcher后,手动控制每一步,问题就解决了。

15.4 延迟测试:跳过等待时间

协程里最常见的操作就是delay。比如轮询、超时、动画延迟。在测试里,我们不可能真的等几秒钟。这时候就需要TestCoroutineScheduler(现在叫TestScheduler)来帮忙。

@Test
fun `测试重试延迟`() = runTest {
    // 模拟一个需要延迟重试的操作
    var retryCount = 0
    val job = launch {
        repeat(3) {
            delay(1000)  // 每次重试间隔1秒
            retryCount++
        }
    }
    
    // 快进时间,跳过所有延迟
    advanceUntilIdle()
    
    assertEquals(3, retryCount)
}

这里的关键函数是advanceUntilIdle()。它会一直推进虚拟时间,直到所有协程都完成。说白了,就是「一键快进到结束」。如果你只想推进一小段时间,可以用advanceTimeBy(500),只跳过500毫秒。

注意:advanceUntilIdle()会执行所有待处理的协程,包括那些可能无限循环的。如果你的协程里有while(true),那它永远不会结束,测试就会卡死。我建议在测试里避免写无限循环,或者用withTimeout兜底。

15.5 实战:测试一个带延迟的ViewModel

咱们来看一个完整的例子。假设有一个搜索功能,用户输入后延迟300毫秒才发起请求(防抖)。

class SearchViewModel(
    private val repository: SearchRepository
) {
    private val _query = MutableStateFlow("")
    val suggestions: StateFlow<List<String>> = _query
        .debounce(300)
        .flatMapLatest { repository.search(it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
}

// 测试代码
@Test
fun `搜索防抖测试`() = runTest {
    val repository = FakeSearchRepository()
    val viewModel = SearchViewModel(repository)
    
    // 模拟用户输入
    viewModel.onQueryChanged("ko")
    viewModel.onQueryChanged("kot")
    viewModel.onQueryChanged("kotlin")
    
    // 此时还没有触发搜索,因为延迟还没到
    assertEquals(emptyList(), viewModel.suggestions.value)
    
    // 快进300毫秒
    advanceTimeBy(300)
    
    // 现在应该只搜索了"kotlin"
    assertEquals(listOf("kotlin"), repository.lastQuery)
}

这个测试里,我模拟了用户连续输入三个关键词。如果没有advanceTimeBy,测试会一直卡在debounce那里。用了虚拟时间后,300毫秒瞬间跳过,测试干净利落。

15.6 避坑指南:我踩过的那些坑

协程测试虽然方便,但坑也不少。我列几个常见的:

  • 忘记调用advanceUntilIdle:用StandardTestDispatcher时,如果不手动推进时间,协程永远不会执行。测试通过了也是假通过。
  • runTest里用Dispatchers.IOrunTest默认只替换当前协程的调度器。如果你在代码里硬编码了Dispatchers.IO,它不会自动被替换。需要手动注入TestDispatcher
  • 测试超时:如果协程里有死循环或者无限等待,advanceUntilIdle会永远跑不完。建议给测试加个@Test(timeout = 1000)兜底。

我曾经在一个项目里,测试一个WebSocket重连逻辑。里面有个delay(Long.MAX_VALUE),我忘了处理,结果测试跑了5分钟还没结束……从那以后,我所有协程测试都加上了超时限制。

15.7 知识体系总览

下面这张图总结了协程测试的核心知识点,我建议你保存下来,写测试时对照着看:

协程测试知识体系 runTest TestDispatcher 延迟测试 协程测试入口容器 自动处理异常传播 默认 StandardTestDispatcher StandardTestDispatcher UnconfinedTestDispatcher TestCoroutineScheduler advanceUntilIdle() advanceTimeBy(ms) runCurrent() 核心原则:用虚拟时间替代真实时间,让异步变同步 避免硬编码 Dispatchers,通过依赖注入替换为 TestDispatcher

15.8 总结

协程测试说白了就三件事:用runTest包起来,用TestDispatcher控制调度,用advanceTimeBy跳过延迟。掌握了这三个工具,你就能写出稳定、快速的协程测试。

我个人觉得,协程测试最大的价值不是「测对」,而是「测快」。以前跑一遍协程测试要等好几秒,现在毫秒级搞定。开发体验好了,大家才愿意写测试。嗯,这就是我为什么花这么多时间研究它的原因。

一句话记住:测试协程,别等真实时间,用虚拟时间「快进」到结果。


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