Composables

Finding Nodes By Tag Text Content

Community

skydoves

Use this skill to locate Compose semantics nodes from a UI test using `onNodeWithTag`, `onNodeWithText`, `onNodeWithContentDescription`, `onAllNodes*`, and `onRoot`. Covers the count contract (single-node finders fail on 0 or >1 matches; collection finders never throw on 0), the merged-vs-unmerged tree distinction, and why tagging in production beats text-matching from tests. Use when the developer reports "no node matched", "multiple nodes matched", "tag not found", "useUnmergedTree", "Modifier.testTag", or asks how to find a Button/Text/Icon in a Compose test. If the developer mentions onNodeWithText, onNodeWithTag, onNodeWithContentDescription, onAllNodesWithTag, onRoot, or merged tree vs unmerged tree, use this skill.

Overview

Repository
skydoves/android-testing-skills
Publisher
skydoves
Stars
347
Forks
14
Bundled files
0
License
Apache-2.0
Updated
May 25, 2026

Installation

tmp=$(mktemp -d)
git clone --depth 1 https://github.com/skydoves/android-testing-skills.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/compose/finders/finding-nodes-by-tag-text-content" ~/.claude/skills/finding-nodes-by-tag-text-content
rm -rf "$tmp"

Instructions

Finding Nodes by Tag, Text, or Content Description — Tag From Production, Find by Tag From Tests

Every Compose UI test starts by locating a semantics node. This skill picks the right finder, reasons about the count contract, and decides when to flip useUnmergedTree. Use it whenever the developer is staring at a "Reason: Expected exactly '1' node but could not find any node that satisfies …" error.

When to use this skill

  • The developer reports "no node matched" / "multiple nodes matched" / "tag not found" / "ambiguous match" from SemanticsNodeInteraction.
  • The developer asks how to locate a Button, Text, Icon, IconButton, Switch, or any other composable inside a test.
  • The developer mentions onNodeWithTag, onNodeWithText, onNodeWithContentDescription, onAllNodesWith*, or onRoot.
  • A test passes locally but is brittle to copy edits or i18n rotation of the UI strings.
  • The developer asks about useUnmergedTree, the merged tree, or "why does my inner Text not exist".

When NOT to use this skill

  • The right node is found but assertions fail — see ../../assertions/asserting-node-state-and-text/SKILL.md (state) or ../../assertions/asserting-bounds-and-dimensions/SKILL.md (geometry).
  • The finder returns one node but custom predicates are needed — see ../composing-semantics-matchers/SKILL.md.
  • The right node exists but only as a child/sibling/descendant — see ../traversing-the-semantics-tree/SKILL.md.
  • The semantics tree itself needs inspection — see ../../debug/printing-the-semantics-tree/SKILL.md.

Prerequisites

  • androidx.compose.ui:ui-test-junit4 (or v2) wired up. See ../../setup/configuring-test-dependencies/SKILL.md if createComposeRule is unresolved.
  • A ComposeTestRule or ComposeUiTest is in scope. See ../../setup/choosing-test-rule-vs-runtest/SKILL.md.
  • The composable under test calls setContent { … } before the finder runs. Finders are lazy and re-evaluate on every operation.

