Skip to content
intermediate Phase 10 · Testing

Compose Testing

Test Compose UIs with ComposeTestRule: find nodes, perform actions, and assert state.

50m
3 problems
Topic Progress 0%

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?

Question 1 options

2. What does createComposeRule() do?

Question 2 options

3. How do you wait for async data to appear in a Compose test?

Question 3 options

4. What dependency provides the Compose test manifest?

Question 4 options

Flashcards

Question

What is the difference between createComposeRule() and ActivityScenarioRule()?

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?

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?

Answer

Use performTextInput("text") for appending or performTextReplacement("text") to replace existing text.

Question

How do you verify a composable is NOT shown?

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 }