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
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
- createComposeRule() — the test entry point — val composeTestRule = createComposeRule() sets up a Compose testing environment. Use composeTestRule.setContent { MyComposable() } to render any composable. For Activity-hosted Compose: createAndroidComposeRule(). The rule handles the test Compose lifecycle — set up, rendering, and teardown.
- The semantic tree — how tests find UI — Compose builds a parallel semantic tree alongside the visual UI tree. Every composable with text, role, or contentDescription adds nodes to the semantic tree. Tests navigate this tree using matchers — not pixel coordinates. The semantic tree is what accessibility services (TalkBack) also use — good accessibility = testable UI.
- Finding nodes — onNode and semantic matchers — composeTestRule.onNodeWithText('Submit') finds a node containing 'Submit' text. onNodeWithTag('login_button') finds by testTag. onNodeWithContentDescription('Close') finds by accessibility description. onNode(hasText('Alice') and hasRole(Role.Button)) combines matchers. Prefer testTag for non-user-visible elements.
- Semantic Modifier.testTag() — Modifier.testTag('order_list') adds a test-only semantic property. Use this for elements without visible text (icons, loading spinners, custom layouts). Best practice: define testTag strings as constants in a shared TestTags object to avoid typos between production and test code.
- Performing actions — click, input, scroll — onNodeWithText('Submit').performClick() simulates a tap. onNodeWithTag('search_field').performTextInput('android') types text. onNodeWithTag('order_list').performScrollToIndex(20) scrolls a LazyList. performTextClearance() clears a text field. Actions trigger recomposition — assert after the action settles.
- Asserting state — assertIsDisplayed and more — onNodeWithText('Success').assertIsDisplayed() checks visible. assertDoesNotExist() checks the node is not in the tree (gone, not just hidden). assertIsEnabled() / assertIsNotEnabled() for button state. assertTextEquals('100') for exact text. assertContentDescriptionContains('icon') for partial match.
- Testing navigation with NavController — Use createComposeRule with a NavController. Provide a TestNavHostController in the test's composable content. After triggering navigation (button click), assert navController.currentBackStackEntry?.destination?.route equals the expected destination. Or use ComposeTestRule's onNode assertions if navigation renders a new screen composable.
- Testing state changes — Compose UI tests are inherently state-driven. Pattern: (1) setContent with initial state, (2) assert initial UI, (3) perform action, (4) assert updated UI. For ViewModel-driven state: use real ViewModel with fake use cases (fast), or directly manipulate a MutableState in the test composable (for pure UI logic tests).
- waitUntil for async state — composeTestRule.waitUntil(timeoutMillis = 2000) { onNodeWithText('Loaded').fetchSemanticsNode() != null } waits for async state changes (network responses, animations completing). Use sparingly — prefer synchronous test doubles over waiting. waitForIdle() waits for recomposition and animations to settle.
- Testing loading and error states — Set content with isLoading = true state — assert CircularProgressIndicator is displayed. Set content with error = 'Network error' state — assert error message text is shown. Test all three states (loading, success, error) in separate test functions. These are pure UI tests — no network needed.
- SemanticsNodeInteractionCollection — onAllNodes — onAllNodesWithText('Delete') returns a list of all matching nodes. onAllNodesWithTag('order_item')[2].performClick() interacts with the third item. Use onAllNodes(hasRole(Role.Button)).fetchSemanticsNodeList() to count buttons. Essential for list-based UI where multiple nodes match the same criteria.
- Compose test vs Espresso — when to use each — Use Compose Testing API for all Jetpack Compose screens — it understands the semantic tree natively. Use Espresso only for View-based screens or when testing hybrid Compose-in-View UI. Never mix Compose Testing and Espresso matchers for the same element — they operate on different trees.
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. object TestTags defines tag strings as constants — using the constant in the composable and the test ensures they always match, even after refactoring.
- 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. @get:Rule val composeTestRule = createComposeRule() sets up the Compose testing environment — this is the entry point for all Compose UI tests.
- 4. composeTestRule.setContent { LoginScreen(...) } renders the composable in the test environment — you control all inputs (uiState, callbacks) from the test.
- 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. 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. assertIsNotEnabled() on the login button during loading state verifies that the UI prevents double submissions — a common and critical UI bug.
- 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. TestNavHostController is the test-friendly NavController — it works inside createComposeRule without requiring a real Activity or Fragment.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Compose Testing Overview (Android Developers)
- Compose Testing Cheat Sheet (Android Developers)
- Semantics in Compose (Android Developers)
- Testing Navigation in Compose (Android Developers)