Workflow

  • 1. Default to onNodeWithTag(SomeTag). Add a Modifier.testTag(SomeTag) in the production source under a const val SomeTag = "Some". Tests find by tag, not by text. Skydoves hot take #1: in androidx/material3 itself, onNodeWithTag outnumbers onNodeWithText roughly 4:1 (1825 vs 424) and onNodeWithContentDescription 40:1 (1825 vs 46). Text finders churn with copy edits and break on locale rotation.

  • 2. Pick the finder by signal type. Mapping (all from commonMain/.../Finders.kt):

    Need to match by … Finder File:line
    Modifier.testTag("…") onNodeWithTag(testTag, useUnmergedTree=false) Finders.kt:31-35
    Visible/editable/input text onNodeWithText(text, substring=false, ignoreCase=false, useUnmergedTree=false) Finders.kt:110-116
    Accessibility content description onNodeWithContentDescription(label, substring=false, ignoreCase=false, useUnmergedTree=false) Finders.kt:66-73
    Custom predicate onNode(matcher, useUnmergedTree=false) from SemanticsNodeInteractionsProvider SemanticsNodeInteractionsProvider.kt
    Whole tree onRoot(useUnmergedTree=false) Finders.kt:150-153

    Each onNodeWith* is a thin @CheckResult wrapper over onNode(hasTestTag(...))/onNode(hasText(...))/onNode(hasContentDescription(...)). The behavior is identical to the matcher form.

  • 3. Respect the count contract. SemanticsNodeInteraction (single) throws AssertionError when fetched against 0 or >1 matching nodes (SemanticsNodeInteraction.kt:150-191). SemanticsNodeInteractionCollection (plural) never throws on 0 — it returns an empty collection that assertCountEquals(0) will accept (Assertions.kt:276-292). Pick the variant that matches the expected cardinality.

  • 4. Flip useUnmergedTree = true only for inner elements. The merged tree collapses descendants whose semantics roll up into the parent — Text and Icon inside a Button appear as one node. To target the inner Text, pass useUnmergedTree = true. The flag is sticky: once a finder is created with useUnmergedTree = true, every subsequent onChild/onParent/filter keeps it on (SemanticsNodeInteraction.kt:42-49). Skydoves hot take #2: default to merged; flip only when targeting composition detail.

  • 5. Read the auto-suggested unmerged hint on count mismatch. When a finder targets the merged tree and finds zero, the framework re-runs the same query against the unmerged tree and embeds the matches in the error message (SemanticsNodeInteraction.kt:184-205, getNodesInUnmergedTree). If the error shows nodes that "would have matched" with useUnmergedTree = true, flip the flag.

  • 6. For substring or case-insensitive text matches, pass the boolean flags explicitly. onNodeWithText("Sub", substring = true) matches "Submit" and "Subscribe". Combine with onAllNodesWithText plus assertCountEquals to express intent (see Pattern: ambiguous text below).

Patterns

Pattern: tag from production, find by tag from tests
// WRONG
@Test
fun submit_isEnabled_afterFormFilled() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithText("Place order").assertIsEnabled()
}
// WRONG because: the button label changes with copy edits, and the test breaks the moment QA
// asks for "Buy now". Also fails under any locale that is not the test default.
// RIGHT
// production: ui/checkout/CheckoutScreen.kt
const val PlaceOrderButtonTag = "CheckoutScreen.PlaceOrderButton"

@Composable
fun CheckoutScreen() {
    Button(modifier = Modifier.testTag(PlaceOrderButtonTag), onClick = { /* … */ }) {
        Text(stringResource(R.string.place_order))
    }
}

// test:
@Test
fun submit_isEnabled_afterFormFilled() {
    rule.setContent { CheckoutScreen() }
    rule.onNodeWithTag(PlaceOrderButtonTag).assertIsEnabled()
}
Pattern: collection finder when the count is the assertion
// WRONG
@Test
fun greeting_appearsTwice() {
    rule.setContent { GreetingList(listOf("Hello", "Hello")) }
    // Returns the first match silently; never proves "twice".
    rule.onNodeWithText("Hello").assertIsDisplayed()
}
// WRONG because: onNode* throws on >1 matches; if the duplicate count drifts to 1 the test
// passes for the wrong reason, and if it drifts to 3 the test fails with an opaque count error.
// RIGHT
@Test
fun greeting_appearsTwice() {
    rule.setContent { GreetingList(listOf("Hello", "Hello")) }
    rule.onAllNodesWithText("Hello").assertCountEquals(2)
}
Pattern: matching an inner Text inside a Button (merge collapse)
// WRONG
@Test
fun button_text_isShown() {
    rule.setContent {
        Button(onClick = {}, modifier = Modifier.testTag("submit")) {
            Text("Submit", modifier = Modifier.testTag("submitLabel"))
        }
    }
    rule.onNodeWithTag("submitLabel").assertIsDisplayed()
}
// WRONG because: in the merged tree the Text is rolled into the Button. The finder sees zero
// nodes and the auto-attached unmerged hint will surface in the error message — flip the flag.
// RIGHT
@Test
fun button_text_isShown() {
    rule.setContent { /* same content */ }
    rule.onNodeWithTag("submitLabel", useUnmergedTree = true).assertIsDisplayed()
}
Pattern: substring/ignoreCase for partial copy
// WRONG
rule.onNodeWithText("hello").assertIsDisplayed()
// WRONG because: matches only the exact string. UI showing "Hello, world" or "HELLO" produces
// a "no node matched" error.
// RIGHT — when intent really is "contains 'Hello'":
rule.onNodeWithText("Hello", substring = true, ignoreCase = true).assertIsDisplayed()

