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
状态已变为: 已刷新
最佳实践:每个测试用例应创建独立的
TestCoroutineDispatcher 实例,并通过 @Before 设置 Dispatchers.setMain,@After 中 Dispatchers.resetMain(),避免污染其他测试。