Compose 测试 API(createComposeRule、createAndroidComposeRule、runComposeUiTest、runAndroidComposeUiTest 等)的 v2 版本现已推出,可用于更好地控制协程执行。此更新不会复制整个 API surface,只会更新用于建立测试环境的 API。
v1 版 API 已弃用,强烈建议您改用新版 API。迁移可验证您的测试是否符合标准协程行为,并避免日后出现兼容性问题。如需查看已弃用的 v1 API 的列表,请参阅 API 映射。
虽然 v1 API 依赖于 UnconfinedTestDispatcher,但 v2 API 默认使用 StandardTestDispatcher 来运行合成。此变更使 Compose 测试行为与标准 runTest API 保持一致,并提供对协程执行顺序的明确控制。
配置测试环境
Compose 测试 v2 API 使用 ComposeUiTestConfig 自定义测试环境。为测试创建设置函数的 API(例如 createComposeRule、runComposeUiTest 和其他相关 API)接受 ComposeUiTestConfig。此配置对象将环境相关 API(如 effectContext、runTestContext 和 testTimeout)整合到一个对象中。
配置模型还管理 inputMode。Compose 测试 v2 API 默认会在每次测试开始时强制执行 InputMode.Touch,以确保确定性并防止输入模式状态在测试之间泄露。
ComposeUiTestConfig 是 Compose 测试 v2 API 的一部分,默认使用 StandardTestDispatcher。如果您的测试使用 v1 API,请在采用 ComposeUiTestConfig 之前参阅迁移到 v2 测试 API。
迁移到 ComposeUiTestConfig
在用于在测试中创建设置函数的过载中,接受单个配置参数(例如 effectContext、runTestContext 或 testTimeout)的多个过载已被弃用。将测试更新为使用 ComposeUiTestConfig,如下例所示:
val testConfig = ComposeUiTestConfig( effectContext = EmptyCoroutineContext, runTestContext = EmptyCoroutineContext, testTimeout = 30.seconds ) @get:Rule val rule = createComposeRule(config = testConfig) // OR runComposeUiTest(config = testConfig) {}
默认输入模式
如果测试依赖于在测试开始之前通过插桩 API 配置的非触控输入模式,则在迁移期间可能会失败。在测试的设置函数中,系统默认会在每个测试开始时强制执行 InputMode.Touch,以提高确定性并防止状态泄漏,从而覆盖环境设备状态和测试前设置。
如需解决此问题,请在 ComposeUiTestConfig 中指定所需的输入模式:
class FocusTest { @get:Rule val rule = createComposeRule( config = ComposeUiTestConfig(inputMode = InputMode.Keyboard) ) @Test fun testFocus() {} }
如需为各个测试用例(而非整个测试类)配置输入模式,请将 ComposeUiTestConfig 传递给 runComposeUiTest:
class FocusTest { @Test fun testTouchMode() = runComposeUiTest { // Runs with the default InputMode.Touch } @Test fun testKeyboardMode() = runComposeUiTest( ComposeUiTestConfig(inputMode = InputMode.Keyboard) ) { // Runs with InputMode.Keyboard } }
如需了解其他迁移问题及解决方案,请参阅常见无法过审情况及解决方法。
迁移到 v2 测试 API
升级到 v2 API 时,您通常可以使用查找和替换来更新软件包导入并采用新的调度程序更改。
或者,您也可以让 Gemini 使用以下提示将 Compose 测试 API 迁移到 v2:
AI 提示
从 v1 测试 API 迁移到 v2 测试 API
此提示将使用本指南迁移到 v2 测试 API。
Migrate to Compose testing v2 APIs using the official
migration guide.使用下表将已弃用的 v1 API 映射到其 v2 替代项:
已弃用 (v1) |
替换 (v2) |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
向后兼容性和例外情况
现有的 v1 API 现已弃用,但请继续使用 UnconfinedTestDispatcher 以保持现有行为并防止重大更改。
只有以下情况例外,默认行为已发生变化:
用于在 AndroidComposeUiTestEnvironment 类中运行组合的默认测试调度程序已从 UnconfinedTestDispatcher 切换到 StandardTestDispatcher。这会影响您使用构造函数创建实例或对 AndroidComposeUiTestEnvironment 进行子类化并调用该构造函数的情况。
主要变更:对协程执行的影响
API 的 v1 和 v2 之间的主要区别在于协程的调度方式:
- v1 API (
UnconfinedTestDispatcher):当协程启动时,它会立即在当前线程上执行,通常会在下一行测试代码运行之前完成。与生产环境中的行为不同,这种立即执行可能会无意中掩盖实际的时间问题或竞态条件,而这些问题或条件会在实际应用中发生。 - v2 API (
StandardTestDispatcher):当协程启动时,它会加入队列,并且在测试显式推进虚拟时钟之前不会执行。标准 Compose 测试 API(例如waitForIdle())已处理此同步问题,因此大多数依赖于这些标准 API 的测试应能继续正常运行,无需进行任何更改。
常见无法过审情况及解决方法
如果您的测试在升级到 v2 后失败,则很可能表现出以下模式:
- 失败:您启动了一项任务(例如,ViewModel 加载数据),但断言立即失败,因为数据仍处于“正在加载”状态。
- 原因:使用 v2 API 时,协程会排队,而不是立即执行。任务已排队,但在检查结果之前从未实际运行。
- 修复:明确推进时间。您必须明确告知 v2 调度程序何时执行工作。
之前的方法
在 v1 中,任务会立即启动并完成。在 v2 中,以下代码会失败,因为 loadData() 尚未实际运行。
// In v1, this launched and finished immediately.
viewModel.loadData()
// In v2, this fails because loadData() hasn't actually run yet!
assertEquals(Success, viewModel.state.value)
建议的方法
使用 waitForIdle 或 runOnIdle 在断言之前执行排队的任务。
方法 1:使用 waitForIdle 可将时钟推进到界面空闲状态,从而验证协程是否已运行。
viewModel.loadData()
// Explicitly run all queued tasks
composeTestRule.waitForIdle()
assertEquals(Success, viewModel.state.value)
方法 2:使用 runOnIdle 在界面空闲后在界面线程上执行代码块。
viewModel.loadData()
// Run the assertion after the UI is idle
composeTestRule.runOnIdle {
assertEquals(Success, viewModel.state.value)
}
手动同步
在涉及手动同步的场景中(例如当自动推进功能处于停用状态时),启动协程不会立即执行,因为测试时钟处于暂停状态。如需在队列中执行协程而不推进虚拟时钟,请使用 runCurrent() API。此方法会运行安排在当前虚拟时间执行的任务。
composeTestRule.mainClock.scheduler.runCurrent()
与 waitForIdle()(可推进测试时钟,直到界面稳定)不同,runCurrent() 会在保持当前虚拟时间的同时执行待处理的任务。此行为可用于验证中间状态,如果时钟前进到空闲状态,则会跳过这些中间状态。
公开了测试环境中使用的底层测试调度程序。此调度程序可与 Kotlin runTest API 结合使用,以同步测试时钟。
迁移到 runComposeUiTest
如果您同时使用 Compose 测试 API 和 Kotlin runTest API,强烈建议您改用 runComposeUiTest。
之前的做法
将 createComposeRule 与 runTest 结合使用会创建两个单独的时钟:一个用于 Compose,一个用于测试协程范围。此配置可能会强制您手动同步测试调度程序。
@get:Rule val composeTestRule = createComposeRule() @Test fun testWithCoroutines() { composeTestRule.setContent { var status by remember { mutableStateOf("Loading...") } LaunchedEffect(Unit) { delay(1000) status = "Done!" } Text(text = status) } // NOT RECOMMENDED // Fails: runTest creates a new, separate scheduler. // Advancing time here does NOT advance the compose clock. // To fix this without migrating, you would need to share the scheduler // by passing 'composeTestRule.mainClock.scheduler' to runTest. runTest { composeTestRule.onNodeWithText("Loading...").assertIsDisplayed() advanceTimeBy(1000) composeTestRule.onNodeWithText("Done!").assertIsDisplayed() } }
建议的方法
runComposeUiTest API 会在其自己的 runTest 范围内自动执行测试块。测试时钟与 Compose 环境同步,因此您不再需要手动管理调度程序。
@Test fun testWithCoroutines() = runComposeUiTest { setContent { var status by remember { mutableStateOf("Loading...") } LaunchedEffect(Unit) { delay(1000) status = "Done!" } Text(text = status) } onNodeWithText("Loading...").assertIsDisplayed() mainClock.advanceTimeBy(1000 + 16 /* Frame buffer */) onNodeWithText("Done!").assertIsDisplayed() } }