关于 composing-semantics-matchers
此技能指导开发者在 Jetpack Compose UI 测试中组合 SemanticsMatcher,用单一声明式谓词筛选节点。它涵盖预置筛选器、自定义语义键和层级关系,并让失败信息指出未满足的条件。
主要功能
- 01组合 and、or、not 匹配器
- 02使用预置状态与操作筛选器
- 03匹配自定义语义键
- 04按祖先或后代关系查找节点
- 05在失败信息中显示匹配条件
适用场景
- 01查找同时满足文本、启用和点击条件的按钮
- 02定位对话框内的文本节点
- 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-530hasText会同时搜索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)。