Skip to content
All posts
July 15, 20264 min read

Writing Tests for Jetpack Compose: A Practical Guide

How I test Compose UIs without the suite turning brittle — what to assert, how the testing APIs work, the synchronization model that makes flakiness rare, and where UI tests stop earning their keep.

Jetpack ComposeTestingAndroidUI TestingKotlin
Share:

UI tests have a bad reputation: slow, flaky, and forever breaking when you move a button. Compose's testing tools earn back a lot of that trust, but only if you test the right things. The trick is to assert behavior a user cares about, not pixel positions or internal structure. Done that way, Compose tests catch real regressions and survive refactors.

The Three-Part API

Almost every Compose test is a variation on the same shape: find a node, do something to it, assert a result. You find nodes by what the user perceives — text, content description, a test tag — not by type or position.

kotlin
@get:Rule val rule = createComposeRule()

@Test
fun submitButton_disabledUntilFormValid() {
    rule.setContent { SignUpScreen() }

    rule.onNodeWithText("Sign up").assertIsNotEnabled()
    rule.onNodeWithContentDescription("Email").performTextInput("a@b.com")
    rule.onNodeWithContentDescription("Password").performTextInput("secret12")
    rule.onNodeWithText("Sign up").assertIsEnabled()
}

That test reads like a user story, and it will keep passing even if you completely rewrite the layout, because it depends on behavior, not structure.

Synchronization Is Handled For You

The usual source of UI-test flakiness is timing — asserting before the screen has settled. Compose's test rule automatically waits for recomposition and idle state before each assertion, so you rarely write manual sleeps or waits. When you collect a flow, animate, or load async data, the rule synchronizes with Compose's own work clock. This is the single biggest reason Compose tests are less flaky than the old Espresso-on-Views world: the framework knows when it's busy and the test waits for it.

When you do drive async state, advance it deterministically rather than sleeping. Pair the test with a test dispatcher so coroutines run on a clock you control, and the result is a test that's fast and never racy.

Use Test Tags Sparingly

It's tempting to slap a

code
testTag
on everything. Resist. A tag is a hook that exists only for tests, and a screen covered in tags couples your UI to your test suite. I reach for text and content descriptions first, because those are things the app needs for accessibility anyway — testing through them means my tests and my screen-reader support reinforce each other. I add a
code
testTag
only when there's genuinely no user-facing way to identify a node, like distinguishing two visually identical items.

Test the ViewModel Separately

The most valuable tests in a Compose app usually aren't UI tests at all. Logic — validation, state transitions, error handling — belongs in the ViewModel, and the ViewModel is a plain class you can test with no UI, no device, and no synchronization concerns. Those tests run in milliseconds. I push as much behavior as possible into the ViewModel precisely so I can test it cheaply, and then I write a thin layer of Compose tests to confirm the screen renders each state and forwards each interaction. The UI test answers "does the screen show the error state," and the unit test answers "does an invalid input produce the error state." Splitting those questions keeps each test simple.

Where UI Tests Stop Paying Off

UI tests are slower than unit tests by orders of magnitude, so I don't try to cover every combination through the UI. I test the critical user flows — sign-up, the core action, a couple of error paths — and rely on ViewModel unit tests for the combinatorial detail. Trying to test every edge case through the UI is how a suite becomes a twenty-minute drag that people start skipping. The goal isn't maximum UI coverage; it's confidence that the screens wire up correctly to logic you've already tested thoroughly elsewhere. A small, fast, behavior-focused UI suite that people actually run beats an exhaustive one that rots in a disabled CI job.

The mindset that makes all of this sustainable is treating tests as documentation of behavior rather than a coverage metric to satisfy. A good Compose test reads like a description of what the screen promises a user, and when it fails it tells you which promise broke. That framing keeps you from writing brittle tests that assert implementation details, because implementation details aren't promises — they're how you happen to keep them today. Aim for a suite where every test would still make sense if you handed it to someone who'd never seen the code, and you'll naturally end up with the kind of behavior-focused tests that survive refactors and actually get maintained.

Key Takeaways

  • Find nodes by user-perceivable properties — text, content description — not by type or position, so tests survive refactors.
  • Let the Compose test rule handle synchronization; drive async work with a test dispatcher instead of sleeps.
  • Use
    code
    testTag
    only when there's no accessible, user-facing way to identify a node.
  • Push logic into the ViewModel and test it as a fast, plain-class unit test; keep UI tests thin.
  • Cover critical flows and a few error paths through the UI; leave combinatorial detail to unit tests so the suite stays fast.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this