Your app works when you manually tap through it. But does it work after your next code change? And the one after that?

Manual testing doesn’t scale. You can’t tap through 50 screens after every change. That is what automated tests are for — and Compose makes testing surprisingly easy.

Why Test Compose UI?

Three reasons:

  1. Catch bugs before users do — tests run in seconds, not minutes of manual tapping
  2. Refactor with confidence — change code, run tests, know nothing broke
  3. Document behavior — tests show WHAT the UI should do

Setup

Add the testing dependencies to app/build.gradle.kts:

dependencies {
    // Already included if you used Android Studio template
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}

Your First Compose Test

class GreetingTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun greeting_shows_name() {
        // Set up the UI
        composeTestRule.setContent {
            Greeting(name = "Alex")
        }

        // Find the text and check it exists
        composeTestRule
            .onNodeWithText("Hello, Alex!")
            .assertIsDisplayed()
    }
}

Three steps:

  1. Set contentsetContent { } renders your Composable
  2. FindonNodeWithText() finds a UI element
  3. AssertassertIsDisplayed() checks it exists

That is the pattern for every Compose test.

Finding Elements

By Text

composeTestRule.onNodeWithText("Submit")
composeTestRule.onNodeWithText("Submit", ignoreCase = true)
composeTestRule.onNodeWithText("Submit", substring = true)  // Matches "Submit Form"

By Test Tag

For elements without visible text, use test tags:

// In your Composable
TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.testTag("email_field")
)

// In your test
composeTestRule.onNodeWithTag("email_field")

By Content Description

For icons and images:

// In your Composable
Icon(
    Icons.Default.Search,
    contentDescription = "Search"
)

// In your test
composeTestRule.onNodeWithContentDescription("Search")

Finding Multiple Elements

// Find ALL elements with a specific tag
composeTestRule.onAllNodesWithTag("list_item")
    .assertCountEquals(5)

// Find the first one
composeTestRule.onAllNodesWithText("Item")[0]
    .assertIsDisplayed()

Actions — Interacting with the UI

Click

composeTestRule
    .onNodeWithText("Submit")
    .performClick()

Type Text

composeTestRule
    .onNodeWithTag("email_field")
    .performTextInput("alex@example.com")

// Clear and type new text
composeTestRule
    .onNodeWithTag("email_field")
    .performTextClearance()
    .performTextInput("new@example.com")

Scroll

// Scroll a standard scrollable Column until an item is visible
composeTestRule
    .onNodeWithText("Item 50")
    .performScrollTo()
    .assertIsDisplayed()

// For LazyColumn: scroll the list to the target index instead
composeTestRule
    .onNodeWithTag("my_lazy_list")
    .performScrollToIndex(49)

performScrollTo() works for Column with Modifier.verticalScroll(). For LazyColumn, use performScrollToIndex or performScrollToNode on the list node — see the dedicated sections below.

Swipe

composeTestRule
    .onNodeWithTag("swipeable_card")
    .performTouchInput {
        swipeLeft()
    }

Assertions — Checking the UI

Exists / Displayed

// Element exists in the tree (may not be visible on screen)
composeTestRule.onNodeWithText("Hidden").assertExists()

// Element is visible on screen
composeTestRule.onNodeWithText("Visible").assertIsDisplayed()

// Element does NOT exist
composeTestRule.onNodeWithText("Deleted").assertDoesNotExist()

Enabled / Disabled

composeTestRule.onNodeWithText("Submit").assertIsEnabled()
composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()

Text Content

composeTestRule
    .onNodeWithTag("counter")
    .assertTextEquals("Count: 5")

composeTestRule
    .onNodeWithTag("counter")
    .assertTextContains("5")

Selected / Toggled

composeTestRule.onNodeWithText("Dark Mode").assertIsOn()
composeTestRule.onNodeWithText("Dark Mode").assertIsOff()

composeTestRule.onNodeWithTag("tab_home").assertIsSelected()

Practical Example: Testing a Login Screen

Here is a complete test for a login form:

class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun login_button_disabled_when_fields_empty() {
        composeTestRule.setContent {
            LoginScreen()
        }

        // Login button should be disabled initially
        composeTestRule
            .onNodeWithText("Sign In")
            .assertIsNotEnabled()
    }

    @Test
    fun login_button_enabled_when_fields_filled() {
        composeTestRule.setContent {
            LoginScreen()
        }

        // Type email
        composeTestRule
            .onNodeWithTag("email_field")
            .performTextInput("alex@example.com")

        // Type password
        composeTestRule
            .onNodeWithTag("password_field")
            .performTextInput("password123")

        // Button should now be enabled
        composeTestRule
            .onNodeWithText("Sign In")
            .assertIsEnabled()
    }

    @Test
    fun password_toggle_shows_and_hides_password() {
        composeTestRule.setContent {
            LoginScreen()
        }

        // Type password
        composeTestRule
            .onNodeWithTag("password_field")
            .performTextInput("secret123")

        // Click show/hide toggle
        composeTestRule
            .onNodeWithText("Show")
            .performClick()

        // Toggle text should change to "Hide"
        composeTestRule
            .onNodeWithText("Hide")
            .assertIsDisplayed()
    }

    @Test
    fun clicking_sign_in_shows_loading() {
        composeTestRule.setContent {
            LoginScreen()
        }

        // Fill in fields
        composeTestRule.onNodeWithTag("email_field").performTextInput("alex@example.com")
        composeTestRule.onNodeWithTag("password_field").performTextInput("password123")

        // Click sign in
        composeTestRule.onNodeWithText("Sign In").performClick()

        // Loading indicator should appear
        composeTestRule
            .onNodeWithTag("loading_indicator")
            .assertIsDisplayed()
    }

    @Test
    fun forgot_password_link_exists() {
        composeTestRule.setContent {
            LoginScreen()
        }

        composeTestRule
            .onNodeWithText("Forgot password?")
            .assertIsDisplayed()
            .assertHasClickAction()
    }
}

