Find Nodes and Assert UI with Compose Test APIs

Quick answer: Find Compose UI through its semantics, then make the smallest assertion that proves the user-visible contract. Start with onNodeWithText, onNodeWithContentDescription, or onNodeWithTag; use onNode with combined matchers when a label is ambiguous; use onAllNodes when several matches are expected. If a node seems to be missing, inspect the merged and unmerged semantics trees before changing the production UI.

The Compose testing APIs do not query a View hierarchy. They query the semantics tree: the meaningful description of the UI shared by accessibility services and the testing framework. That is why a durable test normally describes what the UI means—its label, role, state, or accessible action—rather than its nesting, padding, or implementation class.

This article assumes the test rule and dependencies from Jetpack Compose UI Testing: Setup and First Test are already in place.

Choose the most meaningful finder

ComposeTestRule offers convenience finders for common semantic properties. Select the one that best represents how a person understands the control.

UI contractFinderGood use
Visible labelonNodeWithText("Save")A uniquely labelled button or heading.
Accessible icon/actiononNodeWithContentDescription("Close")An icon button whose description is useful to assistive technology.
Stable non-user-facing identityonNodeWithTag("cart-total")A repeated structural region or a visual value without a suitable label.
More than one required propertyonNode(hasText("Save") and hasClickAction())Disambiguating similar text or proving an interaction contract.
Multiple matching nodesonAllNodesWithContentDescription("Remove item")Counting or checking a repeated collection.

Avoid relying on a tag when a real label or content description is sufficient. A semantic finder makes the test exercise an interface that is meaningful outside the test too. Tags remain valuable when they name a stable concept that users do not need announced, such as a chart region or a particular row in a repeated component.

Assert the contract, not just a node

An assertion answers a distinct question. assertExists() proves exactly one matching node is in the semantics tree; assertIsDisplayed() also checks that the node is composed, placed, and at least partly visible after clipping. Use the latter for a result the user must actually see. The API reference for display assertions documents the visibility behavior.

import androidx.compose.ui.test.assertExists
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import org.junit.Rule
import org.junit.Test

class CartSummaryTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun cartSummary_showsTheCurrentTotal() {
        composeTestRule.setContent {
            CartSummary(total = "$42.00")
        }

        composeTestRule.onNodeWithText("Order summary")
            .assertIsDisplayed()

        composeTestRule.onNodeWithTag("cart-total")
            .assertTextEquals("Total", "$42.00")

        composeTestRule.onNodeWithContentDescription("Remove Coffee")
            .assertExists()
    }
}

The example uses a tag for the total because a price can appear in more than one place. It asserts the heading and total separately, so a failure tells you whether the screen disappeared or the displayed amount is wrong. Use assertDoesNotExist() when the contract is that no matching semantic node should be present; use assertIsNotDisplayed() only when a node can legitimately remain composed but hidden.

Combine matchers to remove ambiguity

Text alone is often not unique. A list may have several Remove buttons, and a screen can display a word both as a heading and a button. Use onNode with SemanticsMatcher composition to add the missing identity.

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNode

val removeCoffee =
    hasClickAction() and
        hasText("Remove") and
        hasAnyAncestor(hasTestTag("coffee-row"))

composeTestRule.onNode(removeCoffee).assertIsDisplayed()

The matcher says something product-level: there is a clickable Remove control within the Coffee row. It does not assume a particular Row, Column, or number of wrapper composables. The official testing guide also supports hierarchical matchers such as hasParent, hasAnyAncestor, hasAnySibling, and hasAnyDescendant for this kind of focused selection.

Do not over-specify every available property. If the test’s behavior is “remove Coffee,” matching an exact hierarchy, a color, a fixed item count, and every text node turns an otherwise useful test brittle. Add only enough semantics to identify the intended element.

Assert repeated nodes as a collection

onNode… expects one match. For repeating content, select a collection with onAllNodes…, then assert its count or a property shared by the matching nodes.

import androidx.compose.ui.test.assertAll
import androidx.compose.ui.test.assertAny
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onAllNodesWithContentDescription

val removeButtons = composeTestRule
    .onAllNodesWithContentDescription("Remove item")

removeButtons.assertCountEquals(3)
removeButtons.assertAll(hasClickAction())
removeButtons.assertAny(hasText("Remove"))

Keep the collection matcher intentional. A count assertion is appropriate when the exact number is a user requirement—three selected chips, one error banner, or no duplicate controls. It is not a substitute for testing a lazy list’s data layer. For UI behavior around list content and stable item state, see Managing Lists and Collections as Compose State.

Understand merged and unmerged semantics

Compose has both merged and unmerged semantics trees. A Material Button, for example, usually merges the meaning of its icon and text children into one accessible, clickable element. Testing finders use the merged tree by default, which is usually what you want when you are testing a user action.

Button(onClick = onSend) {
    Icon(Icons.Outlined.Send, contentDescription = null)
    Text("Send")
}

composeTestRule.onNodeWithText("Send").performClick()

Sometimes you genuinely need to inspect a child that is hidden by merging—perhaps a custom component has unexpected semantics or a specific text child needs its own check. Ask that finder for the unmerged tree instead:

composeTestRule
    .onNodeWithText("Send", useUnmergedTree = true)
    .assertIsDisplayed()

The Semantics in Compose guide explains that the testing framework uses the merged tree by default, while the unmerged tree retains descendants. Treat useUnmergedTree = true as a debugging and precision tool, not a default flag: an action test against the merged button better reflects what TalkBack users encounter.

When a finder unexpectedly matches zero or several nodes, inspect the tree instead of guessing at more tags.

composeTestRule.onRoot().printToLog("SEMANTICS")
composeTestRule.onRoot(useUnmergedTree = true).printToLog("SEMANTICS_UNMERGED")

The logged tree shows properties such as text, content descriptions, roles, actions, test tags, and merge behavior. Compare both outputs to answer practical questions:

  • Did a parent merge the child you expected to find?
  • Is a content description on the icon, the button, or both?
  • Are there multiple matching rows?
  • Does the custom component expose a click action or selected state at all?

If the semantics are wrong for the product, fix the component rather than writing a selector around the defect. Roles, labels, states, and custom actions deserve the same care as the visual component API.

Common selection mistakes

Assuming assertExists() means visible

A node can exist in the tree while it is clipped or otherwise not visible. Assert display for a visual result, and assert existence when the semantic presence itself is the contract.

Matching the first duplicate label

An onNodeWithText("Delete") test is unclear if a dialog and a list both contain Delete. Scope it with a parent or ancestor matcher, or choose a unique content description that is already appropriate for accessibility.

Adding test-only content descriptions

Do not change what a screen announces merely to create a convenient selector. Give controls truthful accessibility semantics; use a tag when the test needs stable identity that should not be read aloud.

Testing the unmerged tree by habit

The merged tree represents an accessible control as a user typically encounters it. Reach for the unmerged tree only when the child-level contract is important or when you are diagnosing a failed matcher.

Finder and assertion checklist

  • Start with a semantic label, role, state, or content description that matters to users.
  • Use a test tag for stable identity when user-facing semantics are not the right key.
  • Combine matchers only until the selected node is unambiguous.
  • Use assertIsDisplayed() for visible outcomes and assertExists() for semantic presence.
  • Use onAllNodes plus collection assertions when multiple matches are expected.
  • Print merged and unmerged trees before “fixing” a failing selector.

With node selection and assertions in place, the next step is injecting clicks, text input, scrolls, and gestures while preserving the same semantics-first testing style.