On this page
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
Finders, actions, assertions
| Category | Examples |
|---|---|
| Finders | onNodeWithText, onNodeWithContentDescription, onNodeWithTag, onAllNodesWithText, onNode(hasClickAction() and hasText("Pay")) |
| Actions | performClick, performTextInput, performTextClearance, performScrollToNode, performTouchInput { swipeUp() } |
| Assertions | assertIsDisplayed, assertExists, assertIsEnabled, assertIsSelected, assertTextEquals, assertCountEquals |
Testing a stateless screen
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
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:
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
@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 orwaitUntil. - 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
compose.onRoot().printToLog("UI_TREE") // merged tree
compose.onRoot(useUnmergedTree = true).printToLog("UI_TREE") // see individual child nodesWhat 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.
Walzi4K wallpapers for lock and home screenGet it ▸
NotiVaultNotification history: recover dismissed notificationsGet it ▸
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.