Compose 测试 v2 API

Compose 测试 API(createComposeRulecreateAndroidComposeRulerunComposeUiTestrunAndroidComposeUiTest 等)的 v2 版本现已推出,可更好地控制协程执行。此更新不会复制整个 API surface,只会更新用于建立测试环境的 API。

v1 API 已弃用,强烈建议您迁移到新 API。迁移可验证您的测试是否符合标准协程行为,并避免日后出现兼容性问题。如需查看已弃用的 v1 API 的列表,请参阅 API 映射

这些更改已包含在 androidx.compose.ui:ui-test-junit4:1.11.0-alpha03+androidx.compose.ui:ui-test:1.11.0-alpha03+ 中。

v1 API 依赖于 UnconfinedTestDispatcher,而 v2 API 默认使用 StandardTestDispatcher 来运行组合。此变更使 Compose 测试行为与标准 runTest API 保持一致,并提供对协程执行顺序的明确控制。

配置测试环境

Compose 测试 v2 API 使用 ComposeUiTestConfig 自定义测试环境。为测试创建设置函数的 API(例如 createComposeRulerunComposeUiTest 和其他相关 API)接受 ComposeUiTestConfig。此配置对象将环境相关 API(例如 effectContextrunTestContexttestTimeout)整合到一个对象中。

配置模型还管理 inputMode。Compose 测试 v2 API 默认在每次测试开始时强制执行 InputMode.Touch,以确保确定性并防止输入模式状态在测试之间泄露。

ComposeUiTestConfig 是 Compose Test v2 API 的一部分,默认使用 StandardTestDispatcher。如果您的测试使用 v1 API,请在采用 ComposeUiTestConfig 之前参阅迁移到 v2 测试 API

迁移到 ComposeUiTestConfig

在用于在测试中创建设置函数的过载中,接受单个配置参数(例如 effectContextrunTestContexttestTimeout)的多个过载已被弃用。将测试更新为使用 ComposeUiTestConfig,如下例所示:

默认输入模式

如果测试依赖于在测试开始之前通过插桩 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:

从 v1 测试 API 迁移到 v2 测试 API

此提示将使用本指南迁移到 v2 测试 API。

Migrate to Compose testing v2 APIs using the official
migration guide.

使用 AI 提示

AI 提示旨在用于 Android Studio 中的 Gemini。

如需详细了解 Studio 中的 Gemini,请访问以下网址:https://developer.android.com/studio/gemini/overview

使用下表将已弃用的 v1 API 映射到其 v2 替代项:

已弃用 (v1)

替换 (v2)

androidx.compose.ui.test.junit4.createComposeRule

androidx.compose.ui.test.junit4.v2.createComposeRule

androidx.compose.ui.test.junit4.createAndroidComposeRule

androidx.compose.ui.test.junit4.v2.createAndroidComposeRule

androidx.compose.ui.test.junit4.createEmptyComposeRule

androidx.compose.ui.test.junit4.v2.createEmptyComposeRule

androidx.compose.ui.test.junit4.AndroidComposeTestRule

androidx.compose.ui.test.junit4.v2.AndroidComposeTestRule

androidx.compose.ui.test.runComposeUiTest

androidx.compose.ui.test.v2.runComposeUiTest

androidx.compose.ui.test.runAndroidComposeUiTest

androidx.compose.ui.test.v2.runAndroidComposeUiTest

androidx.compose.ui.test.runEmptyComposeUiTest

androidx.compose.ui.test.v2.runEmptyComposeUiTest

androidx.compose.ui.test.AndroidComposeUiTestEnvironment

androidx.compose.ui.test.v2.AndroidComposeUiTestEnvironment

向后兼容性和例外情况

现有的 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)

使用 waitForIdlerunOnIdle 在断言之前执行排队的任务。

方法 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

之前的做法

createComposeRulerunTest 结合使用会创建两个单独的时钟:一个用于 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()
    }
}