| name | traversing-the-semantics-tree |
| description | Use this skill to navigate from one Compose semantics node to its relatives via `onParent`, `onChildren`, `onChild`, `onChildAt`, `onSibling`, `onSiblings`, `onAncestors`, plus the collection helpers `onFirst`, `onLast`, `filter`, `filterToOne`, and the `[index]` operator. Covers when to traverse vs when to add a stable `testTag`, the LazyColumn/LazyRow caveat (only currently composed children appear), the absence of a singular `onAncestor`, and the sticky `useUnmergedTree` flag across navigation. Use when the developer mentions `onChildren`, `onChild`, `onParent`, `onSiblings`, `onAncestors`, `filterToOne`, `onFirst`, `onLast`, brittle child-index chains, or asks how to find the second child of a Row, the parent of a Text, or any sibling of a node. If the developer is dot-chaining navigation through a layout, use this skill. |
| license | Apache-2.0. See LICENSE for complete terms. |
| metadata | {"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","ui-testing","onChildren","onParent","onAncestors","filterToOne","onFirst","onLast","semantics-traversal","lazy-column-test"]} |
Traversing the Semantics Tree — When a Single Finder Won't Reach the Node
The single-finder shortcuts (onNodeWithTag, etc.) cover ~95% of test queries. The remainder need tree navigation: "the parent of this Text", "the third child of this Row", "any sibling that is enabled". This skill maps those navigation operators, calls out the LazyColumn snapshot caveat, and shows when traversal is the wrong tool.
When to use this skill
- The target node has no stable
testTag and one cannot be added to production (third-party composable, dynamically generated children).
- The test verifies structural relationships ("the second child of the row is the icon").
- The developer chains
.onChildren()[i].onChildAt(j) and wants to know whether that is the right shape.
- The developer mentions
onChildren, onChild, onParent, onSibling, onSiblings, onAncestors, filterToOne, onFirst, onLast, or [index].
- A
LazyColumn test misses items because they are off-screen.
When NOT to use this skill
- A
Modifier.testTag(...) could be added to the target node — adding a tag is almost always cleaner than a traversal chain. See ../finding-nodes-by-tag-text-content/SKILL.md.
- The relationship is "anywhere above" / "anywhere below" — prefer the matcher-based
hasAnyAncestor / hasAnyDescendant (see ../composing-semantics-matchers/SKILL.md).
- The query is about a LazyColumn item by key — use
performScrollToKey instead (see ../../actions/clicking-and-scrolling/SKILL.md, ../../patterns/testing-lazy-lists/SKILL.md).
Prerequisites
- A working
ComposeTestRule / ComposeUiTest. See ../../setup/configuring-test-dependencies/SKILL.md.
- Familiarity with the merged vs unmerged tree distinction. See
../finding-nodes-by-tag-text-content/SKILL.md.
Workflow
Patterns
Pattern: prefer a tag over a deep [index] chain
@Test
fun second_avatar_isVisible() {
rule.setContent { ProfileGrid(profiles = profiles) }
rule.onNodeWithTag("ProfileGrid")
.onChildren()[2]
.onChildren()[0]
.assertIsDisplayed()
}
@Composable
fun ProfileGrid(profiles: List<Profile>) {
LazyColumn(modifier = Modifier.testTag(ProfileGridTag)) {
itemsIndexed(profiles) { index, profile ->
Row(modifier = Modifier.testTag("$AvatarTagPrefix$index")) {
Avatar(profile, modifier = Modifier.testTag("$AvatarImageTagPrefix$index"))
}
}
}
}
rule.onNodeWithTag("$AvatarImageTagPrefix${1}").assertIsDisplayed()
Pattern: traversal when no tag is available
@Test
fun row_third_child_isIcon() {
rule.setContent {
Row(modifier = Modifier.testTag("toolbar")) {
Text("Title")
Spacer(Modifier.weight(1f))
Icon(Icons.Default.Share, contentDescription = "Share")
}
}
rule.onNodeWithTag("toolbar")
.onChildren()
.assertCountEquals(3)
rule.onNodeWithTag("toolbar")
.onChildAt(2)
.assertContentDescriptionEquals("Share")
}
onChildAt(index) is exactly onChildren()[index] (Selectors.kt:85). Both fail if the index is out of range or if the resolved node count is not exactly 1 at the leaf.
Pattern: filterToOne instead of [i]
rule.onAllNodesWithTag(RowTag).onChildren().filter(hasClickAction())[0]
.assertHasClickAction()
rule.onAllNodesWithTag(RowTag).onChildren()
.filterToOne(hasClickAction())
.assertHasClickAction()
Pattern: assert the parent role from a known child
@Test
fun submitText_isInsideEnabledButton() {
rule.setContent {
Button(onClick = {}, modifier = Modifier.testTag("submit"), enabled = true) {
Text("Submit", modifier = Modifier.testTag("submitLabel"))
}
}
rule.onNodeWithTag("submitLabel", useUnmergedTree = true)
.onParent()
.assertIsEnabled()
.assertHasClickAction()
}
The useUnmergedTree = true set on the inner Text finder propagates to onParent() automatically — no need to repeat it.
Pattern: onAncestors() for the chain to root
@Test
fun confirmButton_isInsideDialog() {
rule.setContent { ConfirmDialog() }
rule.onNodeWithTag(ConfirmButtonTag)
.onAncestors()
.filterToOne(isDialog())
.assertExists()
}
PREFERRED: rule.onNode(hasTestTag(ConfirmButtonTag) and hasAnyAncestor(isDialog())).assertExists() — same intent in one matcher. See ../composing-semantics-matchers/SKILL.md.
Pattern: LazyColumn — only on-screen children appear
rule.onNodeWithTag(ListTag).onChildren().assertCountEquals(1000)
rule.onNodeWithTag(ListTag).performScrollToIndex(999)
rule.onNodeWithTag("$ItemTagPrefix${999}").assertIsDisplayed()
See ../../patterns/testing-lazy-lists/SKILL.md for the full LazyColumn workflow.
Pattern: onSibling() to assert "the row's other half"
@Test
fun checkbox_label_isPresent() {
rule.setContent {
Row {
Checkbox(checked = true, onCheckedChange = {},
modifier = Modifier.testTag("agreeBox"))
Text("I agree", modifier = Modifier.testTag("agreeLabel"))
}
}
rule.onNodeWithTag("agreeBox")
.onSibling()
.assertTextEquals("I agree")
}
onSibling() requires exactly one sibling (Selectors.kt:114-125). For multiple siblings use onSiblings() plus filterToOne(...).
Mandatory rules
- MUST prefer adding a
Modifier.testTag(...) to the target node over a multi-step traversal chain. Skydoves hot take #1.
- MUST use
filterToOne(matcher) over filter(matcher).onFirst() when the contract is "exactly one match"; the former throws on >1, the latter silently picks the first.
- MUST NOT assume
onChildren() returns the full data set for a LazyColumn / LazyRow — it returns the currently-composed snapshot only. Scroll first with performScrollToIndex / performScrollToKey.
- MUST NOT look up a singular
onAncestor — only onAncestors() plural exists. Use onParent() for the immediate parent or hasParent(matcher) / hasAnyAncestor(matcher) as predicates.
- MUST remember the
useUnmergedTree flag is sticky across onChild/onParent/filter. Skydoves hot take #2.
- PREFERRED: for "anywhere above/below" relationships, replace traversal chains with
hasAnyAncestor / hasAnyDescendant from ../composing-semantics-matchers/SKILL.md.
Verification
References
- Compose testing overview: https://developer.android.com/develop/ui/compose/testing
- Compose testing cheat sheet: https://developer.android.com/develop/ui/compose/testing-cheatsheet
- Semantics in Compose: https://developer.android.com/develop/ui/compose/accessibility/semantics
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Selectors.kt — onParent, onChildren, onChild, onChildAt, onSibling(s), onAncestors, onFirst, onLast, filter, filterToOne.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt — collection [index] operator and the sticky useUnmergedTree field.
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt — hasParent, hasAnyChild, hasAnySibling, hasAnyAncestor, hasAnyDescendant predicate alternatives to traversal.