测试 Compose 布局

测试界面或测试屏幕可用于验证 Compose 代码的行为是否正确,通过在开发过程的早期阶段发现错误来提高应用的质量。

Compose 提供了一组测试 API,用于查找元素、验证其属性以及执行用户操作。这些 API 还包括时间控制等高级功能。

语义

Compose 中的界面测试使用语义与界面层次结构进行交互。顾名思义,语义就是为一部分界面赋予意义。在此处,“一部分界面”(或一个元素)可以表示从单个可组合项到整个屏幕的任何内容。语义树与界面层次结构一起生成,并对其进行描述。

一张示意图,图中显示了一个典型的界面布局以及该布局如何映射到对应的语义树

图 1. 典型的界面层次结构及其语义树。

语义框架主要用于无障碍功能,因此测试会利用语义提供的有关界面层次结构的信息。由开发者决定要提供哪些信息以及提供多少信息。

一个包含图形和文本的按钮

图 2. 一个包含图标和文本的典型按钮。

例如,假设有一个这样的按钮,它由一个图标和一个文本元素组成,默认语义树仅包含文本标签“Like”。这是因为,某些可组合项(例如 Text)已经向语义树公开了一些属性。您可以使用 Modifier 向语义树添加属性。

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

设置

本部分介绍如何设置模块,以便您测试 Compose 代码。

首先,将以下依赖项添加到包含界面测试的模块的 build.gradle 文件中:

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createComposeRule(), but not for createAndroidComposeRule<YourActivity>():
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

此模块包含 ComposeTestRule 和一个名为 AndroidComposeTestRule 的 Android 实现。通过此规则,您可以设置 Compose 内容或访问 activity。这些规则使用工厂函数 createComposeRulecreateAndroidComposeRule(如果您需要访问 activity)来构建。Compose 的典型界面测试如下所示:

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

测试 API

与元素交互的方式主要有以下三种:

  • 查找器:可供您选择一个或多个元素(或语义树中的节点),以进行断言或对其执行操作。
  • 断言:用于验证元素是否存在或者具有某些属性。
  • 操作:会在元素上注入模拟的用户事件,例如点击或其他手势。

其中一些 API 接受 SemanticsMatcher 来引用语义树中的一个或多个节点。

查找器

您可以使用 onNodeonAllNodes 分别选择一个或多个节点,但也可以使用便捷查找器进行最常见的搜索,例如 onNodeWithTextonNodeWithContentDescription 等。您可以在 Compose Testing 备忘单中浏览完整列表。

选择单个节点

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

选择多个节点

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

使用未合并的树

某些节点会合并其子项的语义信息。例如,一个包含两个文本元素的按钮会合并这两个文本元素的标签:

MyButton {
    Text("Hello")
    Text("World")
}

在测试中,我们可以使用 printToLog() 来显示语义树:

composeTestRule.onRoot().printToLog("TAG")

此代码会生成以下输出:

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

如果您需要匹配未合并的树的节点,可以将 useUnmergedTree 设为 true

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

此代码会生成以下输出:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

useUnmergedTree 参数在所有查找器中都可用。例如,此处它用在 onNodeWithText 查找器中。

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

断言

您可以通过对带有一个或多个匹配器的查找器返回的 SemanticsNodeInteraction 调用 assert() 来检查断言:

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

您还可以对最常见的断言使用便捷函数,例如 assertExistsassertIsDisplayedassertTextEquals 等。您可以在 Compose Testing 备忘单中浏览完整列表。

还有一些函数用于检查一系列节点上的断言:

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

操作

如需在节点上注入操作,请调用 perform…() 函数:

composeTestRule.onNode(...).performClick()

下面是一些操作示例:

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

您可以在 Compose Testing 备忘单中浏览完整列表。

匹配器

本部分介绍可用于测试 Compose 代码的一些匹配器。

分层匹配器

分层匹配器可让您在语义树中向上或向下移动并执行简单的匹配。

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

