UI tests verify that the composable tree behaves correctly from a user's perspective — text appears, buttons are clickable, state changes are visible. They run on a device or emulator, which makes them slower than unit tests but more realistic.
Compose's testing APIs are built around the semantic tree — the accessibility representation of the UI. Every Text, Button, and TextField is a semantic node. Tests find nodes, assert properties, and perform actions on them.
Basic Setup
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_isDisabledWhenFieldsAreEmpty() {
composeTestRule.setContent {
LoginScreen(
state = LoginUiState(),
onEmailChange = {},
onPasswordChange = {},
onSubmit = {}
)
}
composeTestRule.onNodeWithText("Login").assertIsNotEnabled()
}
}
createComposeRule() handles setup and teardown. setContent renders the composable. From there, every interaction goes through composeTestRule.
Finders
// Find by text
onNodeWithText("Continue")
// Find by content description (for icons)
onNodeWithContentDescription("Close")
// Find by test tag (the most reliable approach)
onNodeWithTag("email_field")
// Find by role
onNode(hasRole(Role.Button))
// Combine matchers
onNode(hasText("Retry") and hasClickAction())
Test tags are the most stable finder. Text and content descriptions can change during copy editing; test tags are for tests only.
Add tags in the composable:
TextField(
value = email,
onValueChange = onEmailChange,
modifier = Modifier.testTag("email_field")
)
Assertions
onNodeWithText("Welcome, Akshay").assertIsDisplayed()
onNodeWithTag("submit_button").assertIsEnabled()
onNodeWithTag("loading_indicator").assertDoesNotExist()
onNodeWithTag("error_message").assertTextContains("Invalid email")
Use assertExists() when the node may be in the tree but not visible. Use assertIsDisplayed() when it must be visible to the user.
Actions
// Click
onNodeWithText("Login").performClick()
// Type text
onNodeWithTag("email_field").performTextInput("akshay@example.com")
// Clear and retype
onNodeWithTag("password_field").performTextClearance()
onNodeWithTag("password_field").performTextInput("newpassword")
// Scroll
onNodeWithTag("lesson_list").performScrollToIndex(10)
// Swipe
onNodeWithTag("card").performTouchInput { swipeLeft() }
Testing State Changes
@Test
fun submitButton_becomesEnabled_whenFieldsAreFilled() {
var email by mutableStateOf("")
var password by mutableStateOf("")
composeTestRule.setContent {
LoginScreen(
state = LoginUiState(email = email, password = password),
onEmailChange = { email = it },
onPasswordChange = { password = it },
onSubmit = {}
)
}
onNodeWithTag("submit_button").assertIsNotEnabled()
onNodeWithTag("email_field").performTextInput("akshay@example.com")
onNodeWithTag("password_field").performTextInput("password123")
onNodeWithTag("submit_button").assertIsEnabled()
}
Holding state locally in the test lets you drive the composable and assert the results without a ViewModel.
Testing with a ViewModel
For integration-style UI tests, provide a real ViewModel with a fake repository:
@Test
fun errorMessage_isShown_onNetworkFailure() {
val viewModel = LoginViewModel(FakeAuthRepository(shouldFail = true))
composeTestRule.setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
LoginScreen(
state = state,
onEmailChange = viewModel::onEmailChange,
onPasswordChange = viewModel::onPasswordChange,
onSubmit = viewModel::submit
)
}
onNodeWithTag("email_field").performTextInput("a@b.com")
onNodeWithTag("password_field").performTextInput("pass1234")
onNodeWithTag("submit_button").performClick()
composeTestRule.waitUntil {
onAllNodesWithTag("error_banner").fetchSemanticsNodes().isNotEmpty()
}
onNodeWithTag("error_banner").assertIsDisplayed()
}
waitUntil polls until the condition is true or a timeout (default 1 second) is reached. Use it for async state updates.
Screenshot Testing
Screenshot tests capture a rendered composable as a bitmap and compare it to a golden reference. They catch visual regressions that assertions miss.
With Paparazzi (no device needed):
@RunWith(AndroidJUnit4::class)
class LoginScreenScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun loginScreen_defaultState() {
paparazzi.snapshot {
LoginScreen(
state = LoginUiState(),
onEmailChange = {},
onPasswordChange = {},
onSubmit = {}
)
}
}
}
Run ./gradlew recordPaparazziDebug to create golden images. Run ./gradlew verifyPaparazziDebug in CI to detect regressions.
Semantic Merging
Compose merges semantics from children into parent nodes for accessibility. This can confuse tests.
If a click target has a Text child, the text is merged into the parent. Access it directly:
// Works — text is merged up
onNodeWithText("Submit").performClick()
// More explicit — uses the button's role
onNode(hasRole(Role.Button) and hasText("Submit")).performClick()
Use printToLog("TAG") to inspect the semantic tree when a finder is not matching:
composeTestRule.onRoot().printToLog("SemanticTree")
Practice
Build a composable form with name, email, and a submit button. Add test tags to each element. Write tests for: submit button disabled until both fields are filled, an error message appears when name is blank and submit is tapped, and the success state is shown after valid submission.
Summary
Compose UI tests use the semantic tree to find nodes, assert properties, and perform actions. Prefer test tags over text matchers for stability. Drive state locally or through a ViewModel with a fake repository. Use waitUntil for async updates and screenshot tests for visual regression coverage.