Lesson 42 of 83 intermediate

Compose UI Testing Fundamentals

Test your Jetpack Compose UI without an emulator — find, interact, and assert on composables

Open interactive version (quiz + challenge)

Real-world analogy

Compose UI testing is like a blind quality inspector who uses a tactile map (the semantic tree) to navigate your UI. They cannot see pixels — they feel the labels, roles, and content descriptions. If your button has a clear content description and text, the inspector can find it, press it, and verify the result. If your UI has no semantic information, the inspector is lost — even if the UI looks beautiful on screen.

What is it?

Compose UI testing uses the Compose semantic tree — a parallel structure describing what composables are and do, not how they look — to find, interact with, and assert on UI elements. createComposeRule() hosts composables in a test environment. Finders (onNodeWithText, onNodeWithTag) locate semantic nodes. Actions (performClick, performTextInput) simulate user interactions. Assertions (assertIsDisplayed, assertTextEquals) verify state. Tests run on device or emulator but are much faster and less brittle than Espresso tests.

Real-world relevance

A fintech app's secure payment flow required UI tests for PCI compliance documentation — the test suite needed to prove that the CVV field was cleared after form submission, that the 'Pay Now' button was disabled while processing, and that error messages appeared for invalid card numbers. Compose UI tests with testTag-based finders and assertIsNotEnabled() checks covered all three requirements reliably, ran in 8 seconds per test on a mid-range emulator, and became part of the required CI gate before any payment flow change could be merged.

Key points

Code example

// Test tags — define as constants to avoid typos
object TestTags {
    const val LOGIN_EMAIL_FIELD = "login_email_field"
    const val LOGIN_PASSWORD_FIELD = "login_password_field"
    const val LOGIN_BUTTON = "login_button"
    const val LOGIN_ERROR_MESSAGE = "login_error_message"
    const val LOADING_INDICATOR = "loading_indicator"
    const val ORDER_LIST = "order_list"
}

// Composable using testTags
@Composable
fun LoginScreen(
    uiState: LoginUiState,
    onLoginClick: (email: String, password: String) -> Unit
) {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column {
        TextField(
            value = email,
            onValueChange = { email = it },
            modifier = Modifier.testTag(TestTags.LOGIN_EMAIL_FIELD)
        )
        TextField(
            value = password,
            onValueChange = { password = it },
            modifier = Modifier.testTag(TestTags.LOGIN_PASSWORD_FIELD)
        )
        if (uiState.isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.testTag(TestTags.LOADING_INDICATOR)
            )
        }
        Button(
            onClick = { onLoginClick(email, password) },
            enabled = !uiState.isLoading,
            modifier = Modifier.testTag(TestTags.LOGIN_BUTTON)
        ) {
            Text("Login")
        }
        if (uiState.error != null) {
            Text(
                text = uiState.error,
                modifier = Modifier.testTag(TestTags.LOGIN_ERROR_MESSAGE)
            )
        }
    }
}

