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),虚拟时间是无法控制的。

协程测试的最佳实践

经过多年的实践,我总结了几条协程测试的核心原则:

  1. 始终使用 runTest:不要手动创建 TestScope,runTest 已经帮你处理好了。
  2. 避免真实延迟:测试中不要使用 Thread.sleep 或真实 delay,用 advanceTimeBy 代替。
  3. 测试边界条件:比如超时、取消、异常恢复等场景。
  4. 隔离协程作用域:每个测试方法都应该有独立的协程作用域。

来看一个完整的测试示例:

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() 来一次性清理。

知识体系总览

下面这张图总结了协程测试的核心概念和它们之间的关系:

协程测试知识体系 runTest TestScope TestCoroutineDispatcher 虚拟时间控制 协程作用域管理 子协程自动取消 调度器替换 任务队列控制 advanceTimeBy advanceUntilIdle 最佳实践:隔离、虚拟时间、边界测试、清理

这张图展示了协程测试的三个核心支柱:TestScope 负责作用域管理,TestCoroutineDispatcher 负责调度控制,虚拟时间控制则让我们可以精确操控时间。三者协同工作,构成了协程测试的基础。

最后,我想说:协程测试并不复杂,但需要你理解虚拟时间的原理。一旦掌握了这些工具,你会发现协程代码的测试比传统回调式代码要简单得多。嗯,希望这些经验能帮你少走一些弯路。


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