20、协程与测试:runTest的使用、TestCoroutineDispatcher与TestScope、虚拟时间控制、协程测试的最佳实践与常见陷阱
协程的测试,说实话,是很多Android开发者容易忽略的一环。我自己刚接触协程时,也觉得“代码跑起来没问题就行”,直到有一次线上出现了时序错乱导致的bug,排查了整整两天……嗯,从那以后,我彻底明白了:协程代码如果不写测试,就是在给自己埋雷。
这一章,我们来聊聊协程测试的核心工具和最佳实践。我会结合自己的踩坑经历,帮你避开那些常见的陷阱。
runTest:协程测试的基石
runTest 是 Kotlin 协程测试库提供的一个顶级函数。它的作用很简单:创建一个 TestScope,并在其中执行你的协程代码。但它的强大之处在于——它会自动处理虚拟时间,让你的测试变得又快又可靠。
核心要点:runTest 会等待所有子协程完成,除非你手动取消。这意味着你不需要手动调用 delay 或 join 来等待异步操作。
来看一个简单的例子:
@Test
fun `test coroutine with delay`() = runTest {
var result = ""
launch {
delay(1000)
result = "done"
}
// 不需要等待真实时间,runTest 会自动推进虚拟时间
assertEquals("done", result)
}
我在项目中第一次用 runTest 时,还习惯性地加了 delay(2000) 来等待,结果发现测试瞬间就结束了。这就是虚拟时间的魅力——它让测试不再依赖真实时钟。
TestCoroutineDispatcher 与 TestScope
TestCoroutineDispatcher 是虚拟时间的核心实现。它控制着协程的调度,让你可以精确控制时间的流逝。而 TestScope 则是绑定了这个调度器的协程作用域。
我个人习惯这样创建测试环境:
class MyViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@Test
fun `test viewmodel state`() = runTest(testDispatcher) {
val viewModel = MyViewModel(testDispatcher)
viewModel.loadData()
// 手动推进时间,触发协程执行
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(State.Success, viewModel.state.value)
}
}
小技巧:如果你不想每次都手动传递调度器,可以用 Dispatchers.setMain(testDispatcher) 来替换主线程调度器。但记得在测试结束后恢复原样。
虚拟时间控制:让时间为你服务
虚拟时间控制是协程测试中最强大的功能之一。你可以精确控制时间的流逝,而不需要等待真实时间。
常用的时间控制方法:
| 方法 | 作用 |
|---|---|
advanceTimeBy(delay) |
推进指定时间,执行在此期间应该完成的任务 |
advanceUntilIdle() |
推进时间直到所有任务完成 |
currentTime |
获取当前虚拟时间 |
我曾经遇到过一个场景:需要测试一个每5秒轮询一次的定时器。如果用真实时间,测试要跑几分钟。但用了虚拟时间,几毫秒就搞定了:
@Test
fun `test polling timer`() = runTest {
var count = 0
val job = launch {
while (isActive) {
delay(5000)
count++
}
}
// 模拟3次轮询
advanceTimeBy(15000)
assertEquals(3, count)
job.cancel()
}
注意:虚拟时间只对使用 delay 的协程有效。如果你在协程中调用了平台相关的定时器(如 Handler.postDelayed),虚拟时间是无法控制的。
协程测试的最佳实践
经过多年的实践,我总结了几条协程测试的核心原则:
- 始终使用 runTest:不要手动创建 TestScope,runTest 已经帮你处理好了。
- 避免真实延迟:测试中不要使用 Thread.sleep 或真实 delay,用 advanceTimeBy 代替。
- 测试边界条件:比如超时、取消、异常恢复等场景。
- 隔离协程作用域:每个测试方法都应该有独立的协程作用域。
来看一个完整的测试示例:
class UserRepositoryTest {
@Test
fun `test fetch user with timeout`() = runTest {
val repository = UserRepository(
api = MockApi(delay = 5000) // 模拟慢网络
)
// 设置超时
val result = withTimeout(1000) {
repository.fetchUser("123")
}
// 推进时间到超时
advanceTimeBy(1000)
assertTrue(result is TimeoutCancellationException)
}
}
常见陷阱与避坑指南
说实话,协程测试的坑不少。我一个个来说:
陷阱1:忘记推进时间
我曾经写过这样的代码:
// ❌ 错误示例
@Test
fun `test wrong`() = runTest {
var result = ""
launch {
delay(100)
result = "done"
}
assertEquals("done", result) // 这里会失败,因为时间还没推进
}
正确的做法是:
// ✅ 正确示例
@Test
fun `test correct`() = runTest {
var result = ""
launch {
delay(100)
result = "done"
}
advanceTimeBy(100) // 手动推进时间
assertEquals("done", result)
}
陷阱2:在测试中使用真实调度器
你想想看,如果在测试中用了 Dispatchers.IO,那测试就会跑在真实线程上,虚拟时间就失效了。我建议所有测试都使用 StandardTestDispatcher。
陷阱3:忽略协程取消
测试中启动的协程如果不取消,可能会在测试结束后继续运行,导致奇怪的错误。记得在测试结束时取消所有协程:
@Test
fun `test with cleanup`() = runTest {
val job = launch {
// 一些长时间运行的任务
}
// 测试逻辑...
job.cancel() // 清理
}
我的建议:如果测试中启动的协程比较多,可以考虑使用 testScope.cancel() 来一次性清理。
知识体系总览
下面这张图总结了协程测试的核心概念和它们之间的关系:
这张图展示了协程测试的三个核心支柱:TestScope 负责作用域管理,TestCoroutineDispatcher 负责调度控制,虚拟时间控制则让我们可以精确操控时间。三者协同工作,构成了协程测试的基础。
最后,我想说:协程测试并不复杂,但需要你理解虚拟时间的原理。一旦掌握了这些工具,你会发现协程代码的测试比传统回调式代码要简单得多。嗯,希望这些经验能帮你少走一些弯路。
公众号:蓝海资料掘金营,微信deep3321