Jetpack Compose 中的测试

1. 简介和设置

在本 Codelab 中,您将学习如何测试使用 Jetpack Compose 创建的 UI。您将在学习隔离测试、调试测试、语义树和同步的过程中编写您的第一个测试。

您需要什么

查看此 Codelab 的代码(Rally)

您将使用 Rally Material 研究 作为本 Codelab 的基础。您可以在 android-compose-codelabs Github 存储库中找到它。要克隆,请运行

git clone https://github.com/android/codelab-android-compose.git

下载完成后,打开 TestingCodelab 项目。

或者,您可以下载两个 zip 文件

打开 TestingCodelab 文件夹,其中包含一个名为 Rally 的应用。

检查项目结构

Compose 测试是 Instrumentation 测试。这意味着它们需要在设备(物理设备或模拟器)上运行。

Rally 已经包含一些 Instrumentation UI 测试。您可以在 androidTest 源集下找到它们

b14721ae60ee9022.png

这是您放置新测试的目录。您可以查看 AnimatingCircleTests.kt 文件以了解 Compose 测试的外观。

Rally 已经配置好了,但是您在新的项目中启用 Compose 测试所需做的就是在相关模块的 build.gradle 文件中添加测试依赖项,这些依赖项为

androidTestImplementation "androidx.compose.ui:ui-test-junit4:$version"

debugImplementation "androidx.compose.ui:ui-test-manifest:$rootProject.composeVersion"

随时运行应用并熟悉它。

2. 测试什么?

我们将重点关注 Rally 的标签栏,它包含一行 标签(概述、帐户和账单)。在上下文中,它看起来像这样

19c6a7eb9d732d37.gif

在本 Codelab 中,您将测试该栏的 UI。

这可能意味着很多事情

  • 测试标签是否显示预期的图标和文本
  • 测试动画是否与规范匹配
  • 测试触发的导航事件是否正确
  • 测试不同状态下 UI 元素的位置和距离
  • 截取该栏的屏幕截图,并将其与以前的屏幕截图进行比较

关于测试组件的数量或方式没有确切的规则。您可以执行以上所有操作!在本 Codelab 中,您将通过验证以下内容来测试状态逻辑是否正确:

  • 仅当标签被选中时,才会显示其标签.
  • 活动屏幕定义选定的标签

3. 创建一个简单的 UI 测试

创建 TopAppBarTest 文件

在与 AnimatingCircleTests.kt 相同的文件夹(app/src/androidTest/com/example/compose/rally)中创建一个新文件,并将其命名为 TopAppBarTest.kt

Compose 带有一个 ComposeTestRule,您可以通过调用 createComposeRule() 来获取。此规则允许您设置要测试的 Compose 内容并与之交互。

添加 ComposeTestRule

package com.example.compose.rally

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    
    // TODO: Add tests
}

隔离测试

在 Compose 测试中,我们可以像在 Android View 世界中使用 Espresso 一样启动应用的主 Activity。您可以使用 createAndroidComposeRule 来做到这一点。

// Don't copy this over

@get:Rule
val composeTestRule = createAndroidComposeRule(RallyActivity::class.java)

但是,使用 Compose,我们可以通过隔离测试组件来大大简化操作。您可以选择在测试中使用哪些 Compose UI 内容。这可以通过 ComposeTestRulesetContent 方法来完成,您可以在任何地方(但只调用一次)调用它。

// Don't copy this over

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun myTest() {
        composeTestRule.setContent { 
            Text("You can set any Compose content!")
        }
    }
}

我们想要测试 TopAppBar,因此让我们专注于此。在 setContent 内部调用 RallyTopAppBar,并让 Android Studio 完成参数的名称。

import androidx.compose.ui.test.junit4.createComposeRule
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test

class TopAppBarTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun rallyTopAppBarTest() {
        composeTestRule.setContent { 
            RallyTopAppBar(
                allScreens = ,
                onTabSelected = { /*TODO*/ },
                currentScreen = 
            )
        }
    }
}

可测试 Composable 的重要性

RallyTopAppBar 接受三个易于提供的参数,因此我们可以传递我们控制的伪数据。例如

    @Test
    fun rallyTopAppBarTest() {
        val allScreens = RallyScreen.values().toList()
        composeTestRule.setContent { 
            RallyTopAppBar(
                allScreens = allScreens,
                onTabSelected = { },
                currentScreen = RallyScreen.Accounts
            )
        }
        Thread.sleep(5000)
    }

我们还添加了 sleep(),以便您可以看到正在发生的事情。右键单击 rallyTopAppBarTest,然后单击“运行 rallyTopAppBarTest()...”。

baca545ddc8c3fa9.png

测试显示了顶部应用栏(持续 5 秒),但它看起来与我们预期的不符:它有一个浅色主题!

原因是该栏是使用 Material Components 构建的,这些组件期望位于 MaterialTheme 中,否则它们会回退到“基线”样式颜色。

