4. 使用 runBlockingTest 与 TestCoroutineDispatcher:配置测试协程作用域,控制时间与异步任务

4.1 原理与背景说明

在 Compose UI 测试中,后台任务通常涉及协程的 delay、withContext 或 launch 操作。真实环境下的延时会导致测试执行缓慢且不可控。runBlockingTest(来自 kotlinx-coroutines-test)提供一个虚拟时钟,配合 TestCoroutineDispatcher 可以:

  • 跳过真实阻塞:所有 delay 不会等待物理时间。
  • 手动控制时间:通过 advanceTimeBy(ms) 或 advanceUntilIdle() 推进虚拟时钟。
  • 精确断言状态:在调度器空闲时验证中间或最终状态。

本质是创建一个受控的测试协程作用域,替换默认 Dispatchers.Main 和 Dispatchers.IO,让所有协程在测试中统一使用虚拟时间。

核心区别: runBlockingTest 自动注入 TestCoroutineDispatcher,而 runBlocking 使用真实调度器,无法控制时间。Compose UI 测试中必须优先使用 runBlockingTest。

4.2 完整实现步骤与代码片段

4.2.1 依赖配置

在 build.gradle.kts (模块级) 添加:

dependencies {
    // Compose UI 测试
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.7.0")
    // 协程测试库
    androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
    // 其他依赖
    debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.0")
}

4.2.2 测试用例:验证带延迟的后台刷新

假设有一个 ViewModel,在刷新数据前有 2 秒等待(模拟网络调用),然后更新状态:

class RefreshViewModel : ViewModel() {
    private val _state = mutableStateOf("初始")
    val state: State<String> = _state

    suspend fun refresh() {
        delay(2000) // 模拟网络延迟
        _state.value = "已刷新"
    }
}

使用 runBlockingTest 和 TestCoroutineDispatcher 编写测试:

import kotlinx.coroutines.test.*
import org.junit.Test

class RefreshViewModelTest {

    @Test
    fun `后台刷新后状态应更新`() = runBlockingTest {
        // 1. 创建 ViewModel
        val viewModel = RefreshViewModel()

        // 2. 启动 refresh 协程(在测试作用域内)
        val job = launch {
            viewModel.refresh()
        }

        // 3. 验证初始状态
        assert(viewModel.state.value == "初始")

        // 4. 推进虚拟时间,跳过 2000ms 延迟
        advanceTimeBy(2000)

        // 5. 断言刷新后状态
        assert(viewModel.state.value == "已刷新")

        // 6. 清理协程
        job.cancel()
    }
}

4.2.3 结合 Compose UI 测试

测试 Composable 中触发刷新:

@Test
fun `点击刷新按钮后UI应显示新数据`() = runBlockingTest {
    // 替换 Main 分发器为 TestCoroutineDispatcher
    Dispatchers.setMain(coroutineContext[ContinuationInterceptor]!!)

    composeTestRule.setContent {
        var text by remember { mutableStateOf("加载中") }
        Button(onClick = {
            GlobalScope.launch(Dispatchers.Main) {
                delay(1000) // 模拟后台任务
                text = "已完成"
            }
        }) { Text(text) }
    }

    // 点击按钮
    composeTestRule.onNodeWithText("加载中").performClick()

    // 推进虚拟时间,跳过 1000ms
    advanceTimeBy(1000)

    // 断言 UI 更新
    composeTestRule.onNodeWithText("已完成").assertIsDisplayed()
}

4.3 关键 API 参数

API / 属性 作用 典型参数
runBlockingTest { ... } 创建测试协程作用域,自动使用虚拟时钟 无参数;可接收一个 TestCoroutineScope 接收者
advanceTimeBy(delay: Long) 将虚拟时钟向前推进指定毫秒,执行在此期间应运行的所有协程 delay: Long(毫秒),不能为负数
advanceUntilIdle() 推进虚拟时钟直到所有计划和挂起的协程完成(包括无限循环) 无参数
currentTime 获取当前虚拟时间(毫秒) 只读属性
TestCoroutineDispatcher 调度器实现,支持虚拟时间控制 通常无需显式创建,runBlockingTest 自动使用
Dispatchers.setMain(dispatcher) 替换 Dispatchers.Main 为测试调度器,使 Compose 协程也受控 通常使用 TestCoroutineDispatcher() 或从 coroutineContext 获取

4.4 常见问题与调试技巧

  • 问题:测试执行时仍然等待真实时间(如 2 秒)。
    原因:可能使用了 runBlocking 而非 runBlockingTest,或任务在未受控的调度器中运行。
    调试:检查所有协程构建器是否在测试协程作用域内,且没有显式指定 Dispatchers.Default 等真实调度器。
  • 问题:advanceTimeBy 后状态仍未更新。
    原因:推进的时间不足,或任务中有嵌套 delay 未抵消。
    调试:使用 currentTime 打印当前虚拟时间,或使用 advanceUntilIdle() 确保所有任务完成。
  • 问题:Compose 测试中点击按钮后协程未运行。
    原因:未将 Dispatchers.Main 替换为测试调度器。
    解决:在 runBlockingTest 块开头调用 Dispatchers.setMain(coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)。
  • 调试技巧:在测试开始时设置 println("当前时间: $currentTime"),并在关键步骤后再次输出,观察时间推进是否如预期。

4.5 可验证的测试结果与日志

运行上述 RefreshViewModelTest,预期输出(无日志时测试通过):

测试通过:后台刷新后状态应更新
测试通过:点击刷新按钮后UI应显示新数据

若未使用 advanceTimeBy,测试将因超时失败(JUnit 默认 30 秒超时):

测试失败:后台刷新后状态应更新
java.lang.AssertionError: expected: 已刷新 but was: 初始
	at ... (RefreshViewModelTest.kt:13)

添加虚拟时间日志后的调试输出:

当前虚拟时间: 0ms
调用 advanceTimeBy(2000)...
当前虚拟时间: 2000ms
状态已变为: 已刷新
测试协程作用域 虚拟时间控制流程图 测试函数 runBlockingTest TestCoroutineScope + TestCoroutineDispatcher 执行 delay / launch (后台刷新协程) advanceTimeBy / advanceUntilIdle 断言状态 / UI 💡 核心优势 不等待物理时间 精确控制时间点 测试快速可重复
最佳实践:每个测试用例应创建独立的 TestCoroutineDispatcher 实例,并通过 @Before 设置 Dispatchers.setMain,@After 中 Dispatchers.resetMain(),避免污染其他测试。