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:
- Catch bugs before users do — tests run in seconds, not minutes of manual tapping
- Refactor with confidence — change code, run tests, know nothing broke
- 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:
- Set content —
setContent { }renders your Composable - Find —
onNodeWithText()finds a UI element - Assert —
assertIsDisplayed()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 parentperformScrollToNode()— 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
| Finder | Use For |
|---|---|
onNodeWithText("text") | Visible text |
onNodeWithTag("tag") | Test tags |
onNodeWithContentDescription("desc") | Icons, images |
onAllNodesWithText("text") | Multiple matching elements |
Actions
| Action | What 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
| Assertion | What 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:
Related Tutorials
- Tutorial #4: Components — the login form we tested above
- Tutorial #9: ViewModel — testing ViewModels with fake data
- Tutorial #17: Performance — tests catch performance regressions
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
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.