MaterialTheme 具有良好的默认值,因此不会崩溃。由于我们不会测试主题或截取屏幕截图,因此我们可以省略它并使用其默认的浅色主题。您可以随意使用 RallyTheme 包裹 RallyTopAppBar 以修复它。

验证标签是否已选中

查找 UI 元素、检查其属性和执行操作是通过测试规则完成的,遵循以下模式

composeTestRule{.finder}{.assertion}{.action}

在此测试中,您将查找“帐户”一词,以验证是否显示了选定标签的标签。

baca545ddc8c3fa9.png

了解可用工具的一种好方法是使用 Compose 测试备忘单测试包参考文档。查找可能对我们的情况有帮助的查找器和断言。例如:onNodeWithTextonNodeWithContentDescriptionisSelectedhasContentDescriptionassertIsSelected...

每个标签都有不同的内容描述

  • 概述
  • 帐户
  • 账单

了解这一点后,用查找内容描述并断言其存在的语句替换 Thread.sleep(5000)

import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithContentDescription
...

@Test
fun rallyTopAppBarTest_currentTabSelected() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertIsSelected()
}

现在再次运行测试,您应该会看到一个绿色的测试

75bab3b37e795b65.png

恭喜!您已经编写了第一个 Compose 测试。您已经学习了如何进行隔离测试以及如何使用查找器和断言。

这很简单,但需要一些关于组件的先验知识(内容描述和 selected 属性)。您将在下一步学习如何检查哪些属性可用。

4. 调试测试

在此步骤中,您将验证当前标签的标签是否以大写形式显示。

baca545ddc8c3fa9.png

一个可能的解决方案是尝试查找文本并断言其存在

import androidx.compose.ui.test.onNodeWithText
...

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.uppercase())
        .assertExists()
}

但是,如果您运行测试,它会失败😱

5755586203324389.png

在此步骤中,您将学习如何使用语义树进行调试。

语义树

Compose 测试使用称为 语义树 的结构来查找屏幕上的元素并读取其属性。这也是辅助功能服务使用的结构,因为它们旨在被诸如 TalkBack 之类的服务读取。

您可以使用节点上的 printToLog 函数打印语义树。在测试中添加新行

import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...

fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule.onRoot().printToLog("currentLabelExists")

    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.uppercase())
        .assertExists() // Still fails
}

现在运行测试并查看 Android Studio 中的 Logcat(您可以查找 currentLabelExists)。

...com.example.compose.rally D/currentLabelExists: printToLog:
    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

查看语义树,您可以看到有一个 SelectableGroup 具有 3 个子元素,它们是顶部应用栏的标签。事实证明,没有值为“ACCOUNTS”的 text 属性,这就是测试失败的原因。但是,每个标签都有一个 内容描述。您可以在 RallyTopAppBar.kt 中的 RallyTab 可组合项中检查此属性是如何设置的

private fun RallyTab(text: String...)
...
    Modifier
        .clearAndSetSemantics { contentDescription = text }

此修饰符正在清除后代的属性并设置其自己的内容描述,因此这就是您看到“帐户”而不是“ACCOUNTS”的原因。

onNodeWithContentDescription 替换查找器 onNodeWithText 并再次运行测试

fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertExists()
}

b5a7ae9f8f0ed750.png

恭喜!您已修复了测试,并了解了ComposeTestRule、隔离测试、查找器、断言以及使用语义树进行调试。

但坏消息是:此测试没有太大用处!如果您仔细查看语义树,您会发现所有三个选项卡的内容描述都存在,无论其选项卡是否被选中。我们必须更深入地研究!

5. 合并和未合并的语义树

语义树始终尝试尽可能简洁,只显示相关信息。

例如,在我们的TopAppBar中,图标和标签无需成为不同的节点。请查看“概述”节点

120e5327856286cd.png

        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'

此节点具有专门为selectable组件定义的属性(例如SelectedRole),以及整个选项卡的内容描述。这些是高级属性,对简单的测试非常有用。关于图标或文本的详细信息将是冗余的,因此不会显示。

Compose 在某些可组合项(如Text)中自动公开这些语义属性。您还可以自定义并合并它们以表示由一个或多个子元素组成的单个组件。例如:您可以表示包含Text可组合项的Button。属性MergeDescendants = 'true'告诉我们,**此节点有子元素,但它们已被合并到其中**。在测试中,我们经常需要访问所有节点。

为了验证选项卡内的Text是否显示,我们可以查询未合并的语义树,将useUnmergedTree = true传递给onRoot查找器。

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

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


}

现在 Logcat 中的输出略长一些

    Printing with useUnmergedTree = 'true'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |  |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
        |    Text = 'ACCOUNTS'
        |    Actions = [GetTextLayoutResult]
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

节点 #3 仍然没有子元素

        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'

但节点 6(选定的选项卡)有一个,我们现在可以看到“Text”属性

        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        |  |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
        |    Text = 'ACCOUNTS'
        |    Actions = [GetTextLayoutResult]

