测试 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 createAndroidComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

此模块包含一个 ComposeTestRule 和一个名为 AndroidComposeTestRule 的 Android 实现。通过此规则,您可以设置 Compose 内容或访问 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 }

常见模式

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

隔离测试

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

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

自定义语义属性

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

// Creates a Semantics property of type boolean
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()

调试

解决测试中的问题的主要方法是查看语义树。您可以在测试过程中的任何时间调用 findRoot().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()))
}

了解详情

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