mcpField
C
composing-semantics-matchers

composing-semantics-matchers

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

关于 composing-semantics-matchers

此技能指导开发者在 Jetpack Compose UI 测试中组合 SemanticsMatcher,用单一声明式谓词筛选节点。它涵盖预置筛选器、自定义语义键和层级关系,并让失败信息指出未满足的条件。

主要功能

  1. 01组合 and、or、not 匹配器
  2. 02使用预置状态与操作筛选器
  3. 03匹配自定义语义键
  4. 04按祖先或后代关系查找节点
  5. 05在失败信息中显示匹配条件

适用场景

  1. 01查找同时满足文本、启用和点击条件的按钮
  2. 02定位对话框内的文本节点
  3. 03测试带自定义语义属性的节点

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill composing-semantics-matchers

安装后在 Claude Code 中输入/composing-semantics-matchers确认已加载。

详细介绍

组合语义匹配器——一个匹配器,一个错误

SemanticsMatcher 是一个描述字符串加上一个 (SemanticsNode) -> Boolean 谓词(SemanticsMatcher.kt:26)。Compose 提供了丰富的筛选器库和三个组合器(and、or、not),可生成一个错误消息列出所有子句的匹配器。本技能会选择合适的工厂,组合精确的谓词,并替换脆弱的断言链。

何时使用此技能

  • 开发者想要查找“启用状态且文本为 Submit 的 Button”,或查找符合多个约束的任意节点。
  • 开发者询问如何组合 hasText 和 hasClickAction,或如何使用 not 对匹配器取反。
  • 开发者需要使用 expectValue/keyIsDefined 按自定义 SemanticsPropertyKey(例如特定领域的角色)筛选。
  • 开发者询问 hasParent、hasAnyAncestor、hasAnyChild、hasAnySibling、hasAnyDescendant 等层级匹配器。
  • 测试连续调用多个 assertIs*,开发者希望使用单个声明式谓词。

何时不使用此技能

  • 查询是“仅按标记/文本/内容描述查找”——请使用 ../finding-nodes-by-tag-text-content/SKILL.md。
  • 目标节点存在,但只能作为相对节点查找——请使用 ../traversing-the-semantics-tree/SKILL.md。
  • 匹配器没有问题;只需调整断言——请参阅 ../../assertions/asserting-node-state-and-text/SKILL.md。

前置条件

  • 测试类路径中包含 androidx.compose.ui:ui-test。如果无法解析,请参阅 ../../setup/configuring-test-dependencies/SKILL.md。
  • 熟悉生产代码中的 Modifier.semantics { … }。自定义键必须通过 Modifier.semantics { customKey = value } 设置,才能在测试中进行匹配。

