Compose UI Testing
Every RecipeBox screen built across this track has been trusted to work by inspection — read the code, believe it's right. That doesn't scale once RecipeBox has dozens of screens and other people touching the codebase. Compose UI testing lets you launch a composable in a test, find specific pieces of it, interact with them the way a user would, and assert on what's showing — all without an emulator's full app lifecycle, running fast enough to be part of a normal test suite.
createComposeRule(): Launching a Composable Under Test
A Compose UI test starts with a JUnit @Rule — createComposeRule() — which
gives the test a way to set the content under test and later inspect it. Unlike an Espresso test
that launches a whole Activity, this can render just the one composable being tested, in isolation:
AddRecipeScreenTest.kt — setup
class AddRecipeScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
}
Finding Nodes: onNodeWithText and onNodeWithTag
A test locates a piece of UI as a node in the semantics tree, using a
matcher. onNodeWithText("Save") finds a node by its visible text —
perfect for a button whose label won't change. But plenty of UI has no unique text at all: a
TextField is usually empty until the user types in it, and an icon-only button has no
text at all. For those, Modifier.testTag("...") attaches an invisible, test-only
identifier directly to a composable, and onNodeWithTag(...) finds it by that identifier
instead — reliable no matter what text is (or isn't) currently displayed:
AddRecipeScreen.kt — tagging the fields under test
@Composable
fun AddRecipeScreen(
viewModel: AddRecipeViewModel = viewModel(),
onSaved: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = viewModel.title,
onValueChange = { viewModel.onTitleChange(it) },
label = { Text("Recipe title") },
modifier = Modifier.testTag("recipeTitleField")
)
Button(
onClick = { viewModel.save(onSaved) },
modifier = Modifier.testTag("saveRecipeButton")
) {
Text("Save")
}
if (viewModel.savedSuccessfully) {
Text(text = "Recipe saved!", modifier = Modifier.testTag("saveSuccessMessage"))
}
}
}
Actions and Assertions: performClick, performTextInput, assertIsDisplayed
Once a node is found, .performClick() and .performTextInput("...") drive
it the way a real user would — tapping a button, typing into a field. Assertions like
.assertIsDisplayed() and .assertTextEquals("...") then check what the UI
looks like as a result:
AddRecipeScreenTest.kt — the worked test
class AddRecipeScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `typing a title and tapping save shows the success message`() {
composeTestRule.setContent {
AddRecipeScreen(onSaved = { })
}
composeTestRule.onNodeWithTag("recipeTitleField")
.performTextInput("Grandma's Lasagna")
composeTestRule.onNodeWithTag("saveRecipeButton")
.performClick()
composeTestRule.onNodeWithTag("saveSuccessMessage")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Recipe saved!")
.assertTextEquals("Recipe saved!")
}
}
Read it top to bottom as exactly what a person testing this screen by hand would do: set the screen
on the page, type a title into the title field, tap Save, then check the success message actually
appeared. composeTestRule.setContent { } is the Compose-test equivalent of launching an
Activity — it renders the given composable into the test's isolated composition, ready for the
onNodeWith... calls that follow.