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.

Where this fits: Unit Testing (JUnit & MockK) covered testing pure logic and ViewModels in isolation. Compose UI testing is one layer up the testing pyramid — it renders real composables and checks what actually appears on screen.

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.

Note: This is correct, real Compose testing code as it would run inside an actual Android Studio project — but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.
Next up: A correct screen can still be a slow one. Performance & Stability covers recomposition stability — the concept behind why some composables recompose far more often than they need to.