// RIGHTER — prefer a tag and assert the text once:
rule.onNodeWithTag(GreetingTag).assertTextContains("Hello", substring = true, ignoreCase = true)
Pattern: content description for icon-only controls
// production:
IconButton(onClick = onShare, modifier = Modifier.testTag(ShareButtonTag)) {
    Icon(Icons.Default.Share, contentDescription = stringResource(R.string.share))
}

// test:
rule.onNodeWithTag(ShareButtonTag).assertIsDisplayed()                  // PREFERRED
rule.onNodeWithContentDescription("Share").assertIsDisplayed()          // acceptable for accessibility-focused tests

onNodeWithContentDescription defaults to merged-tree exact match, so the merged Icon's content description rolls up to the parent IconButton. To match a specific Icon's description in a button that contains many icons, flip useUnmergedTree = true.

Pattern: onRoot() as the screenshot/print starting point
rule.onRoot().printToLog("tree")                  // dump merged
rule.onRoot(useUnmergedTree = true).printToLog("unmerged")

See ../../debug/printing-the-semantics-tree/SKILL.md for printToLog / printToString depth controls.

Mandatory rules

  • MUST prefer onNodeWithTag over onNodeWithText and onNodeWithContentDescription. Skydoves hot take #1.
  • MUST put the Modifier.testTag(...) in production code under a constant. MUST NOT redefine the same string literal in both the production source and the test.
  • MUST default to useUnmergedTree = false. Flip to true only when targeting an inner element collapsed by merge or verifying composition detail. Skydoves hot take #2.
  • MUST use onAllNodesWith* + assertCountEquals when the count itself is part of the test contract. MUST NOT rely on onNodeWith* to imply "exactly one" by side effect.
  • MUST NOT chain finders inside a single statement past the point of clarity (e.g. onNode(...).onChild().onChildAt(2).onChild()). Add a tag instead — see ../traversing-the-semantics-tree/SKILL.md.
  • PREFERRED: when a count-mismatch error shows unmerged-tree matches, flip useUnmergedTree = true rather than rewriting the matcher.

Verification

  • Every finder in the test file uses onNodeWithTag unless the assertion is explicitly about user-visible text or accessibility content description.
  • Every test tag is defined as a const val in the production source, not inlined in the test.
  • useUnmergedTree = true appears only at sites that target an inner element of a merged composable (Button, IconButton, ListItem, etc.).
  • No raw string-literal text comparison appears where a tag would do.
  • ./gradlew :app:connectedDebugAndroidTest (device) or :app:testDebugUnitTest (Robolectric host) passes locally.
  • No "Reason: Expected exactly '1' node" errors remain.

References

FAQ

What does Finding Nodes By Tag Text Content do?

Use this skill to locate Compose semantics nodes from a UI test using `onNodeWithTag`, `onNodeWithText`, `onNodeWithContentDescription`, `onAllNodes*`, and `onRoot`. Covers the count contract (single-node finders fail on 0 or >1 matches; collection finders never throw on 0), the merged-vs-unmerged tree distinction, and why tagging in production beats text-matching from tests. Use when the developer reports "no node matched", "multiple nodes matched", "tag not found", "useUnmergedTree", "Modifier.testTag", or asks how to find a Button/Text/Icon in a Compose test. If the developer mentions onNodeWithText, onNodeWithTag, onNodeWithContentDescription, onAllNodesWithTag, onRoot, or merged tree vs unmerged tree, use this skill.

Who made Finding Nodes By Tag Text Content?

skydoves (a community member) publishes it in the skydoves/android-testing-skills repository on GitHub.

Is Finding Nodes By Tag Text Content free?

Yes. Finding Nodes By Tag Text Content is open source, published by skydoves under the Apache-2.0 license.

What comes with Finding Nodes By Tag Text Content?

Just its instructions. Everything the agent needs is in one SKILL.md file.

Which agents can use Finding Nodes By Tag Text Content?

Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.

What are agent skills?

An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.

How are agent skills different from MCP servers?

An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.

How can my agent test my Android app?

Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.

You're subscribed

The next lesson lands in on Wednesday.