工作流

  • 1. 使用预构建的过滤器库构建匹配器。 每个预构建匹配器都位于 commonMain/.../Filters.kt。按信号分组:

    信号匹配器文件:行号
    启用 / 禁用isEnabled(), isNotEnabled()Filters.kt:38-47
    切换(Switch、Checkbox)isToggleable(), isOn(), isOff()Filters.kt:54-70
    选择(Tab、RadioButton)isSelectable(), isSelected(), isNotSelected()Filters.kt:77-93
    焦点isFocusable(), isNotFocusable(), isFocused(), isNotFocused()Filters.kt:100-122
    点击操作hasClickAction(), hasNoClickAction()Filters.kt:129-136
    滚动操作hasScrollAction(), hasNoScrollAction(), hasScrollToIndexAction(), hasScrollToKeyAction(), hasScrollToNodeAction()Filters.kt:143-425
    文本操作(TextField)hasSetTextAction(), hasInsertTextAtCursorAction(), hasPerformImeAction(), hasImeAction(ImeAction)Filters.kt:365-392
    焦点操作hasRequestFocusAction()Filters.kt:399
    内容描述hasContentDescription(value, substring, ignoreCase), hasContentDescriptionExactly(vararg)Filters.kt:165-212
    文本hasText(text, substring, ignoreCase), hasTextExactly(vararg, includeEditableText=true)Filters.kt:229-293
    无障碍 / 状态元数据hasStateDescription, hasProgressBarRangeInfo, isHeading, isDialog, isPopup, isHiddenFromAccessibility, isEditable, isRootFilters.kt:301-439
    测试标签hasTestTag(testTag)Filters.kt:326-327
    层级hasParent(matcher), hasAnyChild(matcher), hasAnySibling(matcher), hasAnyAncestor(matcher), hasAnyDescendant(matcher)Filters.kt:446-530

    hasText 会同时搜索 SemanticsProperties.Text、SemanticsProperties.EditableText 和 SemanticsProperties.InputText(Filters.kt:229-258)。hasContentDescription 会与合并后的描述列表进行匹配(Filters.kt:165-188)。

  • 2. 使用 and / or / not 组合。 组合器定义在 SemanticsMatcher.kt:60-74。每个组合器都会生成一个新匹配器,其 description 分别为 (left) && (right) / (left) || (right) / NOT (inner)。断言失败时,框架会原样打印此描述,因此失败消息会准确显示哪些子句未通过。

  • 3. 对自定义键使用 SemanticsMatcher.expectValue。 如果生产代码声明了自定义 SemanticsPropertyKey<T>,并通过 Modifier.semantics { customKey = value } 写入该键,可使用 SemanticsMatcher.expectValue(customKey, expectedValue) 进行匹配(SemanticsMatcher.kt:33-37)。若要检查“此键是否已设置”,请使用 SemanticsMatcher.keyIsDefined(customKey)(SemanticsMatcher.kt:40-42);若要检查“此键是否未设置”,请使用 keyNotDefined(SemanticsMatcher.kt:45-47)。isOn() 等内置匹配器本身就是对 expectValue(SemanticsProperties.ToggleableState, On) 的简单封装(Filters.kt:61-62)。

  • 4. 将组合后的匹配器传给 onNode / onAllNodes。 onNode(matcher, useUnmergedTree=false) 和 onAllNodes(matcher, useUnmergedTree=false) 是标准入口(SemanticsNodeInteractionsProvider.kt)。onNodeWithTag 等方法只是便捷封装——谓词变得更复杂时,直接使用匹配器即可。

  • 5. 对层级断言,优先使用 hasAnyAncestor / hasAnyDescendant,而不是多步遍历。 示例:“查找对话框中的文本” → onNode(hasText("Discard?") and hasAnyAncestor(isDialog()))。这比 onAllNodes(isDialog()).onFirst().onChildren().filter(hasText(...)).onFirst() 更简洁。

模式

模式:将断言链替换为单个匹配器
// WRONG
@Test
fun submit_isEnabledClickable() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithText("Submit")
        .assertHasClickAction()
        .assertIsEnabled()
}
// WRONG because: each assertion is a separate fetch + error path. Failure prints "no click
// action" without telling the developer the matched node is the *enabled-but-wrong* button on
// a different screen.
// RIGHT
@Test
fun submit_isEnabledClickable() {
    rule.setContent { CheckoutScreen() }
    rule.onNode(hasText("Submit") and hasClickAction() and isEnabled())
        .assertExists()
}

组合匹配器的描述会在失败时打印为 (Text + InputText + EditableText contains 'Submit' (ignoreCase: false)) && (OnClick is defined) && (is enabled),并指出每个未满足的条件。

模式:层级匹配——“对话框中的文本”
// WRONG
rule.onAllNodes(isDialog())
    .onFirst()
    .onChildren()
    .filterToOne(hasText("Discard?"))
    .assertIsDisplayed()
// WRONG because: it implicitly assumes the Text is a direct child of the dialog root.
// AlertDialog wraps its body in Column/Surface, so the Text is a descendant, not a child.
// RIGHT
rule.onNode(hasText("Discard?") and hasAnyAncestor(isDialog()))
    .assertIsDisplayed()
模式:否定与“恰好一个”
// "An enabled, non-toggleable Button (so: not a Switch or Checkbox) with text Submit"
rule.onNode(hasText("Submit") and isEnabled() and !isToggleable())
    .assertHasClickAction()

!matcher 会调用 operator fun not()(SemanticsMatcher.kt:72-74)——适用于库中的所有匹配器。