下面是一些使用这些匹配器的示例:

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

选择器

创建测试的另一种方法是使用选择器,这样可提高一些测试的可读性。

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

您可以在 Compose Testing 备忘单中浏览完整列表。

同步

默认情况下,Compose 测试会与界面同步。通过 ComposeTestRule 调用断言或操作时,测试将预先同步,直到界面树处于空闲状态。

通常,您无需执行任何操作。但是,您应该了解一些极端情况。

同步测试时,您可以使用虚拟时钟将 Compose 应用的时间提前。这意味着 Compose 测试不会实时运行,从而能够尽快通过测试。

但是,如果您不使用同步测试的方法,则不会发生任何重组,并且界面会暂停。

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions
    var lastSeenValue = 0 // Used to track recompositions
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition

    // Fails because nothing triggered a recomposition
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition
    composeTestRule.onNodeWithText("1").assertExists()
}

另外还需要注意的是,此要求仅适用于 Compose 层次结构,而不适用于应用的其余部分。

停用自动同步功能

通过 ComposeTestRule(如 assertExists())调用断言或操作时,您的测试会与 Compose 界面同步。在某些情况下,您可能需要停止此同步并自行控制时钟。例如,您可以控制时间,以便在界面仍处于繁忙状态时对动画进行精确截图。如需停用自动同步功能,请将 mainClock 中的 autoAdvance 属性设置为 false

composeTestRule.mainClock.autoAdvance = false

一般情况下,您需要自行将时间提前。您可以使用 advanceTimeByFrame() 仅提前一帧,或使用 advanceTimeBy() 提前一段特定时间:

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

空闲资源

Compose 可以同步测试和界面,以便以空闲状态完成各项操作和断言,从而根据需要等待或将时钟提前。但是,某些影响界面状态的异步操作可在后台运行,而测试无法得知这些结果。

您可以在测试中创建并注册这些空闲资源,以便在确定受测应用是忙碌还是空闲时将这些资源考虑在内。除非需要注册其他空闲资源(例如,如果您运行的后台作业未与 Espresso 或 Compose 同步),否则无需执行任何操作。

此 API 与 Espresso 的空闲资源非常相似,用于指示受测对象是空闲还是忙碌。您可以使用 Compose 测试规则注册 IdlingResource 的实现。

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

手动同步

在某些情况下,您必须将 Compose 界面与测试的其他部分或您测试的应用同步。

waitForIdle 等待 Compose 空闲,但这取决于 autoAdvance 属性:

composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle

请注意,在这两种情况下,waitForIdle 还将等待待定的绘图和布局通过

此外,您还可以将时钟提前,直到满足 advanceTimeUntil() 的特定条件为止。

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

请注意,给定条件应检查可能受此时钟影响的状态(仅适用于 Compose 状态)。

正在等待条件

任何依赖于外部工作的条件,例如数据加载或 Android 的测量或绘制(即 Compose 外部的测量或绘制)都应使用更为宽泛的概念,例如 waitUntil()

composeTestRule.waitUntil(timeoutMs) { condition }

您也可以使用任何 waitUntil 帮助程序

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

常见模式

本部分介绍您会在 Compose 测试中看到的一些常见方法。

单独进行测试

ComposeTestRule 可让您启动显示任何可组合项的 activity:整个应用、单个屏幕或小元素。此外,最好检查可组合项是否被正确封装以及它们是否独立工作,从而使界面测试更容易且更有针对性。

这并不意味着您应仅创建单元界面测试。范围涵盖更大一部分界面的界面测试也非常重要。

在设置自己的内容后访问 activity 和资源

通常,您需要使用 composeTestRule.setContent 设置受测内容,还需要访问 activity 资源,例如,断言显示的文本与字符串资源是否匹配。不过,如果 setContent 已被 activity 调用,则无法再对使用 createAndroidComposeRule() 创建的规则进行调用。

