How do you write Compose UI tests?
Tier: EssentialDifficulty: Medium
You add a compose test rule, set the composable as content, then find a node, act on it, and assert on the result. There is no view hierarchy to query, so every test goes through the semantics tree instead, which is the parallel tree Compose builds to describe what the UI means rather than how it looks.
That semantics tree is the whole mental model. Accessibility services read it, and the test framework reads the same thing, so a screen that is easy to test is usually a screen that is easy to use with TalkBack.
The two rules
createComposeRule()hosts your composable in a bare activity that the test framework supplies. Use it whenever you are testing a composable in isolation, which should be most of the time, because there is no real activity to launch and no navigation graph in the way.createAndroidComposeRule<MyActivity>()launches a real activity, so you get the activity, its intent extras, and anything the activity itself sets up. Reach for it only when the test genuinely needs the activity, for example a flow that crosses into a fragment or reads a deep link.setContentis where you install the UI, and it is called once per test. Wrap the composable in your theme, because a missing theme changes colours and sizes and makes screenshot comparisons meaningless.
Finders, assertions and actions
- Finders.
onNodeWithText,onNodeWithTag,onNodeWithContentDescription, andonAllNodesWithTagwhen you expect several.onNode(matcher)composes matchers withandandorwhen a single attribute is not specific enough. - Assertions.
assertIsDisplayed,assertIsEnabled,assertIsSelected,assertTextEquals,assertCountEqualson a collection, andassertDoesNotExistfor the negative case, which is the one people forget. - Actions.
performClick,performTextInput,performTextClearance,performScrollTo,performScrollToIndexon a lazy list, andperformSemanticsActionfor anything custom.
testTag versus content description
- Content description first, when the element needs one anyway. An icon button, an image that carries meaning. Finding it by content description tests the accessibility label at the same time, so one line does two jobs.
- Text next, for anything a user actually reads. Finding a button by its label is the closest a test gets to describing what a person sees, and it fails honestly when the copy changes in a way that matters.
Modifier.testTag("...")last, as an escape hatch. Use it for a container, a list, a chart, anything with no natural label, or when the visible text is dynamic and the test would be asserting on data rather than structure. A tag is invisible to users, so it proves nothing about accessibility, and a screen tagged everywhere is a screen nobody checked with TalkBack.
Synchronisation
- Compose tests wait for idle automatically. Before every assertion and action, the rule drains the compose clock and the recomposition queue, so you do not need a sleep. This is why a well written Compose test is far less flaky than the equivalent view test.
- The waiting only covers work Compose knows about. A coroutine on a background dispatcher, a real network call, an animation driven by something outside the composition, none of that is in the idling contract, which is exactly the same trap Espresso has with idling resources, described in what is Espresso.
waitUntilis the tool for asynchronous state. It polls a condition with a timeout, so you can wait for a node to appear after a fake repository resolves. Give it a real condition to check, never a bare timeout, and keep the timeout tight so a genuine failure fails fast.mainClocklets you pause and advance the compose clock by hand, which is how you assert on a specific frame of an animation instead of waiting for it to settle.
class LoginScreenTest {
@get:Rule val rule = createComposeRule()
@Test
fun `an invalid email shows the error and keeps submit disabled`() {
val viewModel = LoginViewModel(FakeAuthRepository())
rule.setContent { AppTheme { LoginScreen(viewModel) } }
rule.onNodeWithText("Email").performTextInput("not-an-email")
rule.onNodeWithTag("submit").performClick()
// Waits for the fake to resolve rather than sleeping for a fixed time.
rule.waitUntil(timeoutMillis = 2_000) {
rule.onAllNodesWithText("Enter a valid email")
.fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("submit").assertIsNotEnabled()
// When a finder returns nothing, print the tree and look at it.
rule.onRoot(useUnmergedTree = true).printToLog("LoginScreenTest")
}
}
Wiring up the dependencies
- A fake ViewModel or plain state is the default. Construct the real ViewModel with fake repositories, or better, make the composable take a state object and a lambda so the test can pass state directly and assert on what the lambda received. That version needs no ViewModel at all.
- A Hilt test when the graph really is the point. Annotate with
@HiltAndroidTest, addHiltAndroidRuleatorder = 0and the compose rule atorder = 1, run through a custom runner that installsHiltTestApplication, and swap bindings with@BindValueor a@TestInstallInmodule. Rule ordering is the part people get wrong, Hilt has to inject before the activity starts.
The unmerged tree trap
By default the test framework reads the merged semantics tree, where a Button containing an icon and a label collapses into one node carrying the merged properties. That is usually what you want, onNodeWithText("Like").performClick() finds the button rather than the text inside it. It is also why a finder aimed at a child fails with a node not found error even though you can see the thing on screen. Passing useUnmergedTree = true keeps every node separate so you can reach the child, and the honest first move whenever a finder misses is onRoot(useUnmergedTree = true).printToLog(tag), which dumps the whole tree to logcat with every property on every node.
In the room, say semantics tree in the first sentence, because that is the word that separates someone who has written these tests from someone who has read about them. Then name the rule you use and why, describe find, act, assert in one breath, and finish on synchronisation, that Compose waits for idle for you but only for work inside the composition, so anything asynchronous outside it needs waitUntil.
Read more Test your Compose layout (opens in a new tab)Testing cheatsheet (opens in a new tab)Semantics (opens in a new tab)