🧪 Testing, Debugging, and Performance

UI Testing in Jetpack Compose

Compose UI tests interact with your UI the way users and accessibility services do, through the semantics tree. Done well, they're fast, stable and readable. This guide covers the APIs, patterns for lists and navigation, and how to avoid flaky tests.

✍️ Yunok Tech 📅 Updated ⏱ 3 min read 🎯 Intermediate
On this page
  1. The semantics tree
  2. Finders, actions, assertions
  3. Testing a stateless screen
  4. Lists
  5. Waiting for asynchronous work
  6. Testing with an Activity, DI and navigation
  7. Running on the JVM with Robolectric
  8. Avoiding flaky tests
  9. Debugging failing tests
  10. What to UI test
  11. Frequently asked questions
  12. Conclusion

A good UI test reads like a user story: type an email, tap Sign in, see the inbox. Compose's testing APIs make that natural. You find nodes by text or description, perform actions, and assert on what's displayed, with automatic synchronisation instead of sleeps.

The semantics tree

Composition and semantics tree used by tests The UI tree of composables produces a semantics tree with nodes carrying text, content descriptions, roles, states such as enabled or selected, and actions such as click. Both TalkBack and Compose tests read this tree. ComposablesColumn, Button, Text… Semantics treetext · description · roleenabled · selected · onClick TalkBack Compose tests
Accessible UIs are testable UIs. Improving semantics helps both.

Finders, actions, assertions

CategoryExamples
FindersonNodeWithText, onNodeWithContentDescription, onNodeWithTag, onAllNodesWithText, onNode(hasClickAction() and hasText("Pay"))
ActionsperformClick, performTextInput, performTextClearance, performScrollToNode, performTouchInput { swipeUp() }
AssertionsassertIsDisplayed, assertExists, assertIsEnabled, assertIsSelected, assertTextEquals, assertCountEquals

Testing a stateless screen

CartScreenTest.kt (androidTest)Kotlin
class CartScreenTest {
    @get:Rule val compose = createComposeRule()

    private val items = listOf(
        CartItemUi(id = "1", name = "Kotlin Mug", qty = 1, priceText = "₹499"),
        CartItemUi(id = "2", name = "Compose Tee", qty = 2, priceText = "₹1,598"),
    )

    @Test
    fun increasingQuantity_emitsEvent_andCheckoutEnabled() {
        val events = mutableListOf<Pair<String, Int>>()
        compose.setContent {
            AppTheme {
                CartScreen(
                    state = CartUiState(items = items, totalText = "₹2,097", canCheckout = true),
                    onQuantityChange = { id, qty -> events += id to qty },
                    onCheckout = {},
                )
            }
        }

        compose.onNodeWithText("Kotlin Mug").assertIsDisplayed()
        compose.onAllNodesWithContentDescription("Increase quantity")[0].performClick()
        assertEquals(listOf("1" to 2), events)
        compose.onNodeWithText("Checkout").assertIsEnabled()
    }
}

Lists

Scrolling a LazyColumnKotlin
compose.onNodeWithTag("orders_list")
    .performScrollToNode(hasText("Order #1042"))
compose.onNodeWithText("Order #1042").assertIsDisplayed().performClick()

Lazy lists only compose visible items, so scroll to a node before asserting on it.

Waiting for asynchronous work

The test rule automatically waits for recomposition and Compose animations to become idle. For work outside Compose, such as coroutines with real dispatchers, wait explicitly on a condition:

waitUntilKotlin
compose.waitUntil(timeoutMillis = 5_000) {
    compose.onAllNodesWithText("Welcome back").fetchSemanticsNodes().isNotEmpty()
}

Better still, inject fakes and test dispatchers so data is available immediately and deterministically.

Testing with an Activity, DI and navigation

End-to-end-ish flow with Hilt fakesKotlin
@HiltAndroidTest
class SignInFlowTest {
    @get:Rule(order = 0) val hilt = HiltAndroidRule(this)
    @get:Rule(order = 1) val compose = createAndroidComposeRule<MainActivity>()

    @Test fun signInNavigatesToInbox() {
        compose.onNodeWithText("Email").performTextInput("dev@example.com")
        compose.onNodeWithText("Password").performTextInput("Secret123")
        compose.onNodeWithText("Sign in").performClick()
        compose.onNodeWithText("Inbox").assertIsDisplayed()
    }
}

Replace network-backed repositories with fakes via Hilt test modules so this stays fast and reliable.

Running on the JVM with Robolectric

Compose UI tests can also run as local tests with Robolectric (@RunWith(RobolectricTestRunner::class)), which is much faster than an emulator. It's good for screen-level tests in CI, while a smaller set of instrumented tests runs on real devices.

Avoiding flaky tests

  • Never use Thread.sleep. Rely on synchronisation or waitUntil.
  • Use fakes for network and time. Don't depend on live servers.
  • Disable system animations on CI devices for instrumented tests.
  • Prefer user-visible text and descriptions. Use test tags only when necessary.
  • Keep each test focused on one behaviour.

Debugging failing tests

Print the semantics treeKotlin
compose.onRoot().printToLog("UI_TREE")                       // merged tree
compose.onRoot(useUnmergedTree = true).printToLog("UI_TREE") // see individual child nodes

What to UI test

Critical flows

Sign-in, checkout and onboarding: a few end-to-end-style tests.

Stateful components

Forms with validation, expandable items, tabs.

Accessibility

Assert content descriptions exist and important actions are reachable.

Frequently asked questions

Espresso or Compose testing APIs?

Use Compose testing APIs for Compose UI. For hybrid screens, both can interoperate in the same test.

Why can't my test find a node inside a button?

Semantics are merged for some components. Use useUnmergedTree = true, or find the merged parent.

How many UI tests should I write?

Far fewer than unit tests: critical flows and complex components. Most logic should be covered by fast unit tests.

Conclusion

Compose UI tests interact through the semantics tree, synchronise automatically, and stay stable when screens are stateless and fed by fakes. Test critical flows and complex components, run many on the JVM with Robolectric, and avoid sleeps.

Next step: choose a mocking library in MockK vs Mockito for Kotlin Testing.

Official references

📱 Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps →

Ready to build real Android applications?

Jetpack Compose clicks once you build real screens with real state. Practise modern Android UI development on projects that go beyond toy examples.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp