asserting-node-state-and-text
About asserting-node-state-and-text
A Compose testing skill for developers asserting properties of located semantics nodes. It maps common checks to typed assertions and explains visibility, collection assertions, custom matchers, and boolean predicates for waitUntil.
Key features
- 01Maps node properties to typed assertions
- 02Distinguishes assertExists from assertIsDisplayed
- 03Covers collection count, any, and all checks
- 04Supports custom matcher assertions
- 05Uses boolean visibility checks with waitUntil
Use cases
- 01Checking a Compose toggle or text value
- 02Verifying a node is visible rather than merely present
- 03Asserting collection size or selection state
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill asserting-node-state-and-textAfter installing, type/asserting-node-state-and-textin Claude Code to confirm it loaded.
Details
From the project READMEAsserting 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-267 -
2.
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", onValueChange = {}, 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.