第2章 协程测试基础
2.1 协程测试的核心挑战
在 JVM 平台上测试协程代码面临两个关键问题:时间维度(真实 delay 拖慢测试)和作用域维度(结构化并发导致泄漏)。Kotlin 协程测试库提供了 runTest、TestCoroutineDispatcher 和 TestScope 来统一解决这些问题。本章将逐一拆解这些组件的工作原理与最佳实践。
2.2 runBlocking 与 runTest 对比
| 特性 | runBlocking | runTest |
|---|---|---|
| 调度器 | 当前线程阻塞 | 虚拟时间调度器 (StandardTestDispatcher) |
| delay 行为 | 真实等待(挂起线程) | 跳过等待,虚拟时间前进 |
| 协程控制 | 仅阻塞当前线程 | 自动控制,可手动推进时间 |
| 测试隔离 | 依赖外部协程作用域 | 提供内建 TestScope |
| 超时支持 | 需手动编写超时逻辑 | 内置超时,可配置 |
| 推荐用途 | 普通单元测试,不涉及协程时间 | 所有协程单元测试 |
2.3 TestCoroutineDispatcher 与虚拟时间控制
TestCoroutineDispatcher(在 kotlinx-coroutines-test 1.6+ 中更名为 StandardTestDispatcher)是虚拟时间调度的核心。它不会真实挂起线程,而是将 delay 等操作记录为“虚拟时间点”,通过以下 API 精确控制进度:
// 创建调度器
val dispatcher = StandardTestDispatcher()
val scope = TestScope(dispatcher)
// 启动协程
scope.launch {
println("开始: ${currentTime}")
delay(1000)
println("1秒后: ${currentTime}")
delay(2000)
println("3秒后: ${currentTime}")
}
// 手动推进时间
dispatcher.scheduler.advanceTimeBy(1000) // 只前进1秒
println("当前虚拟时间: ${dispatcher.scheduler.currentTime}")
// 推进所有待执行协程直到空闲
dispatcher.scheduler.advanceUntilIdle()
println("最终时间: ${dispatcher.scheduler.currentTime}")
常用虚拟时间 API 一览:
| API | 作用 | 性能影响 |
|---|---|---|
advanceTimeBy(timeMillis) | 将虚拟时间向前推进指定毫秒,执行期间可调度的所有协程 | 低(仅处理已入队任务) |
advanceUntilIdle() | 不断推进时间直到没有更多挂起任务(模拟“自然流逝”) | 中等(可能处理大量链式任务) |
currentTime | 获取当前虚拟时间戳 | 瞬间 |
runCurrent() | 立即执行当前时间点所有可运行的任务,不推进时间 | 低 |
2.4 runTest 内部工作原理
runTest 实际上是一个内联函数,它自动创建 TestScope 并附加 StandardTestDispatcher,在 lambda 结束后自动执行 advanceUntilIdle() 并清理作用域。其等效实现如下:
suspend fun runTest(block: suspend TestScope.() -> Unit) {
// 内部省略了异常处理和超时逻辑
val scope = TestScope()
val testResult = scope.testScheduler.advanceUntilIdle()
// 如果协程未完成则抛出超时异常
scope.backgroundScope.cancel()
}
2.5 协程作用域搭建
对于 runTest 内部已提供了 this 作为 TestScope,可以直接使用。需要自定义作用域时,建议遵循结构化并发原则:
// 好的做法:使用 TestScope 并手动控制生命周期
class MyViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@After
fun tearDown() {
testScope.cancel() // 清理协程
}
@Test
fun testWithCustomScope() = testScope.runTest {
// 这里使用 testScope 的调度器
launch {
delay(100)
// ...
}
advanceUntilIdle()
}
}
陷阱提示:不要在 runTest 内部创建新的 CoroutineScope,否则会导致泄漏。应始终使用 runTest 提供的 TestScope 或通过构造函数注入调度器。
2.6 runBlocking vs runTest 执行流程图
下图对比了两种方式的协程执行路径与时间消耗:
2.7 调试技巧
- 查看当前虚拟时间:在测试中调用
println("当前时间: ${this.testScheduler.currentTime}")观察调度进度。 - 超时定位:当
runTest抛出TestTimeoutException时,说明某个协程未在默认 60s(虚拟时间)内完成。可使用withTimeout缩小排查范围。 - 使用 debug 日志:在协程启动处添加
println("协程 ${coroutineContext[CoroutineName]} 启动,时间=${currentTime}")理清执行顺序。
2.8 常见错误与解决方案
| 错误场景 | 原因 | 解决方案 |
|---|---|---|
| 测试耗时过长 | 使用 runBlocking 测试带 delay 的协程 | 替换为 runTest |
| 虚拟时间无变化 | 忘记调用 advanceUntilIdle 或 advanceTimeBy | 在协程启动后手动推进时间 |
| 协程泄漏 | 创建了自定义 CoroutineScope 但未取消 | 使用 TestScope 并在 @After 中 cancel() |
| 测试结果不确定 | 依赖不同调度器的协程顺序 | 注入 StandardTestDispatcher 统一调度 |
| deadline 超时 | 协程内部有无限循环或未处理的挂起 | 使用 advanceUntilIdle 前确保有终止条件 |
2.9 性能与稳定性优化
- 最小化真实延迟:所有协程测试必须使用
runTest+StandardTestDispatcher,避免 I/O 或网络调用真实等待。 - 批量推进:当测试多个独立 delay 时,使用
advanceTimeBy精确控制,避免advanceUntilIdle做全量推进影响性能。 - 超时配置:对于复杂场景,使用
runTest(timeout = 30_000)设置合理的虚拟时间超时。 - 作用域复用:在同一个测试类的多个测试中复用
TestScope时,务必在@After中cancel()并重新创建,避免残留协程影响后续测试。
最佳实践:在每个测试类的顶部定义全局测试调度器,并通过构造函数注入给被测试对象,这样既能控制时间又便于维护。