1、简介与设置
您将了解如何测试使用Jetpack Compose创建的界面。您将编写您的第一项测试,并在此过程中了解隔离测试、调试测试、语义树和同步。
您将使用 Rally Material 研究作为此 Codelab 的基础。您可以在 android-compose-codelabs GitHub 代码库中找到此代码。如需克隆,请运行以下命令:
git clone https://github.com/android/codelab-android-compose.git
下载完成后,打开 TestingCodelab
项目。
查看项目结构
Compose测试时插桩测试。这意味着,这些测试需要在设备上运行。
Rally已包含一些插桩界面测试。您可以在androidTest源代码集中找到这些测试。
图中橙色方框中显示的是用于保存新测试的目录。您可以查看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”)。在上下文中,此界面如下所示:
此测试包含许多内容:
- 测试标签页是否会显示预期图标和温恩
- 测试动画是否符合规范
- 测试触发的导航时间是否正确
- 测试界面元素在不同状态下的放置位置和距离
- 截取该栏的屏幕截图,并将其与之前截取的屏幕截图进行比较
我们对组件的测试或测试方式并没有具体的规定。您将通过验证以下各项来测试状态逻辑是否正确:
- 标签页是否仅在选中状态下才显示标签
- 当前屏幕是否明确显示了处于选中状态的标签页
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界面内容,具体可通过调用ComposeTestRule
的setContent
方法来完成。该方法可在任何位置调用(但只能调用一次)
// 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()”
该测试显示了顶部应用栏(5秒钟),但与我们的预期不同:该栏使用的是浅色主题!
其原因在于,该栏是使用Material组件构建的,Material组件应在MaterialTheme
内,否则就会回退到“基准”样式的颜色。
MaterialTheme
具有合适的默认设置,故而测试没有崩溃。由于我们不准备已测试主题或接入屏幕截图,因此可以忽略此问题而继续使用默认的浅色主题。不过,如果您愿意,也可以使用RallyTheme
封装RallyTopAppBar
来解决此问题。
3.5、验证标签页是否处于选中状态
查找界面元素、检查其属性和执行操作是按照以下模式通过测试规则完成的:
composeTestRule{.finder}{.assertion}{.action}
在此测试中,您将查找“Accounts”一词,以验证是否显示了处于选中状态的标签页的标签。
除此之外还有一些有助于进行次测试的查找器和断言,例如:onNodeWithText
、onNodeWithContentDescription
、isSelected
、hasContentDescription
、assertIsSelected
...
每个标签都有一个不同的内容说明:
- 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()
}
现在再次运行测试,这次测试应该会通过:
您已成功编写您的第一项Compose测试。在此过程中,您了解了如何进行隔离测试,以及如何使用查找器和断言。
4、调试测试
在此步骤中,您将验证是否显示了当前标签页的标签(全部使用大写字母)。
一种可能的解决方法是尝试查找标签的文本并断言其存在:
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()
}
不过,如果您运行该测试,结果会失败
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()
}
恭喜!您已修复了测试,并了解了ComposeTestRule
、隔离测试、超朝气、断言和使用语义树进行调试。
不过,也有一个坏消息:这项测试并不是很有用!仔细查看语义树就会发现,无论三个标签页是否处于选中状态,它们的内容说明都会显示在树中。我们必须继续深入。
5、合并和未合并的语义树
语义树总是尽可能的精简,仅显示相关的信息。
例如,在我们的TopAppBar
中,没有必要将图标和标签作为不同的节点。我们来看看“Overview”节点:
|-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
组件定义的属性(例如Selected
和Role
),以及对整个标签页的内容说明。这些都是很笼统的属性,对简单的测试非常有用。有关图标或文本的详细信息在此是多余的,因此不会显示。
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 测试备忘单,并试着设法编写该匹配器。请注意,您可以将and
和or
等布尔元素安抚和匹配器结合起来使用。
所有查找器都有一个名为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()
}
开始运行测试:
这一步我们将使用“Overview”屏幕,在应用运行时,此屏幕如下所示:
创建另一个名为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秒后超时)
您可能已经猜到了,问题出在无情无尽的闪烁动画上。应用永不空闲,因此测试无法继续。
我们来看看这个无穷无尽动画的实现:
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) {}
如果您运行该测试,现在测试会通过:
恭喜,您已在这一步中了解了同步以及动画对测试的影响。