Compose UI Testing
Compose UI tests operate on the semantics tree, a parallel description of the UI generated automatically from your composables that exposes properties like text content, content descriptions, click actions, and state such as whether an element is checked or enabled — the same tree screen readers use for accessibility. Rather than testing pixel positions, a Compose UI test finds nodes by these semantic properties (onNodeWithText, onNodeWithContentDescription, onNodeWithTag) and then performs actions (performClick, performTextInput) or assertions (assertIsDisplayed, assertTextEquals) against them. The core entry point is the ComposeTestRule, obtained via createComposeRule() for a test that hosts an isolated composable directly, or createAndroidComposeRule<MyActivity>() when the test needs to run inside a real Activity, for example to test navigation or interaction with Android system APIs.
Cricket analogy: The semantics tree is like a cricket scoreboard's structured data feed rather than the TV picture itself, letting a test find 'the not-out batsman's name' by role instead of pixel coordinates, similar to onNodeWithText locating a labeled button.
Finding nodes and Modifier.testTag
Text and content descriptions are the preferred finders because they double-check that real, user-facing labels are present and correct, which also indirectly verifies accessibility. When a composable has no unique visible text — an icon-only button, or multiple items with identical text — Modifier.testTag("some_tag") gives it a stable identifier that onNodeWithTag can target regardless of visible content, which is especially useful in lists where the same label might repeat across items and semantic disambiguation by text alone would be ambiguous or brittle.
Cricket analogy: Text finders are like identifying a fielder by the name on their jersey, but when two substitute fielders wear identical training bibs, a stable squad number tag (like Modifier.testTag) is needed to tell them apart reliably.
Compose's test synchronization
One of Compose testing's most important properties is that ComposeTestRule automatically synchronizes with Compose's own recomposition, layout, and drawing pipeline: performClick() doesn't return until Compose has processed the resulting state change and recomposed, so a subsequent assertion sees the updated UI without a manual sleep or wait. This synchronization does not extend to coroutines you launch yourself outside of Compose's clock, such as a ViewModel's viewModelScope.launch calling a suspending repository function — for those, tests typically use mainClock.advanceTimeBy(...) for Compose-driven animations, or, more commonly, inject a deterministic fake ViewModel/repository so there's no real asynchronous work for the test to wait on at all.
Cricket analogy: ComposeTestRule syncing with recomposition is like a third umpire's review only resolving after Hawk-Eye finishes rendering the ball's full trajectory, so no one calls the decision on a half-drawn frame; but a separate DRS satellite feed lag (external coroutines) isn't covered, so tests often just fake the review data entirely.
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun addingTask_appearsInList_andClearsInputField() {
composeTestRule.setContent {
MaterialTheme {
TaskScreen(viewModel = FakeTaskViewModel())
}
}
composeTestRule.onNodeWithTag("task_input_field")
.performTextInput("Buy milk")
composeTestRule.onNodeWithContentDescription("Add task")
.performClick()
composeTestRule.onNodeWithText("Buy milk")
.assertIsDisplayed()
composeTestRule.onNodeWithTag("task_input_field")
.assertTextEquals("")
}
@Test
fun completingTask_showsCheckedState() {
composeTestRule.setContent {
MaterialTheme { TaskScreen(viewModel = FakeTaskViewModel(initialTasks = listOf(sampleTask))) }
}
composeTestRule.onNodeWithTag("task_checkbox_1")
.performClick()
.assertIsOn()
}Semantics-based testing means a UI test written against onNodeWithText or onNodeWithContentDescription is effectively also an accessibility smoke test — if TalkBack couldn't identify a node either, the test would fail to find it too.
Overusing Modifier.testTag as the default finder strategy, instead of text or content description, silently erodes the accessibility-verification benefit of Compose testing — a screen full of testTag-only finders can pass even if a screen reader user would have no idea what any of the elements are.
- Compose UI tests query a semantics tree — the same data screen readers use — via finders like onNodeWithText and onNodeWithTag.
- ComposeTestRule (createComposeRule or createAndroidComposeRule<Activity>) is the entry point that hosts composables under test.
- performClick(), performTextInput(), and similar actions auto-synchronize with Compose's recomposition and layout pipeline.
- Modifier.testTag() is a fallback finder for composables without unique visible text, such as icon-only buttons.
- External coroutine work (e.g. real ViewModel/repository calls) isn't covered by Compose's auto-sync, so tests typically use fakes to stay deterministic.
- Preferring text/content-description finders over testTag also indirectly verifies accessibility labeling.
Practice what you learned
1. What underlying structure do Compose UI test finders like onNodeWithText query against?
2. Why does performClick() not require a manual sleep before the next assertion in a Compose UI test?
3. When is Modifier.testTag() the appropriate finder strategy?
4. Why are fake ViewModels/repositories commonly used in Compose UI tests instead of real ones?
5. When would you use createAndroidComposeRule<MyActivity>() instead of createComposeRule()?
Was this page helpful?
You May Also Like
Unit Testing ViewModels
Testing ViewModels in isolation relies on fake repositories, a controlled coroutine dispatcher, and asserting on emitted StateFlow values without touching Android framework classes.
State and remember
Understand how Compose tracks mutable values across recompositions using the remember function, and why state that isn't remembered gets lost.
Buttons and User Input
Learn how Jetpack Compose handles clicks, text entry, and other user interactions through composables like Button, TextField, and gesture modifiers.
ViewModel Basics
What Android's ViewModel class does, how it survives configuration changes, and how to create and scope ViewModels correctly in a Compose app.