mcpField
A
asserting-node-state-and-text

asserting-node-state-and-text

github.com/skydoves测试与质量33964 次安装22 周前更新
代码仓库

关于 asserting-node-state-and-text

面向 Compose 测试开发者的断言技能,针对已定位的语义节点属性选择断言。说明可见性、集合断言、自定义匹配器,以及 waitUntil 中使用的布尔谓词。

主要功能

  1. 01按节点属性选择类型化断言
  2. 02区分 assertExists 与 assertIsDisplayed
  3. 03涵盖集合数量、任一及全部断言
  4. 04支持自定义匹配器断言
  5. 05在 waitUntil 中使用布尔可见性检查

适用场景

  1. 01检查 Compose 开关状态或文本
  2. 02验证节点可见而不只是存在
  3. 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-208
    Content description 等于这些值吗?assertContentDescriptionEquals(vararg)Assertions.kt:139-141
    Content description 包含这个值吗?assertContentDescriptionContains(value, substring, ignoreCase)Assertions.kt:158-163
    StateDescription 等于 X 吗?assertValueEquals(value)Assertions.kt:216-217
    ProgressBar 位于这个位置吗?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 中的预构建匹配器或组合匹配器搭配使用。messagePrefixOnError lambda 用于在此断言是更大型操作的前置条件时添加上下文。

模式

模式:面向用户的检查优先使用 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(主机端)通过。
  • 失败消息会指出有问题的语义属性名称(证明使用了类型化断言)。

参考资料

评论

支持 Markdown