为了验证我们想要的正确行为,您将编写一个匹配器,查找一个文本为“ACCOUNTS”且父节点内容描述为“Accounts”的节点。

再次查看Compose 测试备忘单,并尝试找到编写该匹配器的方法。请注意,您可以使用布尔运算符(如andor)与匹配器一起使用。

所有查找器都有一个名为useUnmergedTree的参数。将其设置为true以使用未合并的树。

尝试在不查看解决方案的情况下编写测试!

解决方案

import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
...

@Test
fun rallyTopAppBarTest_currentLabelExists() {
    val allScreens = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }

    composeTestRule
        .onNode(
            hasText(RallyScreen.Accounts.name.uppercase()) and
            hasParent(
                hasContentDescription(RallyScreen.Accounts.name)
            ),
            useUnmergedTree = true
        )
        .assertExists()
}

继续运行它

94c57e2cfc12c10b.png

恭喜!在本步骤中,您了解了属性合并以及合并和未合并的语义树。

6. 同步

您编写的任何测试都必须与被测对象正确同步。例如,当您使用onNodeWithText之类的查找器时,测试会在查询语义树之前等待应用程序空闲。如果没有同步,测试可能会在元素显示之前查找它们,或者可能会不必要地等待。

在本步骤中,我们将使用“概述”屏幕,当您运行应用程序时,它看起来如下所示

8c467af3570b8de6.gif

请注意“警报”卡片的重复闪烁动画,它会吸引您注意此元素。

创建另一个名为OverviewScreenTest的测试类,并添加以下内容

package com.example.compose.rally

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.example.compose.rally.ui.overview.OverviewBody
import org.junit.Rule
import org.junit.Test

class OverviewScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun overviewScreen_alertsDisplayed() {
        composeTestRule.setContent {
            OverviewBody()
        }

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

如果运行此测试,您会注意到它永远不会完成(在 30 秒后超时)。

b2d71bd417326bd3.png

错误提示为

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
IdlingResourceRegistry has the following idling resources registered:
- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91 

这基本上是在告诉您 Compose 永久处于繁忙状态,因此无法将应用程序与测试同步。

您可能已经猜到问题是无限闪烁动画。应用程序永远不会空闲,因此测试无法继续。

让我们看看无限动画的实现

app/src/main/java/com/example/compose/rally/ui/overview/OverviewBody.kt

var currentTargetElevation by remember {  mutableStateOf(1.dp) }
LaunchedEffect(Unit) {
    // Start the animation
    currentTargetElevation = 8.dp
}
val animatedElevation = animateDpAsState(
    targetValue = currentTargetElevation,
    animationSpec = tween(durationMillis = 500),
    finishedListener = {
        currentTargetElevation = if (currentTargetElevation > 4.dp) {
            1.dp
        } else {
            8.dp
        }
    }
)
Card(elevation = animatedElevation.value) { ... }

此代码本质上是在等待动画完成(finishedListener),然后再次运行它。

解决此测试的一种方法是在开发者选项中禁用动画。这是在View世界中广泛接受的处理方法之一。

在 Compose 中,动画 API 的设计考虑到了可测试性,因此可以通过使用正确的 API 来解决此问题。与其重新启动animateDpAsState动画,我们可以使用无限动画

使用正确的 API 替换OverviewScreen中的代码

import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Dp
...

    val infiniteElevationAnimation = rememberInfiniteTransition()
    val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
        initialValue = 1.dp,
        targetValue = 8.dp,
        typeConverter = Dp.VectorConverter,
        animationSpec = infiniteRepeatable(
            animation = tween(500),
            repeatMode = RepeatMode.Reverse
        )
    )
    Card(elevation = animatedElevation) {

如果运行测试,它现在将通过

369e266eed40e4e4.png

恭喜!在本步骤中,您了解了同步以及动画如何影响测试。

7. 可选练习

在本步骤中,您将使用操作(请参阅测试备忘单)来验证单击RallyTopAppBar的不同选项卡是否会更改选择。

提示

  • 测试的范围需要包含状态,该状态由RallyApp拥有。
  • 验证状态,而不是行为。对 UI 的状态使用断言,而不是依赖于已调用哪些对象以及如何调用。

此练习没有提供的解决方案。

8. 下一步

恭喜!您已完成Jetpack Compose 中的测试。现在,您拥有创建 Compose UI 良好测试策略的基本构建块。

如果您想了解有关测试和 Compose 的更多信息,请查看以下资源

  1. 测试文档提供了有关查找器、断言、操作和匹配器以及同步机制、时间操纵等的更多信息。
  2. 收藏测试备忘单
  3. Rally 示例附带一个简单的屏幕截图测试类。浏览AnimatingCircleTests.kt文件以了解更多信息。
  4. 有关测试 Android 应用程序的一般指南,您可以遵循以下三个代码实验室
  1. Github 上的Compose 示例存储库包含多个具有 UI 测试的应用程序。
  2. Jetpack Compose 学习路径显示了开始使用 Compose 的资源列表。

测试愉快!