Jetpack Compose 中的测试

1. 简介和设置

在本 Codelab 中,您将了解如何测试使用 Jetpack Compose 创建的界面。您将编写第一个测试,同时了解隔离测试、调试测试、语义树和同步。

您需要准备什么

查看本 Codelab 的代码 (Rally)

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

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

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

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

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

检查项目结构

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

Rally 已经包含一些插桩界面测试。您可以在 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 中,您将测试该栏的界面。

这可能意味着很多事情

  • 测试标签页是否显示预期图标和文本
  • 测试动画是否符合规范
  • 测试触发的导航事件是否正确
  • 测试界面元素在不同状态下的位置和距离
  • 截取该栏的屏幕截图并与之前的屏幕截图进行比较

关于组件的测试量或测试方式没有确切的规则。您可以做以上所有事情!在本 Codelab 中,您将通过验证以下各项来测试状态逻辑是否正确:

  • 标签页仅在选中时才显示其标签.
  • 活动屏幕定义选定的标签页

3. 创建简单的界面测试

创建 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 界面内容。这通过 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,然后点击“Run rallyTopAppBarTest()...”。

baca545ddc8c3fa9.png

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

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

MaterialTheme 具有良好的默认设置,因此它不会崩溃。由于我们不打算测试主题或截屏,因此可以省略它并使用其默认的浅色主题。随意用 RallyTheme 包装 RallyTopAppBar 来修复它。

验证标签页是否选中

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

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

在此测试中,您将查找单词“Accounts”以验证是否显示了所选标签页的标签。

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 测试。您已学会了如何隔离测试以及如何使用查找器和断言。

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

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 composable 中是如何设置的。

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

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

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

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 中,图标和标签不需要是不同的节点。请看“Overview”节点

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 是否显示,我们可以通过向 onRoot 查找器传递 useUnmergedTree = true 来查询未合并的语义树。

@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 动画,不如使用无限动画

OverviewScreen 中的代码替换为正确的 API

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 拥有。
  • 验证状态,而不是行为。对界面的状态使用断言,而不是依赖于调用了哪些对象以及如何调用。

本练习没有提供解决方案。

8. 后续步骤

恭喜!您已完成了Jetpack Compose 中的测试。现在您已具备为 Compose 界面创建良好测试策略的基本构建块。

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

  1. 测试文档包含更多关于查找器、断言、操作和匹配器以及同步机制、时间操作等信息。
  2. 收藏测试速查表
  3. Rally 示例附带了一个简单的屏幕截图测试类。探索 AnimatingCircleTests.kt 文件以了解更多信息。
  4. 有关测试 Android 应用的通用指南,您可以学习以下三个 Codelab:
  1. Github 上的 Compose 示例代码库有多个带有界面测试的应用。
  2. Jetpack Compose 学习路径列出了帮助您开始使用 Compose 的资源。

测试愉快!