Jetpack Compose(第十趴)——在Jetpack Compose中进行测试

1,212 阅读10分钟

1、简介与设置

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

您将使用 Rally Material 研究作为此 Codelab 的基础。您可以在 android-compose-codelabs GitHub 代码库中找到此代码。如需克隆,请运行以下命令:

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

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

查看项目结构

Compose测试时插桩测试。这意味着,这些测试需要在设备上运行。

Rally已包含一些插桩界面测试。您可以在androidTest源代码集中找到这些测试。

image.png

图中橙色方框中显示的是用于保存新测试的目录。您可以查看AnimatingCircleTest.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”)。在上下文中,此界面如下所示:

4.gif

此测试包含许多内容:

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

我们对组件的测试或测试方式并没有具体的规定。您将通过验证以下各项来测试状态逻辑是否正确:

  • 标签页是否仅在选中状态下才显示标签
  • 当前屏幕是否明确显示了处于选中状态的标签页

3、创建简单的界面测试

3.1、创建TopAppBarTest文件

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

Compose提供一个ComposeTestRule,调用createComposeRule()即可获得此规则。您可以通过此规则设置被测Compose内容并与其交互。

3.2、添加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
}

3.3、隔离测试

在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 =
            )
        }
    }
}

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

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()”

image.png

该测试显示了顶部应用栏(5秒钟),但与我们的预期不同:该栏使用的是浅色主题!

其原因在于,该栏是使用Material组件构建的,Material组件应在MaterialTheme内,否则就会回退到“基准”样式的颜色。

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

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

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

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

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

image.png

除此之外还有一些有助于进行次测试的查找器和断言,例如:onNodeWithTextonNodeWithContentDescriptionisSelectedhasContentDescriptionassertIsSelected...

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

  • Overview
  • Accounts
  • Bills

知道这一点后,请将Thread.sleep(5000)替换为用于查找内容说明并断言其存在的语句:

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

@Test
fun rallyTopBarTest_currentTabSelected() {
    val allScreen = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreen = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }
    
    composeTestRule
        .onNodeWithContentDescription(RallyScreen.Accounts.name)
        .assertIsSelected()
}

现在再次运行测试,这次测试应该会通过:

image.png

您已成功编写您的第一项Compose测试。在此过程中,您了解了如何进行隔离测试,以及如何使用查找器和断言。

4、调试测试

在此步骤中,您将验证是否显示了当前标签页的标签(全部使用大写字母)。

image.png

一种可能的解决方法是尝试查找标签的文本并断言其存在:

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

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

不过,如果您运行该测试,结果会失败

image.png

4.1、语义树

Compose测试使用称为语义树的结构来查找屏幕上的元素并读取其属性。这也是无障碍服务使用的结构,因为无障碍服务按理会被TalkBack等服务读取。

您可以对节点使用printToLog函数来输出语义树。将一行新代码添加到测试中:

import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...
fun rallyTopAppBar_currentLabelExists() {
    val allScreen = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBar(
            allScreens = allScreens,
            onTabSelected = { },
            currentScreen = RallyScreen.Accounts
        )
    }
    
    composeTestRule.onRoot().printToLog("currentLabelExists")
    
    composeTestRule
        .onNodeWithText(RallyScreen.Accounts.name.toUpperCase())
        .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”,这正是测试失败的原因。不过,每个标签页都有ContentDescription。您可以在RallyTopAppBar.kt内查看RallyTab可组合项中对此属性是如何设置的:

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

此修饰符会清除来自后代的属性并设置自己的内容说明,因此您会看到“Accounts”而不是“ACCOUNTS”。

将查找器onNodeWithText替换为onNodeWithContentDescription,然后再次运行测试:

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

image.png

恭喜!您已修复了测试,并了解了ComposeTestRule、隔离测试、超朝气、断言和使用语义树进行调试。

不过,也有一个坏消息:这项测试并不是很有用!仔细查看语义树就会发现,无论三个标签页是否处于选中状态,它们的内容说明都会显示在树中。我们必须继续深入。

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

语义树总是尽可能的精简,仅显示相关的信息。

例如,在我们的TopAppBar中,没有必要将图标和标签作为不同的节点。我们来看看“Overview”节点:

image.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 allScreen = RallyScreen.values().toList()
    composeTestRule.setContent {
        RallyTopAppBae(
            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()
}

开始运行测试:

image.png

恭喜!您已在这一步中了解了属性合并,以及合并的语义树和未合并的语义树。 ## 6、同步 您编写的任何测试都必须与被测对象正确同步。例如,当您使用`onNodeWithText`等查找器时,测试会一直等到应用进入空闲状态后才查询语义树。如果不同步,测试就可能会在元素显示之前查找元素,或者不必要都等待。

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

5.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("Alert")
            .assertIsDisplayed()
    }
}

如果运行此测试,您会发现测试永远无法完成(它会在30秒后超时)

image.png

错误消息如下: ``` androdix.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy. IdlingResourceRegistry has the following idling resoures registered: - [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91 ``` 此消息大致表示Compose一直处于忙碌状态,因此没有办法与测试同步应用。

您可能已经猜到了,问题出在无情无尽的闪烁动画上。应用永不空闲,因此测试无法继续。

我们来看看这个无穷无尽动画的实现:

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

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来解决该问题。我们可以使用正确的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) {}

如果您运行该测试,现在测试会通过:

image.png

恭喜,您已在这一步中了解了同步以及动画对测试的影响。