实现此行为的一种常见模式是使用空 activity(例如 ComponentActivity)创建 AndroidComposeTestRule

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

请注意,您需要将 ComponentActivity 添加到应用的 AndroidManifest.xml 文件中。为此,您可以将以下依赖项添加到模块中:

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

自定义语义属性

您可以创建自定义语义属性,向测试提供相关信息。为此,请定义一个新的 SemanticsPropertyKey 并使用 SemanticsPropertyReceiver 使之可用。

// Creates a Semantics property of type Long
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

现在,您可以通过 semantics 修饰符使用该属性:

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

在测试中,您可以使用 SemanticsMatcher.expectValue 断言该属性的值:

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

验证状态恢复

您应验证在重新创建 activity 或进程后,Compose 元素的状态是否会正确恢复。您可以使用 StateRestorationTester 类执行此类检查,而无需依赖于 activity 重新创建。

借助该类,您可以对可组合项的重新创建进行模拟。这对验证 rememberSaveable 的实现特别有用。


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

测试不同的设备配置

Android 应用需要适应许多不断变化的条件:窗口大小、语言区域、字体大小、深色主题和浅色主题等。其中大多数条件都派生自由用户控制的设备级值,并通过当前的 Configuration 实例公开。直接在测试中测试不同的配置非常困难,因为测试必须配置设备级属性。

DeviceConfigurationOverride 是一个仅供测试的 API,可让您以本地化的方式为被测 @Composable 内容模拟不同的设备配置。

DeviceConfigurationOverride 的伴生对象具有以下扩展函数,这些函数会替换设备级配置属性:

如需应用特定替换项,请将被测内容封装在对 DeviceConfigurationOverride() 顶级函数的调用中,并以参数形式传递要应用的替换项。

例如,以下代码会应用 DeviceConfigurationOverride.ForcedSize() 替换以在本地更改密度,从而强制在大横向窗口中呈现 MyScreen 可组合项,即使运行测试的设备不直接支持该窗口大小也是如此:

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.ForcedSize(DpSize(1280.dp, 800.dp))
    ) {
        MyScreen() // will be rendered in the space for 1280dp by 800dp without clipping
    }
}

如需同时应用多个替换项,请使用 DeviceConfigurationOverride.then()

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.FontScale(1.5f) then
            DeviceConfigurationOverride.FontWeightAdjustment(200)
    ) {
        Text(text = "text with increased scale and weight")
    }
}

调试

解决测试中的问题的主要方法是查看语义树。您可以在测试过程中的任何时间调用 composeTestRule.onRoot().printToLog() 来输出语义树。此函数会输出如下日志:

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

这些日志包含一些有价值的信息,可用于跟踪错误。

与 Espresso 的互操作性

在混合应用中,您可以在视图层次结构中找到 Compose 组件,在 Compose 可组合项中找到视图(通过 AndroidView 可组合项)。

无需执行任何特殊步骤即可匹配这两种类型。您可以通过 Espresso 的 onView 匹配视图,并通过 ComposeTestRule 匹配 Compose 元素。

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

与 UiAutomator 的互操作性

默认情况下,只能通过便捷描述符(显示的文本、内容说明等)从 UiAutomator 访问可组合项。如果您想访问使用 Modifier.testTag 的任何可组合项,则需要为特定可组合项子树启用语义属性 testTagsAsResourceId。对于没有任何其他唯一句柄的可组合项(例如 LazyColumn 等可滚动的可组合项),启用此行为非常有用。

只能在可组合项层次结构中的较高层级将其启用一次,以确保可以从 UiAutomator 访问具有 Modifier.testTag 的所有嵌套可组合项。

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

任何具有 Modifier.testTag(tag) 的可组合项都可通过使用 By.res(resourceName) 并利用相同的 tag 作为 resourceName 的方式访问。

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

了解详情

如需了解详情,不妨参阅 Jetpack Compose 测试 Codelab

示例