Each test is:

  • Short — tests ONE thing
  • Named clearly — the name says what it tests
  • Independent — each test runs on its own

Testing with ViewModel

When testing screens that use ViewModels, you have two approaches:

Option 1: Test the Screen Directly

@Test
fun counter_increments_on_button_click() {
    composeTestRule.setContent {
        CounterScreen()  // ViewModel created internally
    }

    // Initial state
    composeTestRule.onNodeWithText("0").assertIsDisplayed()

    // Click increment
    composeTestRule.onNodeWithText("+").performClick()

    // Should show 1
    composeTestRule.onNodeWithText("1").assertIsDisplayed()
}

Option 2: Inject a Fake ViewModel

@Test
fun shows_loading_state() {
    val fakeViewModel = FakeUserViewModel(
        initialState = UserListState(isLoading = true)
    )

    composeTestRule.setContent {
        UserListScreen(viewModel = fakeViewModel)
    }

    composeTestRule
        .onNodeWithTag("loading_indicator")
        .assertIsDisplayed()
}

@Test
fun shows_users_when_loaded() {
    val fakeViewModel = FakeUserViewModel(
        initialState = UserListState(
            users = listOf(
                User("1", "Alex", "alex@example.com"),
                User("2", "Sam", "sam@example.com"),
            ),
            isLoading = false
        )
    )

    composeTestRule.setContent {
        UserListScreen(viewModel = fakeViewModel)
    }

    composeTestRule.onNodeWithText("Alex").assertIsDisplayed()
    composeTestRule.onNodeWithText("Sam").assertIsDisplayed()
}

Option 2 gives you full control over the state.

Testing LazyColumn

LazyColumn only composes visible items. Items that are off-screen do not exist in the semantics tree yet. This means you cannot call onNodeWithText("User 50").performScrollTo() to reach a far-off item — the finder will fail because “User 50” has not been composed yet.

Use performScrollToNode or performScrollToIndex on the list itself instead. Those methods are designed to work with lazy layouts:

@Test
fun lazy_list_shows_last_item_after_scroll() {
    composeTestRule.setContent {
        LazyColumn(modifier = Modifier.testTag("user_list")) {
            items(50) { index ->
                Text(text = "User ${index + 1}")
            }
        }
    }

    // Correct: scroll the list itself using performScrollToIndex
    composeTestRule
        .onNodeWithTag("user_list")
        .performScrollToIndex(49)

    composeTestRule
        .onNodeWithText("User 50")
        .assertIsDisplayed()
}

performScrollTo() works correctly for standard scrollable containers (a Column with Modifier.verticalScroll()). For LazyColumn and LazyRow, always use performScrollToNode or performScrollToIndex.

performScrollTo — Scroll to a Node by Matcher

Use performScrollTo() when you know the text or tag of the item you want to reach. Compose will scroll the parent list until that node is visible.

@Test
fun item_is_visible_after_scroll() {
    composeTestRule.setContent {
        UserList(users = (1..50).map { User("$it", "User $it", "") })
    }

    // Scroll down until "User 50" appears, then assert it is displayed
    composeTestRule
        .onNodeWithText("User 50")
        .performScrollTo()
        .assertIsDisplayed()
}

This works with LazyColumn, LazyRow, and Column wrapped in a verticalScroll modifier. The test fails if the node does not exist at all — so it also acts as an existence check.

performScrollToNode — Scroll Inside a LazyColumn

performScrollToNode is called on the list itself, not on the target item. You pass a matcher that describes the node you want to scroll to.

Use this when you have a reference to the list container and want to scroll it to a specific child.

@Test
fun lazy_list_scrolls_to_last_item() {
    composeTestRule.setContent {
        LazyColumn(modifier = Modifier.testTag("user_list")) {
            items((1..50).map { "User $it" }) { name ->
                Text(text = name, modifier = Modifier.testTag(name))
            }
        }
    }

    // Scroll the list container until the target node is reachable
    composeTestRule
        .onNodeWithTag("user_list")
        .performScrollToNode(hasText("User 50"))

    // Now the node is visible — assert it
    composeTestRule
        .onNodeWithText("User 50")
        .assertIsDisplayed()
}

The key difference from performScrollTo():

  • performScrollTo() — called on the target node, scrolls its parent
  • performScrollToNode() — called on the list node, scrolls to find a child

