15、协程测试:runTest、TestDispatcher、延迟测试
协程测试,说实话,是很多Android开发者的痛点。我自己带团队时发现,大家写业务代码时协程用得飞起,一到测试就懵了——「这玩意儿异步的,怎么测?」。其实没那么玄乎,Kotlin官方早就给我们准备好了三件套:runTest、TestDispatcher、还有延迟测试的套路。今天咱们就把它彻底讲透。
15.1 为什么协程测试需要特殊处理?
先想一个问题:普通单元测试是同步的,一行代码跑完再跑下一行。但协程里动不动就delay(1000)、withContext(Dispatchers.IO),你总不能真等一秒吧?
我刚开始写协程测试时,就踩过这个坑。测试一个网络请求的重试逻辑,里面有个3秒的延迟。我傻傻地等了3秒……后来发现,测试框架根本不需要真实时间,它只需要「假装时间过去了」就行。
这就是TestDispatcher的核心思想——虚拟时间。它让协程的调度和延迟都变成可控的,测试代码可以「快进」时间,瞬间跳过所有延迟。
15.2 runTest:测试协程的入口
runTest是Kotlin 1.6引入的测试利器。它本质上是一个协程构建器,专门为测试设计。你想想看,以前我们测协程得手动runBlocking,然后各种join、await,代码又臭又长。现在一个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.IO:runTest默认只替换当前协程的调度器。如果你在代码里硬编码了Dispatchers.IO,它不会自动被替换。需要手动注入TestDispatcher。 - 测试超时:如果协程里有死循环或者无限等待,
advanceUntilIdle会永远跑不完。建议给测试加个@Test(timeout = 1000)兜底。
我曾经在一个项目里,测试一个WebSocket重连逻辑。里面有个delay(Long.MAX_VALUE),我忘了处理,结果测试跑了5分钟还没结束……从那以后,我所有协程测试都加上了超时限制。
15.7 知识体系总览
下面这张图总结了协程测试的核心知识点,我建议你保存下来,写测试时对照着看:
15.8 总结
协程测试说白了就三件事:用runTest包起来,用TestDispatcher控制调度,用advanceTimeBy跳过延迟。掌握了这三个工具,你就能写出稳定、快速的协程测试。
我个人觉得,协程测试最大的价值不是「测对」,而是「测快」。以前跑一遍协程测试要等好几秒,现在毫秒级搞定。开发体验好了,大家才愿意写测试。嗯,这就是我为什么花这么多时间研究它的原因。
一句话记住:测试协程,别等真实时间,用虚拟时间「快进」到结果。
公众号:蓝海资料掘金营,微信deep3321