关于 asserting-node-state-and-text
面向 Compose 测试开发者的断言技能,针对已定位的语义节点属性选择断言。说明可见性、集合断言、自定义匹配器,以及 waitUntil 中使用的布尔谓词。
主要功能
- 01按节点属性选择类型化断言
- 02区分 assertExists 与 assertIsDisplayed
- 03涵盖集合数量、任一及全部断言
- 04支持自定义匹配器断言
- 05在 waitUntil 中使用布尔可见性检查
适用场景
- 01检查 Compose 开关状态或文本
- 02验证节点可见而不只是存在
- 03断言集合大小或选择状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill asserting-node-state-and-text安装后在 Claude Code 中输入/asserting-node-state-and-text确认已加载。
详细介绍
断言节点状态和文本——通过框架验证,而不是通过 fetchSemanticsNode
当查找器解析为 SemanticsNodeInteraction 后,下一步就是对其进行断言。Compose 几乎为每种语义属性都提供了类型化断言;其余情况则由通用的 assert(matcher) 覆盖。本技能介绍如何选择合适的断言、区分“存在”和“已显示”,以及何时应将布尔谓词放入 waitUntil。
何时使用此技能
- 开发者想验证 Switch 已开启、Checkbox 未勾选、Button 已启用,或 Text 等于预期字符串。
- 开发者询问
assertIsDisplayed和assertExists的区别(前者验证是否显示在屏幕上,后者验证是否存在于树中)。 - 开发者需要通过
assert(matcher)为没有类型化扩展的属性编写自定义断言。 - 开发者想验证集合:“恰好有 3 个项目”“至少有一个已选中”“全部已启用”。
- 测试直接比较
node.config[...],而不是使用类型化断言。
何时不要使用此技能
- 无法定位正确的节点——请参阅
../../finders/finding-nodes-by-tag-text-content/SKILL.md。 - 断言涉及几何属性(宽度、高度、位置)——请参阅
./asserting-bounds-and-dimensions/SKILL.md。 - 检查需要自定义
SemanticsMatcher——请参阅../../finders/composing-semantics-matchers/SKILL.md。 - 状态会异步变化,且断言对时序敏感——请参阅
../../synchronization/synchronizing-with-idle/SKILL.md。
前提条件
- 已配置可用的
ComposeTestRule/ComposeUiTest。请参阅../../setup/configuring-test-dependencies/SKILL.md。 - 已通过
onNode*/onAllNodes*/ 导航器定位节点。 - 对于
assertIsDisplayed的语义,节点必须已组合、已放置,并且裁剪后至少部分位于屏幕上(Assertions.kt:30-39)。
工作流
-
1. 按问题类型选择。 将开发者的问题映射到一个断言调用。除非另有说明,所有列出的 API 都位于
commonMain/.../Assertions.kt。问题 API 文件:行号 它在树中吗? assertExists(errorMessageOnFail)SemanticsNodeInteraction.kt:124-127它不存在了吗? assertDoesNotExist()SemanticsNodeInteraction.kt:94-110它是 SubcomposeLayout 保留的子项吗? assertIsDeactivated()SemanticsNodeInteraction.kt:137-148用户能看到它吗? assertIsDisplayed()/assertIsNotDisplayed()Assertions.kt:32-53它可以交互吗? assertIsEnabled()/assertIsNotEnabled()Assertions.kt:60-67开关已打开吗? assertIsOn()/assertIsOff()/assertIsToggleable()Assertions.kt:74-81, 103单选项卡 / 标签页已选中吗? assertIsSelected()/assertIsNotSelected()/assertIsSelectable()Assertions.kt:88-110它获得焦点了吗? assertIsFocused()/assertIsNotFocused()Assertions.kt:117-124它等于这些字符串吗? assertTextEquals(vararg, includeEditableText=true)Assertions.kt:181-185它包含这个字符串吗? assertTextContains(value, substring=false, ignoreCase=false)Assertions.kt:204-208Content description 等于这些值吗? assertContentDescriptionEquals(vararg)Assertions.kt:139-141Content description 包含这个值吗? assertContentDescriptionContains(value, substring, ignoreCase)Assertions.kt:158-163StateDescription 等于 X 吗? assertValueEquals(value)Assertions.kt:216-217ProgressBar 位于这个位置吗? assertRangeInfoEquals(rangeInfo)Assertions.kt:225-227它有点击操作吗? assertHasClickAction()/assertHasNoClickAction()Assertions.kt:234-243其他情况 assert(matcher, messagePrefixOnError = null)Assertions.kt:254-267 -
2. 谨慎选择
assertIsDisplayed还是assertExists。 如果节点位于语义树中(已组合),则使用assertExists()。如果节点已组合、已放置且裁剪后至少部分可见,则使用assertIsDisplayed()(Assertions.kt:30-39)。对于尚未滚动到视野内的 LazyColumn 项,assertExists()可能失败(项尚未组合),也可能成功(项已组合但在屏幕外——取决于预取情况);若要确认“用户能看到它”,应使用assertIsDisplayed()。 -
3. 使用类型化断言,而非
fetchSemanticsNode().config[...]。 类型化断言会委托给匹配器库,并生成框架格式的错误,指出失败的属性并转储节点(Assertions.kt:254-267——通用的assert会构建buildGeneralErrorMessage(errorMessageOnFail, selector, node))。直接读取 config 会绕过这些错误报告,也不会自动附带节点转储。 -
4. 对集合使用集合类型的断言。 使用
assertCountEquals(expectedSize)检查数量(Assertions.kt:276-292),使用assertAny(matcher)检查“至少一个匹配”(集合为空时失败——Assertions.kt:300-312),使用assertAll(matcher)检查“每个节点都匹配”(集合为空时通过——Assertions.kt:323-339)。 -
5. 对
waitUntil谓词使用布尔 API,而非会抛出异常的断言。isDisplayed()返回Boolean(Assertions.kt:351),非常适合用于rule.waitUntil { node.isDisplayed() }。isNotDisplayed()是它的反向判断(Assertions.kt:361)。如果 finder 匹配到多个节点,两者都会抛出异常;但匹配到零个节点时不会。交叉参考:../../synchronization/synchronizing-with-idle/SKILL.md。 -
6. 对没有类型化断言的一次性属性,使用
assert(matcher)。 与../../finders/composing-semantics-matchers/SKILL.md中的预构建匹配器或组合匹配器搭配使用。messagePrefixOnErrorlambda 用于在此断言是更大型操作的前置条件时添加上下文。
模式
模式:面向用户的检查优先使用 assertIsDisplayed,而非 assertExists
// WRONG
@Test
fun submit_isVisibleAfterError() {
rule.setContent { CheckoutScreen(state = state) }
rule.onNodeWithTag(SubmitTag).assertExists()
}
// WRONG because: assertExists passes for nodes that are composed but off-screen, behind a
// dialog, or measured to zero size. The test does not prove the user actually sees the button.
// RIGHT
@Test
fun submit_isVisibleAfterError() {
rule.setContent { CheckoutScreen(state = state) }
rule.onNodeWithTag(SubmitTag).assertIsDisplayed()
}
模式:使用类型化的 assertIsOn,而不是手动读取配置
// WRONG
@Test
fun darkMode_switch_isOn() {
rule.setContent { SettingsScreen() }
val node = rule.onNodeWithTag(DarkModeSwitchTag).fetchSemanticsNode()
assertEquals(ToggleableState.On, node.config[SemanticsProperties.ToggleableState])
}
// WRONG because: bypasses the framework's error reporting. Failure prints a JUnit assertEquals
// diff with no node dump, no selector description, no hint about which screen the failure
// happened on.
// RIGHT
@Test
fun darkMode_switch_isOn() {
rule.setContent { SettingsScreen() }
rule.onNodeWithTag(DarkModeSwitchTag).assertIsOn()
}
assertIsOn 是 assert(isOn())(Assertions.kt:74);isOn() 是 expectValue(SemanticsProperties.ToggleableState, ToggleableState.On)(Filters.kt:61-62)。错误会自动指出属性名称并转储节点。
模式:assertTextEquals(vararg)可处理合并后的 Text + EditableText
// production:
TextField(value = "hello", onValueChange = {}, label = { Text("Name") },
modifier = Modifier.testTag(NameFieldTag))
// test:
rule.onNodeWithTag(NameFieldTag).assertTextEquals("Name", "hello")
assertTextEquals(vararg) 会匹配 SemanticsProperties.Text 的无序集合,以及默认情况下的 SemanticsProperties.EditableText(Assertions.kt:181-185 → Filters.kt:274-293)。要在比较中排除可编辑文本:assertTextEquals("Name", includeEditableText = false)。
模式:使用 assertCountEquals,而不是 onAllNodes(...).onFirst().assertExists()
// WRONG
rule.onAllNodesWithTag(ItemTag).onFirst().assertExists()
// WRONG because: only proves there is at least one item. Drift to zero items causes
// "no node matched" instead of a count error; drift to many is silently accepted.
// RIGHT
rule.onAllNodesWithTag(ItemTag).assertCountEquals(3)
模式:对没有类型化断言的属性使用 assert(matcher)
@Test
fun row_hasPriorityOne() {
rule.setContent { TaskList(tasks = tasks) }
rule.onNodeWithTag(TaskRowTag)
.assert(SemanticsMatcher.expectValue(PriorityKey, 1))
}
PriorityKey 是一个自定义的 SemanticsPropertyKey<Int>,通过 Modifier.semantics { priority = … } 设置。有关匹配器组合,请参阅 ../../finders/composing-semantics-matchers/SKILL.md。
模式:assertAny(集合为空时失败)与 assertAll(集合为空时通过)
// "at least one row is selected" — must have at least one row
rule.onAllNodesWithTag(RowTag).assertAny(isSelected())
// "every row is enabled" — accepts zero rows (passes vacuously)
rule.onAllNodesWithTag(RowTag).assertAll(isEnabled())
集合为空时,assertAny 会抛出 AssertionError("Failed to assertAny … no node matched")(Assertions.kt:305-307)。集合为空时,assertAll 会成功返回(Assertions.kt:323-339)。请有意选择。
模式:在 waitUntil 中使用 isDisplayed()
// WRONG
rule.waitUntil { rule.onNodeWithTag(SnackbarTag).assertIsDisplayed(); true }
// WRONG because: assertIsDisplayed throws on the first poll where the snackbar isn't yet
// shown. waitUntil expects a boolean — predicates that throw will fail-fast.
// RIGHT
rule.waitUntil(timeoutMillis = 2_000) {
rule.onAllNodesWithTag(SnackbarTag).fetchSemanticsNodes().isNotEmpty() &&
rule.onNodeWithTag(SnackbarTag).isDisplayed()
}
rule.onNodeWithTag(SnackbarTag).assertTextContains("Saved")
当没有匹配的节点时,isDisplayed() 会返回 false;只有匹配多个节点时才会抛出异常(Assertions.kt:343-351)。交叉参考:有关 waitUntil 与 mainClock.advanceTimeUntil,请参阅 ../../synchronization/synchronizing-with-idle/SKILL.md。
模式:对 SubcomposeLayout 保留的子项使用 assertIsDeactivated
// LookaheadLayout / SubcomposeLayout may retain a previously composed slot for reuse.
// To verify the slot is currently deactivated (kept, not active):
rule.onNodeWithTag(RetainedSlotTag).assertIsDeactivated()
assertIsDeactivated 会获取节点,不会跳过已停用的节点(SemanticsNodeInteraction.kt:137-148),并检查 node.layoutInfo.isDeactivated。
强制规则
- 必须优先使用类型化断言(
assertIsOn、assertIsEnabled、assertTextEquals等),而不是fetchSemanticsNode().config[...]。框架的错误消息包含选择器、节点转储和失败的子句。 - 必须在约定为“用户能看到此内容”时使用
assertIsDisplayed();只有在约定为“此内容位于语义树中”时才必须使用assertExists()(例如,在未合并的树中检查是否存在,而不要求其显示在屏幕上)。 - 必须在
waitUntil { … }代码块中使用布尔值isDisplayed()/isNotDisplayed();不得在waitUntil中调用会抛出异常的assertIs*。 - 必须使用
assertCountEquals检查集合基数;不得通过索引[0]来暗示“只有一个”。 - 不得在链式调用中重复相同的断言,若一个组合匹配器即可完成检查;请参阅
../../finders/composing-semantics-matchers/SKILL.md。 - **优先做法:**在断言之前,优先使用以标签为锚点的查找。Skydoves 的热门观点 #1。
验证
- 没有断言直接读取
node.config[...]。所有属性检查都通过assertIs*/assertTextEquals/assert(matcher)完成。 - 用户可见内容的检查使用
assertIsDisplayed;只有在屏幕可见性无关紧要时才使用assertExists。 -
waitUntil { … }代码块使用isDisplayed()/isNotDisplayed()/fetchSemanticsNodes().size,其中不调用会抛出异常的assertIs*。 -
assertAny/assertAll的选择符合预期的空集合语义。 -
./gradlew :app:connectedDebugAndroidTest(设备端)或:app:testDebugUnitTest(主机端)通过。 - 失败消息会指出有问题的语义属性名称(证明使用了类型化断言)。
参考资料
- Compose 测试概览:https://developer.android.com/develop/ui/compose/testing
- Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
- Compose 中的语义:https://developer.android.com/develop/ui/compose/accessibility/semantics
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Assertions.kt— 所有assertIs*/assertTextEquals/assertContentDescriptionEquals/assertHasClickAction/assertCountEquals/assertAll/assertAny/isDisplayed/isNotDisplayed。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt—assertExists、assertDoesNotExist、assertIsDeactivated、fetchSemanticsNode。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt— 类型化断言所用的底层匹配器isOn、isEnabled、hasText、hasClickAction。