06 April 2026
After years of writing Espresso tests that broke every time a layout changed, flaked on CI because animations hadn’t settled, and required scrolling through ActivityScenario boilerplate just to click a button — Compose testing felt like a completely different world. The first time I wrote a Compose test, I set content, found a node by its text, performed a click, and asserted the result. No onView(withId(R.id.something)), no IdlingResource, no waiting for animations. It just worked. The entire test was 8 lines and ran in under a second.
Compose’s testing APIs are built on top of the semantics tree — the same tree that powers accessibility. Instead of finding views by IDs or view hierarchy positions, you find nodes by what they mean: their text, content description, test tag, or semantic properties. Because Compose controls the entire rendering pipeline, the test framework knows exactly when composition is done and when the UI is idle. That built-in synchronization eliminates the entire category of flaky test bugs that plagued Espresso for years.
Here’s the thing — the APIs are well-designed, but the documentation scatters them across multiple pages. This guide covers everything from basic setup to navigation testing, in the order you’d actually learn it on a real project.
Every Compose test needs a ComposeTestRule. There are two factory functions, and picking the wrong one leads to either unnecessary overhead or missing infrastructure.
createComposeRule() creates a standalone test environment with no Activity. You call setContent { } to render your composables directly. This is what you want for 90% of your tests — testing a composable in isolation without Activity lifecycle noise.
createAndroidComposeRule<Activity>() creates a rule backed by a real Activity. You need this when your composable depends on Activity-scoped things — LocalContext.current that must be an Activity, permission handling, or when testing a full screen inside your actual Activity. The tradeoff is that it’s slower and carries Activity lifecycle baggage.
class LoginFormTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `login form renders with empty fields`() {
composeTestRule.setContent {
LoginForm(
onLoginClick = {},
onForgotPasswordClick = {}
)
}
composeTestRule
.onNodeWithText("Email")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Password")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Sign In")
.assertIsDisplayed()
}
}
The setContent block renders whatever you want to test. I keep these blocks minimal — just the composable under test and the minimum state it needs. If your composable needs a theme, wrap it in AppTheme. If it needs a ViewModel, pass in a fake or control the state directly.
One thing that caught me off guard initially: the test rule automatically waits for composition to complete before running assertions. By the time your first onNode call executes, the UI is fully composed and laid out. This synchronization extends to animations too — the framework waits for pending recompositions to settle. If you’ve ever written Thread.sleep(500) in an Espresso test, you’ll appreciate this.
Finders are how you locate nodes in the semantics tree. The core three cover most use cases: onNodeWithText matches visible text or a text field’s label, onNodeWithContentDescription matches what screen readers announce, and onNodeWithTag matches a testTag modifier you’ve explicitly set. I reach for them in that order of preference.
Semantic matchers are always better than test tags because they verify what the user actually sees. If a button says “Submit” and you find it with onNodeWithText("Submit"), your test breaks when the text changes — which is exactly what you want. A testTag("submit_button") wouldn’t catch a label change from “Submit” to “Cancel.”
Test tags are essential when semantic matchers aren’t enough. Complex screens have multiple elements with similar text, or elements that only differ structurally. A form with two text fields both labeled “Name” needs test tags to disambiguate.
// Both fields labeled "Name" — testTag disambiguates
OutlinedTextField(
value = firstName,
onValueChange = onFirstNameChange,
label = { Text("Name") },
modifier = Modifier.testTag("first_name_field")
)
OutlinedTextField(
value = lastName,
onValueChange = onLastNameChange,
label = { Text("Name") },
modifier = Modifier.testTag("last_name_field")
)
// In test — testTag needed here, semantic matcher for email
composeTestRule
.onNodeWithTag("first_name_field")
.performTextInput("Mukul")
composeTestRule
.onNodeWithText("Email")
.performTextInput("mukul@example.com")
You can also combine matchers using hasText(), hasClickAction(), hasScrollAction(), and isDisplayed() with the and operator. This is useful when you need to distinguish between a text label and a clickable button that both contain the same string. onNode(hasText("Delete") and hasClickAction()) finds specifically the clickable “Delete” element, ignoring any static text that says “Delete” elsewhere.
For lists and scrollable content, onAllNodesWithText returns a SemanticsNodeInteractionCollection. You can index into it with [0], [1], or assert on the whole collection with assertCountEquals(3).
Actions simulate user interactions: performClick(), performTextInput(), performTextClearance(), performScrollTo(), performTouchInput { swipeUp() }. Assertions verify state: assertIsDisplayed(), assertTextEquals(), assertIsEnabled(), assertHasClickAction().
The combination of finders, actions, and assertions gives you the full vocabulary for testing any interaction. Here’s a login form test that exercises the complete flow.
@Test
fun `successful login shows welcome message`() {
composeTestRule.setContent {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var message by remember { mutableStateOf<String?>(null) }
LoginScreen(
email = email,
password = password,
message = message,
onEmailChange = { email = it },
onPasswordChange = { password = it },
onLoginClick = {
message = if (email == "user@test.com" && password == "pass123")
"Welcome back!" else "Invalid credentials"
}
)
}
composeTestRule.onNodeWithText("Email").performTextInput("user@test.com")
composeTestRule.onNodeWithText("Password").performTextInput("pass123")
composeTestRule.onNodeWithText("Sign In").performClick()
composeTestRule.onNodeWithText("Welcome back!").assertIsDisplayed()
}
Notice how the test reads like a user story: enter email, enter password, click sign in, see welcome message. There’s no Thread.sleep, no IdlingResource. The framework handles synchronization — when performClick() triggers a recomposition, it waits for completion before the next assertion runs.
One gotcha with performTextInput — it appends to existing text rather than replacing it. If the field already has text, use performTextClearance() first. For password fields that mask input, you can’t use onNodeWithText("pass123") to find the field after typing — use the test tag or content description instead.
The pattern for testing state changes in Compose is clean: set initial state, perform an action, assert the new state. Because Compose’s test framework synchronizes automatically with recomposition, state transitions are deterministic.
@Test
fun `counter increments on button click`() {
composeTestRule.setContent {
var count by remember { mutableStateOf(0) }
Column {
Text(
text = "Count: $count",
modifier = Modifier.testTag("counter_text")
)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
composeTestRule.onNodeWithTag("counter_text").assertTextEquals("Count: 0")
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithTag("counter_text").assertTextEquals("Count: 1")
repeat(3) { composeTestRule.onNodeWithText("Increment").performClick() }
composeTestRule.onNodeWithTag("counter_text").assertTextEquals("Count: 4")
}
For async operations — like a ViewModel launching a coroutine — you need waitUntil. The waitForIdle() call only waits for pending recompositions. waitUntil polls a condition with a timeout, handling the gap between “the UI is idle” and “the data has loaded.”
@Test
fun `loading state transitions to content after fetch`() {
val viewModel = SearchViewModel(FakeSearchRepository())
composeTestRule.setContent { SearchScreen(viewModel = viewModel) }
composeTestRule.onNodeWithText("Search").performTextInput("Kotlin")
composeTestRule.onNodeWithText("Go").performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
composeTestRule
.onAllNodesWithTag("search_result_item")
.fetchSemanticsNodes().isNotEmpty()
}
composeTestRule
.onAllNodesWithTag("search_result_item")
.assertCountEquals(3)
}
The waitUntil timeout defaults to 1 second. For operations with realistic fake delays, I bump it to 5 seconds. If your test consistently needs more than that, the fake is probably too slow — speed it up rather than increasing the timeout.
Here’s the insight that changed how I write Compose tests: you’re not testing pixels or view hierarchies. You’re testing a semantics tree. Every composable that conveys meaning — text, buttons, images with descriptions, checkboxes — adds a node to this tree. The tree is the same one that TalkBack uses, which means well-tested Compose apps are inherently more accessible.
When a test isn’t finding the node you expect, printToLog is your best debugging tool. It dumps the entire semantics tree to logcat so you can see exactly what’s there.
// Dump the full semantics tree to logcat
composeTestRule.onRoot().printToLog("SEMANTICS")
// See unmerged tree (individual child nodes)
composeTestRule.onRoot(useUnmergedTree = true).printToLog("UNMERGED")
The tree output shows properties like Text, ContentDescription, Role, TestTag, Focused, and Selected. I use this whenever a finder returns “no matching node found” — the tree dump shows whether the node exists with different properties than I expected.
The mergeDescendants semantic matters here. When a composable sets Modifier.semantics(mergeDescendants = true), its children’s semantics merge into a single node. Button does this by default — the text inside a Button merges with the button’s semantics. That’s why onNodeWithText("Submit") finds the button, not just the Text composable inside it. When writing tests for complex composables with multiple text elements, sometimes you need useUnmergedTree = true in your finders to match a specific child rather than the merged parent.
// Merged tree (default): Button node has text "Submit Order"
composeTestRule.onNodeWithText("Submit Order").assertHasClickAction()
// Unmerged tree: finds the Text node specifically
composeTestRule.onNodeWithText("Submit Order", useUnmergedTree = true).assertExists()
Testing Compose Navigation adds complexity because you’re no longer testing a single composable in isolation — you’re testing screen transitions driven by a NavController. The key is creating a TestNavHostController and asserting against its current route.
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
@Test
fun `clicking item navigates to detail screen`() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current).apply {
navigatorProvider.addNavigator(ComposeNavigator())
}
NavHost(navController = navController, startDestination = "product_list") {
composable("product_list") {
ProductListScreen(
products = sampleProducts,
onProductClick = { id -> navController.navigate("product_detail/$id") }
)
}
composable("product_detail/{productId}") { backStack ->
ProductDetailScreen(
productId = backStack.arguments?.getString("productId") ?: ""
)
}
}
}
assertEquals("product_list", navController.currentDestination?.route)
composeTestRule.onNodeWithText("Pixel Watch").performClick()
assertEquals("product_detail/{productId}", navController.currentDestination?.route)
}
}
There’s an important subtlety. TestNavHostController doesn’t verify that the navigation graph was set up correctly in your actual Activity — it only tests the graph you define in the test. I treat navigation tests as integration tests for the navigation graph logic, not end-to-end tests for the full app.
For back navigation, call navController.popBackStack() and assert the route returns to the previous destination. For deep links, use navController.navigate(Uri.parse("myapp://product/123")) and assert the route resolved correctly.
The tradeoff with navigation tests is that they’re coupled to your route structure. If you rename a route, every navigation test that references it breaks. Some teams extract route strings into constants shared between production and test code — that way the breakage is a compile error, not a runtime surprise.
Test behavior, not implementation. Assert what the user sees and experiences, not how the composable achieves it internally. “Clicking the favorite button shows a filled heart” is a behavior test. “Clicking the favorite button updates the isFavorite MutableState to true” is an implementation test. The first survives refactors. The second breaks if you switch from MutableState to a ViewModel flow or rename the variable.
Prefer semantic matchers over test tags. Use onNodeWithText, onNodeWithContentDescription, and hasClickAction() first. Only reach for testTag when semantics genuinely can’t distinguish the element you need. Over-relying on test tags creates a parallel naming system that adds maintenance overhead and tests nothing about what the user sees.
Keep tests focused — one assertion concept per test. A test called login flow works that verifies empty validation, successful login, error handling, and password visibility toggle is actually four tests jammed into one. When it fails, you don’t know which behavior broke without reading the whole test. Split them. One test per scenario, each with a descriptive name that tells you exactly what broke when it fails.
Use test robots for complex screens. When a screen has many elements and your tests repeat the same finder chains, extract them into a robot class. The robot encapsulates the “how do I interact with this screen” so your tests only express “what do I want to do.” This is the same Page Object pattern from web testing, and it works beautifully in Compose.
class LoginRobot(private val rule: ComposeTestRule) {
fun enterEmail(email: String) = rule.onNodeWithText("Email").performTextInput(email)
fun enterPassword(pw: String) = rule.onNodeWithText("Password").performTextInput(pw)
fun clickSignIn() = rule.onNodeWithText("Sign In").performClick()
fun assertWelcomeShown() = rule.onNodeWithText("Welcome back!").assertIsDisplayed()
fun assertErrorShown(msg: String) = rule.onNodeWithText(msg).assertIsDisplayed()
}
@Test
fun `valid credentials show welcome message`() {
composeTestRule.setContent { LoginScreen(viewModel) }
val login = LoginRobot(composeTestRule)
login.enterEmail("user@test.com")
login.enterPassword("pass123")
login.clickSignIn()
login.assertWelcomeShown()
}
Question 1: What’s wrong with this test?
@Test
fun `button click test`() {
composeTestRule.setContent {
MyScreen(viewModel = MyViewModel())
}
composeTestRule
.onNodeWithTag("action_button")
.performClick()
composeTestRule
.onNodeWithTag("result_text")
.assertIsDisplayed()
}
Wrong — The test uses test tags for everything instead of semantic matchers. onNodeWithTag("action_button") tells you nothing about what the button says or does. If someone changes the button text from “Submit” to “Cancel” but keeps the same tag, this test still passes. Use onNodeWithText("Submit") and onNodeWithText("Expected result") so the test actually validates what the user sees.
Question 2: Which ComposeTestRule factory should you use to test a composable that calls LocalContext.current as Activity?
Correct — createAndroidComposeRule<YourActivity>(). Because the composable casts the context to an Activity, it needs a real Activity instance. createComposeRule() provides a ComponentActivity by default but doesn’t guarantee it’s your specific Activity subclass. If the composable needs specific Activity methods or theme attributes, use createAndroidComposeRule with the correct Activity type.
Build a TaskListScreen composable that displays tasks with checkboxes. Each task has a title and completed state. Tapping a checkbox toggles completion. An “Add Task” button shows a text field and “Save” button. Write tests covering: rendering the initial list, toggling completion, adding a new task, and verifying it appears. Use semantic matchers where possible. Bonus: extract a TaskListRobot class.
Thanks for reading!