Asserting Node State and Text — Verify Through the Framework, Not Through fetchSemanticsNode
Once a finder resolves to a SemanticsNodeInteraction, the next step is asserting it. Compose ships a typed assertion for almost every semantic property; the generic assert(matcher) covers the rest. This skill picks the right assertion, distinguishes "exists" from "displayed", and shows when boolean predicates belong inside waitUntil.
When to use this skill
- The developer wants to verify a Switch is on, a Checkbox is unchecked, a Button is enabled, a Text equals an expected string.
- The developer asks about
assertIsDisplayedvsassertExists(one verifies on-screen, the other verifies presence in the tree). - The developer needs a custom assertion via
assert(matcher)for a property without a typed extension. - The developer wants to verify a collection: "exactly 3 items", "at least one is selected", "all are enabled".
- A test compares
node.config[...]directly instead of using a typed assertion.
When NOT to use this skill
- The right node cannot be located — see
../../finders/finding-nodes-by-tag-text-content/SKILL.md. - The assertion is geometric (width, height, position) — see
./asserting-bounds-and-dimensions/SKILL.md. - The check needs a custom
SemanticsMatcher— see../../finders/composing-semantics-matchers/SKILL.md. - The state changes asynchronously and the assertion is timing-sensitive — see
../../synchronization/synchronizing-with-idle/SKILL.md.
Prerequisites
- A working
ComposeTestRule/ComposeUiTest. See../../setup/configuring-test-dependencies/SKILL.md. - The node has been located through
onNode*/onAllNodes*/ a navigator. - For
assertIsDisplayedsemantics, the node must be composed AND placed AND at least partially on-screen post-clip (Assertions.kt:30-39).
Workflow
1. Pick by question type. Map the developer's question to one assertion call. Every API listed is in
commonMain/.../Assertions.ktunless noted.Question API File:line Is it in the tree? assertExists(errorMessageOnFail)SemanticsNodeInteraction.kt:124-127Is it gone? assertDoesNotExist()SemanticsNodeInteraction.kt:94-110Is it a SubcomposeLayout retained child? assertIsDeactivated()SemanticsNodeInteraction.kt:137-148Is it visible to the user? assertIsDisplayed()/assertIsNotDisplayed()Assertions.kt:32-53Is it interactive? assertIsEnabled()/assertIsNotEnabled()Assertions.kt:60-67Is the toggle on? assertIsOn()/assertIsOff()/assertIsToggleable()Assertions.kt:74-81, 103Is the radio/tab selected? assertIsSelected()/assertIsNotSelected()/assertIsSelectable()Assertions.kt:88-110Does it have focus? assertIsFocused()/assertIsNotFocused()Assertions.kt:117-124Does it equal these strings? assertTextEquals(vararg, includeEditableText=true)Assertions.kt:181-185Does it contain this string? assertTextContains(value, substring=false, ignoreCase=false)Assertions.kt:204-208Content description equals these? assertContentDescriptionEquals(vararg)Assertions.kt:139-141Content description contains this? assertContentDescriptionContains(value, substring, ignoreCase)Assertions.kt:158-163StateDescription equals X? assertValueEquals(value)Assertions.kt:216-217ProgressBar at this position? assertRangeInfoEquals(rangeInfo)Assertions.kt:225-227Has a click action? assertHasClickAction()/assertHasNoClickAction()Assertions.kt:234-243Anything else assert(matcher, messagePrefixOnError = null)Assertions.kt:254-2672.
assertIsDisplayedvsassertExists— pick deliberately. A nodeassertExists()if it is in the semantics tree (composed). A nodeassertIsDisplayed()if it is composed AND placed AND at least partially visible after clipping (Assertions.kt:30-39). For LazyColumn items not yet scrolled into view,assertExists()may fail (item not composed) or succeed (item composed but off-screen — depends on prefetch);assertIsDisplayed()is the right gate for "the user sees this".3. Use the typed assertion, not
fetchSemanticsNode().config[...]. Typed assertions delegate to the matcher library and produce framework-formatted errors that name the failed property and dump the node (Assertions.kt:254-267— the genericassertbuildsbuildGeneralErrorMessage(errorMessageOnFail, selector, node)). Direct config reads bypass that error reporting and miss the auto-attached node dump.4. For collections, use the collection-typed assertions.
assertCountEquals(expectedSize)for cardinality (Assertions.kt:276-292),assertAny(matcher)for "at least one matches" (fails on empty —Assertions.kt:300-312),assertAll(matcher)for "every node matches" (passes on empty —Assertions.kt:323-339).5. For
waitUntilpredicates, use boolean APIs, not throwing assertions.isDisplayed()returnsBoolean(Assertions.kt:351) — perfect forrule.waitUntil { node.isDisplayed() }.isNotDisplayed()is its inverse (Assertions.kt:361). Both throw if multiple nodes match the finder, but never on zero. Cross-reference:../../synchronization/synchronizing-with-idle/SKILL.md.6. For one-off properties without a typed assertion, use
assert(matcher). Combine with prebuilt or composed matchers from../../finders/composing-semantics-matchers/SKILL.md. ThemessagePrefixOnErrorlambda is for adding context when this assert is a precondition for a larger operation.
Patterns
Pattern: assertIsDisplayed over assertExists for user-facing checks
// WRONG
@Test
fun submit_isVisibleAfterError() {
rule.setContent { CheckoutScreen(state = state) }
rule.onNodeWithTag(SubmitTag).assertExists()
}
// WRONG because: assertExists passes for nodes that are composed but off-screen, behind a
// dialog, or measured to zero size. The test does not prove the user actually sees the button.
// RIGHT
@Test
fun submit_isVisibleAfterError() {
rule.setContent { CheckoutScreen(state = state) }
rule.onNodeWithTag(SubmitTag).assertIsDisplayed()
}
Pattern: typed assertIsOn over manual config reads
// WRONG
@Test
fun darkMode_switch_isOn() {
rule.setContent { SettingsScreen() }
val node = rule.onNodeWithTag(DarkModeSwitchTag).fetchSemanticsNode()
assertEquals(ToggleableState.On, node.config[SemanticsProperties.ToggleableState])
}
// WRONG because: bypasses the framework's error reporting. Failure prints a JUnit assertEquals
// diff with no node dump, no selector description, no hint about which screen the failure
// happened on.
// RIGHT
@Test
fun darkMode_switch_isOn() {
rule.setContent { SettingsScreen() }
rule.onNodeWithTag(DarkModeSwitchTag).assertIsOn()
}
assertIsOn is assert(isOn()) (Assertions.kt:74); isOn() is expectValue(SemanticsProperties.ToggleableState, ToggleableState.On) (Filters.kt:61-62). The error names the property and dumps the node automatically.
Pattern: assertTextEquals (vararg) handles merged Text + EditableText
// production:
TextField(value = "hello", label = { Text("Name") },
modifier = Modifier.testTag(NameFieldTag))
// test:
rule.onNodeWithTag(NameFieldTag).assertTextEquals("Name", "hello")
assertTextEquals(vararg) matches the unordered set of SemanticsProperties.Text plus, by default, SemanticsProperties.EditableText (Assertions.kt:181-185 → Filters.kt:274-293). To exclude editable text from the comparison: assertTextEquals("Name", includeEditableText = false).
Pattern: assertCountEquals instead of onAllNodes(...).onFirst().assertExists()
// WRONG
rule.onAllNodesWithTag(ItemTag).onFirst().assertExists()
// WRONG because: only proves there is at least one item. Drift to zero items causes
// "no node matched" instead of a count error; drift to many is silently accepted.
// RIGHT
rule.onAllNodesWithTag(ItemTag).assertCountEquals(3)
Pattern: assert(matcher) for properties without a typed assertion
@Test
fun row_hasPriorityOne() {
rule.setContent { TaskList(tasks = tasks) }
rule.onNodeWithTag(TaskRowTag)
.assert(SemanticsMatcher.expectValue(PriorityKey, 1))
}
PriorityKey is a custom SemanticsPropertyKey<Int> set via Modifier.semantics { priority = … }. See ../../finders/composing-semantics-matchers/SKILL.md for matcher composition.
Pattern: assertAny (fails on empty) vs assertAll (passes on empty)
// "at least one row is selected" — must have at least one row
rule.onAllNodesWithTag(RowTag).assertAny(isSelected())
// "every row is enabled" — accepts zero rows (passes vacuously)
rule.onAllNodesWithTag(RowTag).assertAll(isEnabled())
assertAny throws AssertionError("Failed to assertAny … no node matched") on an empty collection (Assertions.kt:305-307). assertAll returns successfully on an empty collection (Assertions.kt:323-339). Pick deliberately.
Pattern: isDisplayed() inside waitUntil
// WRONG
rule.waitUntil { rule.onNodeWithTag(SnackbarTag).assertIsDisplayed(); true }
// WRONG because: assertIsDisplayed throws on the first poll where the snackbar isn't yet
// shown. waitUntil expects a boolean — predicates that throw will fail-fast.
// RIGHT
rule.waitUntil(timeoutMillis = 2_000) {
rule.onAllNodesWithTag(SnackbarTag).fetchSemanticsNodes().isNotEmpty() &&
rule.onNodeWithTag(SnackbarTag).isDisplayed()
}
rule.onNodeWithTag(SnackbarTag).assertTextContains("Saved")
isDisplayed() returns false when zero nodes match and only throws on multiple matches (Assertions.kt:343-351). Cross-reference: ../../synchronization/synchronizing-with-idle/SKILL.md for waitUntil vs mainClock.advanceTimeUntil.
Pattern: assertIsDeactivated for SubcomposeLayout retained children
// LookaheadLayout / SubcomposeLayout may retain a previously composed slot for reuse.
// To verify the slot is currently deactivated (kept, not active):
rule.onNodeWithTag(RetainedSlotTag).assertIsDeactivated()
assertIsDeactivated fetches the node without skipping deactivated ones (SemanticsNodeInteraction.kt:137-148) and checks node.layoutInfo.isDeactivated.
Mandatory rules
- MUST use the typed assertion (
assertIsOn,assertIsEnabled,assertTextEquals, …) overfetchSemanticsNode().config[...]. The framework's error message includes the selector, the node dump, and the failed clause. - MUST use
assertIsDisplayed()when the contract is "the user sees this"; MUST useassertExists()only when the contract is "this is in the semantics tree" (e.g. checking presence in unmerged tree without on-screen requirement). - MUST use the boolean
isDisplayed()/isNotDisplayed()insidewaitUntil { … }blocks; MUST NOT call throwingassertIs*from insidewaitUntil. - MUST use
assertCountEqualsfor collection cardinality; MUST NOT index[0]to imply "the only one". - MUST NOT repeat the same assertion across chained calls when one composed matcher would do — see
../../finders/composing-semantics-matchers/SKILL.md. - PREFERRED: prefer tag-anchored lookups before assertions. Skydoves hot take #1.
Verification
- No assertion reads
node.config[...]directly. All property checks go throughassertIs*/assertTextEquals/assert(matcher). -
assertIsDisplayedis used for user-visible checks;assertExistsonly when on-screen visibility is irrelevant. -
waitUntil { … }blocks useisDisplayed()/isNotDisplayed()/fetchSemanticsNodes().size— no throwingassertIs*calls inside. -
assertAny/assertAllchoice matches the intended empty-collection semantics. -
./gradlew :app:connectedDebugAndroidTest(device) or:app:testDebugUnitTest(host) passes. - Failure messages mention the offending semantic property by name (proof the typed assertion is in use).
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/Assertions.kt— everyassertIs*/assertTextEquals/assertContentDescriptionEquals/assertHasClickAction/assertCountEquals/assertAll/assertAny/isDisplayed/isNotDisplayed.compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/SemanticsNodeInteraction.kt—assertExists,assertDoesNotExist,assertIsDeactivated,fetchSemanticsNode.compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt—isOn,isEnabled,hasText,hasClickActionunderlying matchers used by typed assertions.