在 Jetpack Compose 中进行测试

1. 简介与设置

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

所需条件

查看此 Codelab 的代码 (Rally)

您将使用 Rally Material 研究作为此 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 的标签页栏,该栏包含一行标签页(“Overview”“Accounts”和“Bills”)。在上下文中,此界面如下所示:

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 测试中,我们可以启动应用的主 activity,操作方式与您在 Android View 环境中使用 Espresso 执行同样的操作类似。您可以使用 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 =
            )
        }
    }
}

可测试的可组合项的重要性

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 组件构建的,Material 组件应在 MaterialTheme 内,否则就会回退到“基准”样式的颜色。

MaterialTheme 具有合适的默认设置,故而测试没有崩溃。由于我们不准备测试主题或截取屏幕截图,因此可以忽略此问题而继续使用默认的浅色主题。不过,如果您愿意,也可以使用 RallyTheme 封装 RallyTopAppBar 来解决此问题。

验证标签页是否处于选中状态

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

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

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

baca545ddc8c3fa9.png

如需了解有哪些工具可用,建议您参阅 Compose 测试备忘单测试软件包参考文档。您可以在其中查找有助于进行此测试的查找器和断言。例如:onNodeWithTextonNodeWithContentDescriptionisSelectedhasContentDescriptionassertIsSelected...

每个标签页都有一个不同的内容说明:

  • Overview
  • Accounts
  • Bills

知道这一点后,请将 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 个子元素,这些子元素就是顶部应用栏的标签页。结果显示,没有哪一个 text 属性的值为“ACCOUNTS”,这正是测试失败的原因。不过,每个标签页都有内容说明。您可以在 RallyTopAppBar.kt 内查看 RallyTab 可组合项中对此属性是如何设置的:

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 是否会显示,我们可以将 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 等查找器时,测试会一直等到应用进入空闲状态后才查询语义树。如果不同步,测试就可能会在元素显示之前查找元素,或者不必要地等待。

这一步我们将使用“Overview”屏幕,在应用运行时,此屏幕如下所示:

8c467af3570b8de6.gif

请注意,“Alerts”卡片显示了反复闪烁的动画,吸引用户注意该元素。

创建另一个名为 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 的资源。

祝您测试顺利!