模式:自定义语义键
// production:
val PriorityKey = SemanticsPropertyKey<Int>("Priority")
var SemanticsPropertyReceiver.priority by PriorityKey

@Composable
fun TaskRow(task: Task) {
    Row(modifier = Modifier
        .testTag(TaskRowTag)
        .semantics { priority = task.priority }
    ) { /* … */ }
}

// test:
rule.onNode(hasTestTag(TaskRowTag) and SemanticsMatcher.expectValue(PriorityKey, 1))
    .assertIsDisplayed()

// "this row has a priority assigned at all":
rule.onNode(hasTestTag(TaskRowTag) and SemanticsMatcher.keyIsDefined(PriorityKey))
    .assertIsDisplayed()
模式:下拉菜单 / IME 操作 / 滚动到键——缩小 TextField 查询范围
// "the editable TextField that handles Done as IME action"
rule.onNode(hasSetTextAction() and hasImeAction(ImeAction.Done))
    .performTextInput("hello")

// "any LazyColumn child reachable by key — i.e. has IndexForKey on top of ScrollToIndex"
rule.onNode(hasScrollToKeyAction()).performScrollToKey(itemKey)

hasScrollToKeyAction 是 hasKey(ScrollToIndex) and hasKey(IndexForKey) 的合取(Filters.kt:415-416)。hasScrollToNodeAction 还会检查轴范围(Filters.kt:419-425)。

模式:匹配器描述出现在错误消息中
val matcher = hasText("Submit") and hasClickAction() and isEnabled()
println(matcher.description)
// (Text + InputText + EditableText contains 'Submit' (ignoreCase: false)) &&
// (OnClick is defined) && (is enabled)

该描述通俗易懂;当匹配器不成立时,assert(matcher)(Assertions.kt:254-267)会使用它。各个子句必须单独阅读时也易于理解——因此优先使用命名匹配器,而非内联 lambda。

强制规则

  • 必须使用 infix and / infix or / operator not 组合匹配器;不得在一个组合匹配器已能表达相同意图时,串联调用 assertIs*。
  • 必须使用 SemanticsMatcher.expectValue(...) 或 SemanticsMatcher.keyIsDefined(...) 包装自定义语义键;不得在临时 lambda 内访问 node.config[CustomKey]。这些工厂方法会生成易读的错误描述。
  • 当关系是“上方 / 下方某处”时,必须优先使用 hasAnyAncestor(isDialog()) / hasAnyDescendant(...),而非多步遍历。交叉引用:../traversing-the-semantics-tree/SKILL.md。
  • 必须将 useUnmergedTree 标志保持为默认值 false,除非匹配器针对的是合并时被折叠的内部元素。Skydoves 热点观点 #2。
  • 不得重新定义已有的预构建匹配器(例如编写自定义的 SemanticsMatcher("is enabled") { … })——应使用 isEnabled()。自定义匹配器仅用于没有预构建过滤器的键。
  • **优先:**优先使用标签锚定的匹配器——hasTestTag(SubmitTag) and isEnabled()——而非文本锚定的匹配器。Skydoves 热点观点 #1。

验证

  • 每个多条件查询都使用一个 onNode(matcher),而非串联查找器和断言。
  • 自定义键检查使用 SemanticsMatcher.expectValue / keyIsDefined——匹配器谓词中不会出现 node.config[...]。
  • ./gradlew :app:connectedDebugAndroidTest 或 :app:testDebugUnitTest 通过。
  • 故意设置为失败的匹配器,其失败消息会打印组合后的 description,从而指出失败的子句。
  • 没有匹配器被同名的重新声明局部 lambda 静默遮蔽。

参考资料

  • 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/SemanticsMatcher.kt — class SemanticsMatcher、expectValue / keyIsDefined / keyNotDefined、infix and、infix or、operator not。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt — 所有预构建匹配器(状态、操作、文本、层级)。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Assertions.kt — fun assert(matcher, messagePrefixOnError),以及匹配器描述如何显示在错误中。
  • compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteractionsProvider.kt — onNode(matcher, useUnmergedTree) / onAllNodes(matcher, useUnmergedTree)。

评论

支持 Markdown