performScrollToIndex — Scroll to a Specific Index

Use performScrollToIndex when you want to jump to a position by index instead of searching by text or tag. This is useful for very long lists where the item text is dynamic.

@Test
fun lazy_list_scrolls_to_index_49() {
    composeTestRule.setContent {
        LazyColumn(modifier = Modifier.testTag("user_list")) {
            items(50) { index ->
                Text(
                    text = "User ${index + 1}",
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }

    // Scroll the LazyColumn to index 49 (the 50th item, zero-based)
    composeTestRule
        .onNodeWithTag("user_list")
        .performScrollToIndex(49)

    composeTestRule
        .onNodeWithText("User 50")
        .assertIsDisplayed()
}

performScrollToIndex is the most direct option when you already know the position. It does not require the item to have a tag or fixed text.

Testing Navigation

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Before
    fun setup() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavigation(navController = navController)
        }
    }

    @Test
    fun start_destination_is_home() {
        composeTestRule
            .onNodeWithText("Home")
            .assertIsDisplayed()
    }

    @Test
    fun clicking_item_navigates_to_detail() {
        composeTestRule
            .onNodeWithText("Item A")
            .performClick()

        composeTestRule
            .onNodeWithText("Detail: Item A")
            .assertIsDisplayed()
    }

    @Test
    fun back_button_returns_to_home() {
        // Navigate to detail
        composeTestRule.onNodeWithText("Item A").performClick()

        // Press back
        composeTestRule.onNodeWithText("← Back").performClick()

        // Should be back on home
        composeTestRule.onNodeWithText("Home").assertIsDisplayed()
    }
}

Best Practices

1. Test Behavior, Not Implementation

// BAD — tests how it's built
composeTestRule.onNodeWithTag("header_column").assertExists()

// GOOD — tests what the user sees
composeTestRule.onNodeWithText("Welcome Back").assertIsDisplayed()

2. Use Meaningful Test Names

// BAD
@Test fun test1() { ... }

// GOOD
@Test fun login_button_disabled_when_email_empty() { ... }

3. One Assertion Per Test (Usually)

// BAD — too much in one test
@Test fun everything_works() {
    // test login, navigation, list, detail, logout...
}

// GOOD — focused tests
@Test fun login_button_disabled_when_empty() { ... }
@Test fun login_button_enabled_when_filled() { ... }
@Test fun login_shows_loading_on_submit() { ... }

4. Use testTag for Non-Text Elements

// Elements without text need tags
CircularProgressIndicator(
    modifier = Modifier.testTag("loading_spinner")
)

// Now you can find it
composeTestRule.onNodeWithTag("loading_spinner").assertIsDisplayed()

5. Wait for Async Operations

Compose testing automatically waits for idle state. But for custom async work:

@Test
fun data_loads_after_delay() {
    composeTestRule.setContent {
        DataScreen()
    }

    // Wait for the data to load
    composeTestRule.waitUntil(timeoutMillis = 5000) {
        composeTestRule
            .onAllNodesWithTag("data_item")
            .fetchSemanticsNodes()
            .isNotEmpty()
    }

    composeTestRule.onNodeWithText("Loaded Data").assertIsDisplayed()
}

Common Mistakes

Mistake 1: Forgetting to Add testTag

// BAD — can't find the element in tests
Button(onClick = {}) { Text("") }  // No text, no tag

// GOOD — testTag makes it findable
Button(
    onClick = {},
    modifier = Modifier.testTag("submit_button")
) { Icon(Icons.Default.Send, null) }

Mistake 2: Testing in the Wrong Module

androidTest/ → Instrumentation tests (Compose UI tests go HERE)
test/        → Unit tests (JVM only, no Compose)

Compose UI tests need an Android device/emulator. Put them in androidTest/.

Mistake 3: Not Handling Animations

// If animations make tests flaky, disable them
composeTestRule.mainClock.autoAdvance = false
composeTestRule.mainClock.advanceTimeBy(1000) // Advance by 1 second

Quick Reference

Finders

FinderUse For
onNodeWithText("text")Visible text
onNodeWithTag("tag")Test tags
onNodeWithContentDescription("desc")Icons, images
onAllNodesWithText("text")Multiple matching elements

Actions

ActionWhat It Does
performClick()Tap the element
performTextInput("text")Type text
performTextClearance()Clear text field
performScrollTo()Scroll parent until this node is visible
performScrollToNode(matcher)Scroll the list to find a child by matcher
performScrollToIndex(index)Scroll the list to a specific zero-based index
performTouchInput { swipeLeft() }Swipe gesture

Assertions

AssertionWhat It Checks
assertIsDisplayed()Visible on screen
assertExists()Exists in tree (may not be visible)
assertDoesNotExist()Not in the tree
assertIsEnabled()Clickable
assertIsNotEnabled()Grayed out
assertTextEquals("text")Exact text match
assertTextContains("text")Contains text
assertIsOn() / assertIsOff()Toggle state
assertHasClickAction()Has onClick handler

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Permissions and Camera — requesting runtime permissions and integrating the camera in Compose.

See you there.