Compose Test Setup
Compose Test Setup
Compose UI testing uses a different framework than Espresso. It operates on the semantics tree instead of the view hierarchy.
Dependencies
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
The test manifest provides the Compose activity that hosts your composables during tests. Without it, tests crash with a missing activity error.
ComposeTestRule
createComposeRule() is the entry point for all Compose tests:
@RunWith(AndroidJUnit4::class)
class ProductCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun productCard_displaysNameAndPrice() {
composeTestRule.setContent {
MaterialTheme {
ProductCard(
product = Product(
id = "1",
name = "Widget",
price = 9.99
)
)
}
}
composeTestRule.onNodeWithText("Widget").assertIsDisplayed()
composeTestRule.onNodeWithText("$9.99").assertIsDisplayed()
}
}
setContent renders your composable inside the test activity. All onNode calls happen after this.
Testing a Full Activity
If you need to test a composable within its actual Activity (for lifecycle, navigation, etc.), use ActivityScenarioRule:
@get:Rule
val activityRule = ActivityScenarioRule(ProductActivity::class.java)
This launches the real Activity. Use createComposeRule() when you only need to test an isolated composable.
Testing Coroutine Launched Effects
If your composable launches coroutines, use MainDispatcherRule or runTest:
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun loadingIndicator_shownWhileFetching() = composeTestRule.runTest {
setContent { LoadingScreen(isLoading = true) }
onNodeWithTag("loading_indicator").assertIsDisplayed()
}
Finding and Matching Nodes
Finding and Matching Nodes
Compose tests find UI elements through the semantics tree. Every composable exposes semantic properties that tests can query.
Finding by Text
// Exact text match
composeTestRule.onNodeWithText("Submit")
// Partial text match
composeTestRule.onNodeWithSubstring("Submit")
// Text with ignore case
composeTestRule.onNodeWithText("submit", ignoreCase = true)
Finding by Test Tag
Test tags are the most reliable way to find composables. Add them in production code:
// In your composable
Button(
modifier = Modifier.testTag("login_button"),
onClick = { /* ... */ }
) {
Text("Login")
}
// In your test
composeTestRule.onNodeWithTag("login_button")
Finding by Content Description
composeTestRule.onNodeWithContentDescription("Close dialog")
Finding by Semantics Properties
// Find by role
composeTestRule.onNode(hasText("Email") and hasClickAction())
// Find by semantics
composeTestRule.onNode(
hasSemantics {
isEditable = true
}
)
Filtering and Combining
// First match only
composeTestRule.onAllNodesWithText("Item").onFirst()
// Last match
composeTestRule.onAllNodesWithText("Item").onLast()
// At index
composeTestRule.onAllNodesWithText("Item").fetchSemanticsNodes().size // count
Unmatched Node Errors
When a node is not found, Espresso gives a clear error:
No node found that matches: hasText("Nonexistent")
This means the composable did not render the expected text. Common causes:
- Wrong text in the composable
- Conditional rendering hiding the element
- Navigation not reaching the expected screen
Actions and Assertions
Actions
Actions interact with composable nodes:
// Click
composeTestRule.onNodeWithTag("submit").performClick()
// Text input
composeTestRule.onNodeWithTag("email_input")
.performTextInput("test@example.com")
// Replace text
composeTestRule.onNodeWithTag("search")
.performTextReplacement("new query")
// Scroll
composeTestRule.onNodeWithTag("list")
.performScrollToIndex(5)
// Touch actions
composeTestRule.onNodeWithTag("image")
.performTouchInput {
swipeUp()
}
// Key events
composeTestRule.onNodeWithTag("field")
.performKeyInput {
key Press(KeyEvent.KEYCODE_ENTER)
}
Assertions
Assertions verify composable state:
// Displayed / Not displayed
composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
composeTestRule.onNodeWithTag("error").assertDoesNotExist()
// Text content
composeTestRule.onNodeWithTag("title")
.assertTextEquals("Product Details")
// Partial text
composeTestRule.onNodeWithTag("description")
.assertTextContains("premium")
// Enabled / Disabled
composeTestRule.onNodeWithTag("submit").assertIsEnabled()
composeTestRule.onNodeWithTag("disabled_btn").assertIsNotEnabled()
// Selected
composeTestRule.onNodeWithTag("tab_active").assertIsSelected()
// Clickable
composeTestRule.onNodeWithTag("link").assertHasClickAction()
Waiting for Async Content
Compose tests wait for composition to settle, but for async data loading use waitUntil:
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("product_item")
.fetchSemanticsNodes().isNotEmpty()
}
Testing Navigation
Use NavHostController test doubles to verify navigation:
@Test
fun loginButton_navigatesToHome() {
val navController = rememberNavController()
composeTestRule.setContent {
NavHost(navController, startDestination = "login") {
composable("login") { LoginScreen(onLoginSuccess = { navController.navigate("home") }) }
composable("home") { HomeScreen() }
}
}
composeTestRule.onNodeWithTag("login_button").performClick()
composeTestRule.waitUntil(timeoutMillis = 3000) {
navController.currentDestination?.route == "home"
}
}
Quiz
1. Why is testTag preferred over withText for finding composables?
2. What does createComposeRule() do?
3. How do you wait for async data to appear in a Compose test?
4. What dependency provides the Compose test manifest?
Flashcards
Question
What is the difference between createComposeRule() and ActivityScenarioRule()?
Click to reveal answer
Answer
createComposeRule() hosts composables in a test Activity for isolated testing. ActivityScenarioRule() launches the real Activity for integration-level testing.
Question
How do you add a test tag to a composable?
Click to reveal answer
Answer
Use Modifier.testTag("tag_name") in your composable. Then find it with onNodeWithTag("tag_name") in tests.
Question
How do you type text into a Compose input field?
Click to reveal answer
Answer
Use performTextInput("text") for appending or performTextReplacement("text") to replace existing text.
Question
How do you verify a composable is NOT shown?
Click to reveal answer
Answer
Use assertDoesNotExist() on the node finder. Example: onNodeWithTag("error").assertDoesNotExist()
Revision Notes
Key Takeaways
- 1. Use testTag for stable node identification instead of text which can change
- 2. createComposeRule() is for isolated composable tests, ActivityScenarioRule for Activity tests
- 3. The ui-test-manifest debugImplementation is required for Compose tests to work
- 4. waitUntil replaces Thread.sleep for waiting on async composable state
- 5. Always wrap composables in MaterialTheme when testing to avoid missing theme dependencies
Interview Tips
- • Explain the semantics tree and how Compose tests differ from Espresso tests
- • Discuss why testTag is preferred over withText for finding composables
- • Know how to test async loading states with waitUntil
- • Be ready to explain how you would test navigation between composable screens
Cheat Sheet
Compose Testing Cheat Sheet
Setup:
- createComposeRule() for isolated composables
- ActivityScenarioRule for full Activity testing
- debugImplementation(ui-test-manifest) required
Finding Nodes:
- onNodeWithText("text")
- onNodeWithTag("tag") — preferred
- onNodeWithContentDescription("desc")
- onAllNodesWithText("text")
Actions:
- performClick()
- performTextInput("text")
- performTextReplacement("text")
- performScrollToIndex(n)
- performTouchInput { swipeUp() }
Assertions:
- assertIsDisplayed() / assertDoesNotExist()
- assertTextEquals("text") / assertTextContains("sub")
- assertIsEnabled() / assertIsNotEnabled()
- assertIsSelected()
- assertHasClickAction()
Async:
- waitUntil(timeoutMillis) { condition }