UI testing in Compose
Tuesday, October 17, 2023UI testing with Espresso always felt fragile. Tests break when you move a button from one layout to another. ViewMatcher with resource IDs couples tests to layout files. RecyclerView interactions require extra libraries.
Compose has a different testing model built around semantics.
The semantics tree
Every Composable emits a semantics tree alongside the visual tree. The testing APIs query this tree, not the visual output.
A Text("Submit") automatically has a text semantic. A Button has a role. You can also add custom semantics:
Button(
onClick = { submit() },
modifier = Modifier.semantics { contentDescription = "Submit form" }
) {
Text("Submit")
}
Tests can find this button by content description or by the text it contains.
Basic test setup
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_isDisabled_whenFieldsAreEmpty() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = {})
}
composeTestRule
.onNodeWithText("Login")
.assertIsNotEnabled()
}
}
createComposeRule sets up an isolated Compose environment. You call setContent to render the Composable under test. No Activity needed.
Finding nodes
// by text
composeTestRule.onNodeWithText("Submit")
// by content description
composeTestRule.onNodeWithContentDescription("Close dialog")
// by test tag (most reliable)
composeTestRule.onNodeWithTag("email_input")
// by role
composeTestRule.onNode(hasRole(Role.Button))
// multiple conditions
composeTestRule.onNode(hasText("Save") and isEnabled())
Test tags are the most stable. Add them to Composables you test often:
TextField(
value = email,
onValueChange = onEmailChange,
modifier = Modifier.testTag("email_input")
)
Interactions
@Test
fun login_succeeds_withValidCredentials() {
var loginCalled = false
composeTestRule.setContent {
LoginScreen(onLoginSuccess = { loginCalled = true })
}
composeTestRule
.onNodeWithTag("email_input")
.performTextInput("user@test.com")
composeTestRule
.onNodeWithTag("password_input")
.performTextInput("password123")
composeTestRule
.onNodeWithText("Login")
.performClick()
assert(loginCalled)
}
performTextInput, performClick, performScrollTo. The interactions are intuitive.
Assertions
.assertIsDisplayed()
.assertIsEnabled()
.assertIsNotEnabled()
.assertTextEquals("Expected text")
.assertExists()
.assertDoesNotExist()
Async content
When content loads asynchronously you use waitUntil:
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("result_item")
.fetchSemanticsNodes()
.isNotEmpty()
}
Wait for nodes matching the tag to appear. Cleaner than Thread.sleep.
What is better than Espresso
Tests are faster because there is no full Activity. Tests are less brittle because they query by semantics, not by layout position. Moving a button from the top to the bottom of the screen does not break the test.
Also testing Composables in isolation is easy. You render one Composable, pass mock state, verify the output. No Fragment, no Activity, no navigation needed for most tests.
The semantics model takes some time to understand. But once it clicks, Compose testing is genuinely more pleasant than Espresso.