测试界面或测试屏幕可用于验证 Compose 代码的行为是否正确,通过在开发过程的早期阶段发现错误来提高应用的质量。
Compose 提供了一组测试 API,用于查找元素、验证其属性以及执行用户操作。这些 API 还包括时间控制等高级功能。
语义
Compose 中的界面测试使用语义与界面层次结构进行交互。顾名思义,语义就是为一部分界面赋予意义。在此处,“一部分界面”(或一个元素)可以表示从单个可组合项到整个屏幕的任何内容。语义树与界面层次结构一起生成,并对其进行描述。
图 1. 典型的界面层次结构及其语义树。
语义框架主要用于无障碍功能,因此测试会利用语义提供的有关界面层次结构的信息。由开发者决定要提供哪些信息以及提供多少信息。
图 2. 一个包含图标和文本的典型按钮。
例如,假设有一个这样的按钮,它由一个图标和一个文本元素组成,默认语义树仅包含文本标签“Like”。这是因为,某些可组合项(例如 Text
)已经向语义树公开了一些属性。您可以使用 Modifier
向语义树添加属性。
MyButton(
modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)
设置
本部分介绍如何设置模块,以便您测试 Compose 代码。
首先,将以下依赖项添加到包含界面测试的模块的 build.gradle
文件中:
// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createAndroidComposeRule, but not createComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")
此模块包含一个 ComposeTestRule
和一个名为 AndroidComposeTestRule
的 Android 实现。通过此规则,您可以设置 Compose 内容或访问 activity。Compose 的典型界面测试如下所示:
// file: app/src/androidTest/java/com/package/MyComposeTest.kt
class MyComposeTest {
@get:Rule
val composeTestRule = createComposeRule()
// use createAndroidComposeRule<YourActivity>() if you need access to
// an activity
@Test
fun myTest() {
// Start the app
composeTestRule.setContent {
MyAppTheme {
MainScreen(uiState = fakeUiState, /*...*/)
}
}
composeTestRule.onNodeWithText("Continue").performClick()
composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
}
}
测试 API
与元素交互的方式主要有以下三种:
- 查找器:可供您选择一个或多个元素(或语义树中的节点),以进行断言或对其执行操作。
- 断言:用于验证元素是否存在或者具有某些属性。
- 操作:会在元素上注入模拟的用户事件,例如点击或其他手势。
其中一些 API 接受 SemanticsMatcher
来引用语义树中的一个或多个节点。
查找器
您可以使用 onNode
和 onAllNodes
分别选择一个或多个节点,但也可以使用便捷查找器进行最常见的搜索,例如 onNodeWithText
、onNodeWithContentDescription
等。您可以在 Compose Testing 备忘单中浏览完整列表。
选择单个节点
composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
.onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")
选择多个节点
composeTestRule
.onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
.onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")
使用未合并的树
某些节点会合并其子项的语义信息。例如,一个包含两个文本元素的按钮会合并这两个文本元素的标签:
MyButton {
Text("Hello")
Text("World")
}
在测试中,我们可以使用 printToLog()
来显示语义树:
composeTestRule.onRoot().printToLog("TAG")
此代码会生成以下输出:
Node #1 at (...)px
|-Node #2 at (...)px
Role = 'Button'
Text = '[Hello, World]'
Actions = [OnClick, GetTextLayoutResult]
MergeDescendants = 'true'
如果您需要匹配未合并的树的节点,可以将 useUnmergedTree
设为 true
:
composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")
此代码会生成以下输出:
Node #1 at (...)px
|-Node #2 at (...)px
OnClick = '...'
MergeDescendants = 'true'
|-Node #3 at (...)px
| Text = '[Hello]'
|-Node #5 at (83.0, 86.0, 191.0, 135.0)px
Text = '[World]'
useUnmergedTree
参数在所有查找器中都可用。例如,此处它用在 onNodeWithText
查找器中。
composeTestRule
.onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()
断言
您可以通过对带有一个或多个匹配器的查找器返回的 SemanticsNodeInteraction
调用 assert()
来检查断言:
// Single matcher:
composeTestRule
.onNode(matcher)
.assert(hasText("Button")) // hasText is a SemanticsMatcher
// Multiple matchers can use and / or
composeTestRule
.onNode(matcher).assert(hasText("Button") or hasText("Button2"))
您还可以对最常见的断言使用便捷函数,例如 assertExists
、assertIsDisplayed
、assertTextEquals
等。您可以在 Compose Testing 备忘单中浏览完整列表。
还有一些函数用于检查一系列节点上的断言:
// Check number of matched nodes
composeTestRule
.onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
.onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
.onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())
操作
如需在节点上注入操作,请调用 perform…()
函数:
composeTestRule.onNode(...).performClick()
下面是一些操作示例:
performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }
您可以在 Compose Testing 备忘单中浏览完整列表。
匹配器
本部分介绍可用于测试 Compose 代码的一些匹配器。
分层匹配器
分层匹配器可让您在语义树中向上或向下移动并执行简单的匹配。
fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher): SemanticsMatcher
下面是一些使用这些匹配器的示例:
composeTestRule.onNode(hasParent(hasText("Button")))
.assertIsDisplayed()
选择器
创建测试的另一种方法是使用选择器,这样可提高一些测试的可读性。
composeTestRule.onNode(hasTestTag("Players"))
.onChildren()
.filter(hasClickAction())
.assertCountEquals(4)
.onFirst()
.assert(hasText("John"))
您可以在 Compose Testing 备忘单中浏览完整列表。
同步
默认情况下,Compose 测试会与界面同步。通过 ComposeTestRule
调用断言或操作时,测试将预先同步,直到界面树处于空闲状态。
通常,您无需执行任何操作。但是,您应该了解一些极端情况。
同步测试时,您可以使用虚拟时钟将 Compose 应用的时间提前。这意味着 Compose 测试不会实时运行,从而能够尽快通过测试。
但是,如果您不使用同步测试的方法,则不会发生任何重组,并且界面会暂停。
@Test
fun counterTest() {
val myCounter = mutableStateOf(0) // State that can cause recompositions
var lastSeenValue = 0 // Used to track recompositions
composeTestRule.setContent {
Text(myCounter.value.toString())
lastSeenValue = myCounter.value
}
myCounter.value = 1 // The state changes, but there is no recomposition
// Fails because nothing triggered a recomposition
assertTrue(lastSeenValue == 1)
// Passes because the assertion triggers recomposition
composeTestRule.onNodeWithText("1").assertExists()
}
另外还需要注意的是,此要求仅适用于 Compose 层次结构,而不适用于应用的其余部分。
停用自动同步功能
通过 ComposeTestRule
(如 assertExists()
)调用断言或操作时,您的测试会与 Compose 界面同步。在某些情况下,您可能需要停止此同步并自行控制时钟。例如,您可以控制时间,以便在界面仍处于繁忙状态时对动画进行精确截图。如需停用自动同步功能,请将 mainClock
中的 autoAdvance
属性设置为 false
:
composeTestRule.mainClock.autoAdvance = false
一般情况下,您需要自行将时间提前。您可以使用 advanceTimeByFrame()
仅提前一帧,或使用 advanceTimeBy()
提前一段特定时间:
composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)
空闲资源
Compose 可以同步测试和界面,以便以空闲状态完成各项操作和断言,从而根据需要等待或将时钟提前。但是,某些影响界面状态的异步操作可在后台运行,而测试无法得知这些结果。
您可以在测试中创建并注册这些空闲资源,以便在确定受测应用是忙碌还是空闲时将这些资源考虑在内。除非需要注册其他空闲资源(例如,如果您运行的后台作业未与 Espresso 或 Compose 同步),否则无需执行任何操作。
此 API 与 Espresso 的空闲资源非常相似,用于指示受测对象是空闲还是忙碌。您可以使用 Compose 测试规则注册 IdlingResource
的实现。
composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)
手动同步
在某些情况下,您必须将 Compose 界面与测试的其他部分或您测试的应用同步。
waitForIdle
等待 Compose 空闲,但这取决于 autoAdvance
属性:
composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle
composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle
请注意,在这两种情况下,waitForIdle
还将等待待定的绘图和布局通过。
此外,您还可以将时钟提前,直到满足 advanceTimeUntil()
的特定条件为止。
composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }
请注意,给定条件应当检查受到此时钟影响的状态(仅适用于 Compose 状态)。依赖于 Android 的测量或绘制(即 Compose 外部的测量或绘制)的任何条件应使用更为宽泛的概念,例如 waitUntil()
:
composeTestRule.waitUntil(timeoutMs) { condition }
常见模式
本部分介绍您会在 Compose 测试中看到的一些常见方法。
单独进行测试
ComposeTestRule
可让您启动显示任何可组合项的 activity:整个应用、单个屏幕或小元素。此外,最好检查可组合项是否被正确封装以及它们是否独立工作,从而使界面测试更容易且更有针对性。
这并不意味着您应仅创建单元界面测试。范围涵盖更大一部分界面的界面测试也非常重要。
在设置自己的内容后访问 activity 和资源
通常,您需要使用 composeTestRule.setContent
设置受测内容,还需要访问 activity 资源,例如,断言显示的文本与字符串资源是否匹配。不过,如果 setContent
已被 activity 调用,则无法再对使用 createAndroidComposeRule()
创建的规则进行调用。
实现此行为的一种常见模式是使用空 activity(例如 ComponentActivity
)创建 AndroidComposeTestRule
。
class MyComposeTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun myTest() {
// Start the app
composeTestRule.setContent {
MyAppTheme {
MainScreen(uiState = exampleUiState, /*...*/)
}
}
val continueLabel = composeTestRule.activity.getString(R.string.next)
composeTestRule.onNodeWithText(continueLabel).performClick()
}
}
请注意,您需要将 ComponentActivity
添加到应用的 AndroidManifest.xml
文件中。为此,您可以将以下依赖项添加到模块中:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")
自定义语义属性
您可以创建自定义语义属性,向测试提供相关信息。为此,请定义一个新的 SemanticsPropertyKey
并使用 SemanticsPropertyReceiver
使之可用。
// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey
现在,您可以通过 semantics
修饰符使用该属性:
val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
modifier = Modifier.semantics { pickedDate = datePickerValue }
)
在测试中,您可以使用 SemanticsMatcher.expectValue
断言该属性的值:
composeTestRule
.onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
.assertExists()
验证状态恢复
您应验证在重新创建 activity 或进程后,Compose 元素的状态是否会正确恢复。您可以使用 StateRestorationTester
类执行此类检查,而无需依赖于 activity 重新创建。
借助该类,您可以对可组合项的重新创建进行模拟。这对验证 rememberSaveable
的实现特别有用。
class MyStateRestorationTests {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun onRecreation_stateIsRestored() {
val restorationTester = StateRestorationTester(composeTestRule)
restorationTester.setContent { MainScreen() }
// TODO: Run actions that modify the state
// Trigger a recreation
restorationTester.emulateSavedInstanceStateRestore()
// TODO: Verify that state has been correctly restored.
}
}
调试
解决测试中的问题的主要方法是查看语义树。您可以在测试过程中的任何时间调用 composeTestRule.onRoot().printToLog()
来输出语义树。此函数会输出如下日志:
Node #1 at (...)px
|-Node #2 at (...)px
OnClick = '...'
MergeDescendants = 'true'
|-Node #3 at (...)px
| Text = 'Hi'
|-Node #5 at (83.0, 86.0, 191.0, 135.0)px
Text = 'There'
这些日志包含一些有价值的信息,可用于跟踪错误。
与 Espresso 的互操作性
在混合应用中,您可以在视图层次结构中找到 Compose 组件,在 Compose 可组合项中找到视图(通过 AndroidView
可组合项)。
无需执行任何特殊步骤即可匹配这两种类型。您可以通过 Espresso 的 onView
匹配视图,并通过 ComposeTestRule
匹配 Compose 元素。
@Test
fun androidViewInteropTest() {
// Check the initial state of a TextView that depends on a Compose state:
Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
// Click on the Compose button that changes the state
composeTestRule.onNodeWithText("Click here").performClick()
// Check the new value
Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}
与 UiAutomator 的互操作性
默认情况下,只能通过便捷描述符(显示的文本、内容说明等)从 UiAutomator 访问可组合项。如果您想访问使用 Modifier.testTag
的任何可组合项,则需要为特定可组合项子树启用语义属性 testTagAsResourceId
。对于没有任何其他唯一句柄的可组合项(例如 LazyColumn
等可滚动的可组合项),启用此行为非常有用。
只能在可组合项层次结构中的较高层级将其启用一次,以确保可以从 UiAutomator 访问具有 Modifier.testTag
的所有嵌套可组合项。
Scaffold(
// Enables for all composables in the hierarchy.
modifier = Modifier.semantics {
testTagsAsResourceId = true
}
){
// Modifier.testTag is accessible from UiAutomator for composables nested here.
LazyColumn(
modifier = Modifier.testTag("myLazyColumn")
){
// content
}
}
任何具有 Modifier.testTag(tag)
的可组合项都可通过使用 By.res(resourceName)
并利用相同的 tag
作为 resourceName
的方式访问。
val device = UiDevice.getInstance(getInstrumentation())
val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn
了解详情
如需了解详情,不妨参阅 Jetpack Compose 测试 Codelab。