// UI Tests
class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `login button is enabled when not loading`() {
        composeTestRule.setContent {
            LoginScreen(
                uiState = LoginUiState(isLoading = false, error = null),
                onLoginClick = { _, _ -> }
            )
        }
        composeTestRule.onNodeWithTag(TestTags.LOGIN_BUTTON).assertIsEnabled()
        composeTestRule.onNodeWithTag(TestTags.LOADING_INDICATOR).assertDoesNotExist()
    }

    @Test
    fun `login button is disabled and spinner shown during loading`() {
        composeTestRule.setContent {
            LoginScreen(
                uiState = LoginUiState(isLoading = true, error = null),
                onLoginClick = { _, _ -> }
            )
        }
        composeTestRule.onNodeWithTag(TestTags.LOGIN_BUTTON).assertIsNotEnabled()
        composeTestRule.onNodeWithTag(TestTags.LOADING_INDICATOR).assertIsDisplayed()
    }

    @Test
    fun `error message is shown when uiState has error`() {
        composeTestRule.setContent {
            LoginScreen(
                uiState = LoginUiState(isLoading = false, error = "Invalid credentials"),
                onLoginClick = { _, _ -> }
            )
        }
        composeTestRule.onNodeWithTag(TestTags.LOGIN_ERROR_MESSAGE)
            .assertIsDisplayed()
            .assertTextEquals("Invalid credentials")
    }

    @Test
    fun `tapping login button calls onLoginClick with entered credentials`() {
        var capturedEmail = ""
        var capturedPassword = ""

        composeTestRule.setContent {
            LoginScreen(
                uiState = LoginUiState(isLoading = false, error = null),
                onLoginClick = { email, password ->
                    capturedEmail = email
                    capturedPassword = password
                }
            )
        }

        composeTestRule.onNodeWithTag(TestTags.LOGIN_EMAIL_FIELD)
            .performTextInput("alice@fintech.com")
        composeTestRule.onNodeWithTag(TestTags.LOGIN_PASSWORD_FIELD)
            .performTextInput("SecurePass123")
        composeTestRule.onNodeWithTag(TestTags.LOGIN_BUTTON)
            .performClick()

        assertThat(capturedEmail).isEqualTo("alice@fintech.com")
        assertThat(capturedPassword).isEqualTo("SecurePass123")
    }

    @Test
    fun `navigation to home screen after successful login`() {
        val navController = TestNavHostController(
            ApplicationProvider.getApplicationContext()
        )
        composeTestRule.setContent {
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            NavHost(navController, startDestination = "login") {
                composable("login") {
                    LoginScreen(
                        uiState = LoginUiState(isLoading = false, error = null),
                        onLoginClick = { _, _ -> navController.navigate("home") }
                    )
                }
                composable("home") { Text("Home Screen") }
            }
        }

        composeTestRule.onNodeWithTag(TestTags.LOGIN_EMAIL_FIELD)
            .performTextInput("alice@fintech.com")
        composeTestRule.onNodeWithTag(TestTags.LOGIN_PASSWORD_FIELD)
            .performTextInput("pass")
        composeTestRule.onNodeWithTag(TestTags.LOGIN_BUTTON).performClick()

        composeTestRule.onNodeWithText("Home Screen").assertIsDisplayed()
    }
}

Line-by-line walkthrough

  1. 1. object TestTags defines tag strings as constants — using the constant in the composable and the test ensures they always match, even after refactoring.
  2. 2. Modifier.testTag(TestTags.LOGIN_BUTTON) attaches a test-only semantic property to the Button — it does not affect visual appearance or behavior in production.
  3. 3. @get:Rule val composeTestRule = createComposeRule() sets up the Compose testing environment — this is the entry point for all Compose UI tests.
  4. 4. composeTestRule.setContent { LoginScreen(...) } renders the composable in the test environment — you control all inputs (uiState, callbacks) from the test.
  5. 5. Passing onLoginClick = { _, _ -> } as a no-op lambda isolates the UI test from navigation logic — the test only verifies UI behavior, not what happens after login.
  6. 6. assertDoesNotExist() is stronger than assertIsNotDisplayed() — it asserts the node is not in the semantic tree at all, not just invisible. Use it for conditionally-rendered composables.
  7. 7. assertIsNotEnabled() on the login button during loading state verifies that the UI prevents double submissions — a common and critical UI bug.
  8. 8. capturedEmail and capturedPassword variables in the click test capture the lambda's arguments — this pattern tests that the composable passes the correct values without needing a ViewModel.
  9. 9. TestNavHostController is the test-friendly NavController — it works inside createComposeRule without requiring a real Activity or Fragment.
  10. 10. composeTestRule.onNodeWithText('Home Screen').assertIsDisplayed() after the click verifies navigation succeeded — the new screen's content is now visible.

Spot the bug

class PaymentScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `pay button is disabled when processing`() {
        composeTestRule.setContent {
            PaymentScreen(
                uiState = PaymentUiState(isProcessing = true),
                onPayClick = {}
            )
        }
        // Bug 1
        composeTestRule.onNodeWithText("Pay Now").assertIsDisplayed()
    }

    @Test
    fun `cvv field is cleared after payment submitted`() {
        var cvvValue by mutableStateOf("123")

        composeTestRule.setContent {
            CvvField(
                value = cvvValue,
                onValueChange = { cvvValue = it },
                // Bug 2 — no testTag
            )
        }

        composeTestRule.onNodeWithText("123").performTextClearance()
        assertThat(cvvValue).isEmpty()
    }

    @Test
    fun `error message shown for invalid card number`() {
        composeTestRule.setContent {
            PaymentScreen(
                uiState = PaymentUiState(error = "Invalid card number"),
                onPayClick = {}
            )
        }
        // Bug 3
        composeTestRule.onNodeWithTag("error_message").assertExists()
    }

    @Test
    // Bug 4
    fun testPaymentNavigation() {
        composeTestRule.setContent {
            PaymentScreen(
                uiState = PaymentUiState(isProcessing = false),
                onPayClick = {}  // Bug 5
            )
        }
        composeTestRule.onNodeWithTag(TestTags.PAY_BUTTON).performClick()
        composeTestRule.onNodeWithText("Payment Confirmation").assertIsDisplayed()
    }
}
Need a hint?
Look at what is being asserted vs what should be asserted for the disabled state, the missing testTag, the wrong assertion method for error display, the test name, and the no-op onClick lambda.
Show answer
Bug 1: The test name says 'pay button is disabled when processing' but the assertion is assertIsDisplayed() — this only checks the button is visible, NOT that it is disabled. The correct assertion is assertIsNotEnabled(). The current test would pass even if the button is enabled (allowing double-payment), which is the exact PCI compliance requirement being tested. Fix: change to composeTestRule.onNodeWithText('Pay Now').assertIsNotEnabled(). Bug 2: CvvField has no Modifier.testTag — the test finds it using onNodeWithText('123'), which works but is fragile. If the CVV changes value (e.g., user typed a partial number), the finder breaks. Also, finding by current value couples the test to state that changes. Fix: add Modifier.testTag(TestTags.CVV_FIELD) to the CvvField composable and use onNodeWithTag(TestTags.CVV_FIELD).performTextClearance() in the test. Bug 3: assertExists() only checks the node is in the semantic tree — it does not verify it is visible to the user. For an error message, you want assertIsDisplayed() which additionally verifies the node is actually visible on screen (not zero size, not clipped, not scrolled off). A node can exist in the tree but be invisible. Fix: change to assertIsDisplayed(). Bug 4: The test name 'testPaymentNavigation' is too generic and provides no specification context. Rename to 'tapping pay button navigates to payment confirmation screen'. Bug 5: onPayClick = {} is a no-op lambda — clicking the pay button does nothing, so navigation never happens, and the assertion 'Payment Confirmation'.assertIsDisplayed() will always fail. Fix: either wire up a real NavController with TestNavHostController and navigate in the lambda, or change the assertion to verify that onPayClick was called (capture with a var called = false; onPayClick = { called = true } and then assertThat(called).isTrue()).

Explain like I'm 5

Testing your Compose UI is like playing a game blindfolded where you can only feel the buttons (the semantic tree). If a button has a clear label like 'Submit', you can find it, press it, and check if the right thing happened. If a button has no label, you are lost. That is why we add testTag — it is like putting a sticker on the button with its name so the blindfolded tester can always find it.

Fun fact

Jetpack Compose's semantic tree was designed with accessibility as the primary use case — the same tree that TalkBack uses to announce UI elements to visually impaired users is the tree that tests use to find composables. This means writing testable Compose UI and writing accessible Compose UI are the exact same thing. Apps with great Compose test coverage tend to have significantly better TalkBack support as a side effect.

Hands-on challenge

Write a complete Compose UI test class for an OrderListScreen that: (a) shows a loading spinner while loading, (b) shows a list of orders when loaded, (c) shows an error message on failure, (d) navigates to OrderDetailScreen when an order is tapped. Use createComposeRule, testTags for all key elements, and separate test functions for each state. Pass all UI state directly to the composable (no ViewModel in the test) for maximum speed and isolation.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery