Testing Compose UI: A Comprehensive Guide to Reliable Tests
TestingUI TestingQuality AssuranceScreenshot Testing
The Compose Testing Philosophy
Compose testing differs fundamentally from View testing. Instead of finding views by ID and simulating touches, you interact with the semantic tree that Compose uses for accessibility. This makes tests more resilient and closer to how users actually interact with your app.
Setting Up Compose Tests
Add the testing dependencies and create your first test:
// build.gradle.kts
dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
// LoginScreenTest.kt
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_isDisabled_whenEmailIsEmpty() {
composeTestRule.setContent {
LoginScreen()
}
composeTestRule
.onNodeWithText("Log In")
.assertIsNotEnabled()
}
}Finding and Interacting with Nodes
Compose provides powerful matchers to find nodes in the semantic tree:
// Find by text
composeTestRule.onNodeWithText("Submit")
// Find by content description (accessibility)
composeTestRule.onNodeWithContentDescription("Close button")
// Find by test tag
composeTestRule.onNodeWithTag("email_input")
// Combine matchers
composeTestRule.onNode(
hasText("Submit") and hasClickAction() and isEnabled()
)
// Perform actions
composeTestRule.onNodeWithTag("email_input")
.performTextInput("user@example.com")
composeTestRule.onNodeWithText("Submit")
.performClick()Testing State and Recomposition
For testing state changes and async operations, use waitUntil and other synchronization utilities:
@Test
fun loadingIndicator_disappears_afterDataLoads() {
composeTestRule.setContent {
MyScreenWithLoading()
}
// Initially shows loading
composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
// Wait for loading to complete
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("loading")
.fetchSemanticsNodes().isEmpty()
}
// Content is now visible
composeTestRule.onNodeWithTag("content").assertIsDisplayed()
}Screenshot Testing with Paparazzi
Screenshot tests catch visual regressions that functional tests miss. Paparazzi runs on JVM without an emulator:
class ButtonScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
theme = "android:Theme.Material3.Light"
)
@Test
fun primaryButton_default() {
paparazzi.snapshot {
PrimaryButton(text = "Click Me", onClick = {})
}
}
@Test
fun primaryButton_disabled() {
paparazzi.snapshot {
PrimaryButton(text = "Click Me", enabled = false, onClick = {})
}
}
}Note: Run screenshot tests on CI with consistent environment settings. Small differences in font rendering or density can cause false failures across different machines.