Skip to content
Go back

Clean Lap: UI Testing in Compose Multiplatform

by KMP Bits

KMP Bits Cover

In qualifying, the data engineer doesn’t wait for Sunday to find out something was wrong. By the time the driver comes back to the garage, every sector time is already on the screen, every corner apex mapped against the reference lap. If the front left is locking under braking at Turn 3, it shows up immediately, not three hours later when the car is fighting for position and has no margin to fix anything.

I spent a long time treating UI tests the same way I treated the race result: something I’d look at after the damage was done. If a screen broke, someone filed a bug, I fixed it. But the first time I caught a regression in a CMP project because a test failed before the PR merged, I understood what the data engineer already knew. You want the telemetry before race day, not after.

Compose Multiplatform 1.11 (currently in beta) brings runComposeUiTest v2 support for non-Android targets. Writing a test in commonTest and running it on Android, desktop, and iOS is no longer an experimental workaround. This article walks through the setup, the API, and the one coroutine change in 1.11 that will break your existing tests if you upgrade without knowing about it.

A quick note on versions: Everything in this article uses 1.11.0-beta02. The APIs work as described, but beta means things can still shift before the final release. If you’re on a stable version, stick with what you have — the stable runComposeUiTest API is available from 1.6.x onwards, but the v2 surface and the dispatcher change covered here are 1.11-specific.

Note on Navigation: To keep the focus on testing, this sample uses a manual state-switching approach. While simplified, this mirrors the state-driven philosophy of Navigation 3. For production apps—especially when implementing Shared Element Transitions as discussed in this Navigation 3 CMP article, the formal Navigation 3 library is recommended to handle the transition orchestration and back-stack management effectively.


What you’re actually testing here

Before touching Gradle, it’s worth being precise about what compose.uiTest is for. It tests composable UI: the layout, the interaction flow, the visual states. It is not a replacement for unit tests on your ViewModel or business logic.

In a clean CMP project, your ViewModel lives in commonMain, your Compose UI lives in commonMain, and your tests live in commonTest. The tests invoke the composable directly, interact with it through semantic queries, and assert what the user sees. No emulator needed for desktop and iOS targets. No platform ceremony.

(If you’re testing ViewModels and Flows in isolation without a UI, those tests can also live in commonTest without compose. I covered how Flows behave across platforms in the StateFlow and SharedFlow article.)

That context set, here is what the setup actually looks like.


Setting up the dependencies

Start with libs.versions.toml. Every new library goes here first.

# gradle/libs.versions.toml
[versions]
compose-multiplatform = "1.11.0-beta02"
kotlin = "2.1.20"
androidx-compose-ui-test = "1.8.0"

[libraries]
# Android instrumented test support — not needed for desktop or iOS
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4-android", version.ref = "androidx-compose-ui-test" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-ui-test" }

Then in build.gradle.kts:

// composeApp/build.gradle.kts
kotlin {
    androidTarget {
        @OptIn(ExperimentalKotlinGradlePluginApi::class)
        // Without this, commonTest won't link to the Android instrumented variant
        instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test)
    }

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
            @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
            implementation(compose.uiTest)
        }

        // Android instrumented tests need this additional dependency
        androidInstrumentedTest.dependencies {
            implementation(libs.androidx.compose.ui.test.junit4)
        }
    }
}

dependencies {
    // Manifest injection for Android debug builds
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}

The instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) line is the one that trips people up. Without it, your commonTest source set won’t link to the Android instrumented test variant, and you’ll get missing class errors on device. I spent two hours staring at that error before I found it buried in a JetBrains issue tracker thread.

With that in place, the pit lane work is done. Everything else happens in commonTest.


Writing your first common test

runComposeUiTest is a top-level function. You call it, get a ComposeUiTest receiver, set your content, and query the semantic tree. No TestRule, no JUnit class-level annotation.

// commonTest/kotlin/ui/HomeScreenTest.kt
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runComposeUiTest
import kotlin.test.Test

@OptIn(ExperimentalTestApi::class)
class HomeScreenTest {

    @Test
    fun homeScreen_titleIsVisible() = runComposeUiTest {
        setContent {
            HomeScreen()
        }

        onNodeWithText("Home").assertIsDisplayed()
    }
}

Import correct: Use androidx.compose.ui.test.v2.runComposeUiTest, not androidx.compose.ui.test.runComposeUiTest. The package without .v2 is the old version (and it’s deprecated on 1.11-beta02).

The @OptIn(ExperimentalTestApi::class) is required for every file until the API fully graduates. In 1.11.0-beta02, the v2 APIs are available for non-Android targets but the annotation is still needed on the common surface — expect it to drop when 1.11 goes stable.

For interaction flows:

// commonTest/kotlin/ui/LoginScreenTest.kt
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runComposeUiTest
import kotlin.test.Test
import kotlin.test.assertTrue

@OptIn(ExperimentalTestApi::class)
class LoginScreenTest {

    @Test
    fun loginScreen_showsErrorWhenEmailIsEmpty() = runComposeUiTest {
        setContent {
            LoginScreen(onLoginSuccess = {})
        }

        // User taps submit without filling anything in
        onNodeWithTag("submit_button").performClick()

        onNodeWithText("Email cannot be empty").assertIsDisplayed()
    }

    @Test
    fun loginScreen_navigatesOnValidInput() = runComposeUiTest {
        var navigated = false

        setContent {
            LoginScreen(onLoginSuccess = { navigated = true })
        }

        onNodeWithTag("email_field").performTextInput("test@example.com")
        onNodeWithTag("password_field").performTextInput("hunter2")
        onNodeWithTag("submit_button").performClick()

        assertTrue(navigated)
    }
}

The test annotation is kotlin.test.Test, not org.junit.Test. That’s what makes the test run across all targets, including iOS via the Kotlin/Native runner. If you accidentally import the JUnit annotation in a common file, the iOS and desktop targets will not pick it up at all — the test silently won’t exist on those platforms.


The coroutine dispatcher change in CMP 1.11

This is the part you need to know before you upgrade.

Before v2, runComposeUiTest used UnconfinedTestDispatcher internally. Coroutines ran eagerly — side effects triggered immediately, states updated without you doing anything. Tests passed without any manual clock advancement. It felt convenient.

In CMP 1.11, the default switches to StandardTestDispatcher. Coroutines no longer run automatically. If your composable launches a coroutine on composition, such as a LaunchedEffect triggering a data fetch, you need to advance the test scheduler to see the result.

// commonTest/kotlin/ui/FeedScreenTest.kt
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.v2.runComposeUiTest
import kotlin.test.Test

@OptIn(ExperimentalTestApi::class)
class FeedScreenTest {

    @Test
    fun feedScreen_showsLoadingIndicator() = runComposeUiTest {
        setContent {
            FeedScreen(state = FeedUiState.Loading)
        }

        onNodeWithTag("loading_indicator").assertIsDisplayed()
    }

    @Test
    fun feedScreen_showsContentItems() = runComposeUiTest {
        setContent {
            FeedScreen(state = FeedUiState.Content(listOf("Article 1", "Article 2")))
        }

        onNodeWithTag("feed_list").assertIsDisplayed()
    }

    @Test
    fun feedScreen_showsLoadingThenContent() = runComposeUiTest {
        var state by mutableStateOf<FeedUiState>(FeedUiState.Loading)

        setContent {
            LaunchedEffect(Unit) {
                delay(500)
                state = FeedUiState.Content(listOf("Article 1", "Article 2", "Article 3"))
            }
            FeedScreen(state = state)
        }

        // Check initial state
        onNodeWithTag("loading_indicator").assertIsDisplayed()

        // Wait until the list appears (this handles the clock advancement internally)
        waitUntil(timeoutMillis = 1000) {
            // Returns true when the node exists
            onAllNodesWithTag("feed_list").fetchSemanticsNodes().isNotEmpty()
        }

        onNodeWithTag("feed_list").assertIsDisplayed()
    }
}

While waitForIdle() pumps the event queue until the current UI state is stable, waitUntil actively advances the virtual clock to bridge gaps created by delay() or asynchronous tasks, ensuring the test stays paused until your specific UI condition is met.

When I upgraded a CMP project from 1.9 to 1.11-beta02, five tests that were passing started failing. Every single one was relying on the eager dispatcher to hide an async gap in the UI. The tests were wrong before. The new dispatcher just finally showed it.


Running across platforms

Three targets, three commands:

# Android instrumented (emulator or connected device required)
./gradlew :composeApp:connectedAndroidTest

# Desktop (JVM, no emulator needed — fast)
./gradlew :composeApp:desktopTest

# iOS (Kotlin/Native, runs via XCTest wrapper)
./gradlew :composeApp:iosSimulatorArm64Test

The desktop run is the fastest feedback loop. I use it constantly during development — the round trip is under 30 seconds on a warm build. If the desktop test passes, Android and iOS almost always do too, unless there’s a platform-specific composable in the mix.

A quick note: iOS tests require a Mac with an iOS Simulator available. On CI, that means a macOS runner for the iOS command. Linux runners will handle desktop and Android.

The goal on CI is to run all three in parallel. You want the qualifying data from every target before the PR merges, not just one.


Gotchas

A few things that cost me time:

  1. Semantic tags must be added explicitly. onNodeWithTag() only works if you’ve added Modifier.testTag("your_tag") to the composable. Don’t rely on text content for interactive elements — button labels change, tags don’t. Add tags from the start, not after the test breaks.

  2. The @OptIn annotation is per file. You can’t declare it once at module level and have it propagate. Every test file using runComposeUiTest needs its own @OptIn(ExperimentalTestApi::class).

  3. System dialogs are invisible to tests. If your composable triggers a permission dialog or a system-level sheet, the test runner won’t see it. Mock the permission state at the ViewModel level and test the resulting composable state, not the dialog.

  4. @Preview composables are not tests. They don’t run in commonTest. If you want to test a state-dependent screen, you’re testing the real composable with injected state — that’s a different thing, and it’s the right thing.


Where this fits with everything else

commonTest UI tests are fast and platform-agnostic. They cover layout, interactions, and state transitions. They don’t cover the full integration with real network, real databases, or real OS behaviour.

The way I think about it: common UI tests confirm the qualifying lap. The composable is doing the right thing in isolation. Integration tests on a real device confirm the race pace: does the real data layer, real network, and real platform behave together as expected?

Both matter. The qualifying lap is just considerably cheaper to run before the team heads out to the grid.

(For Android-specific UI testing on the JVM without a CMP setup, I covered Robolectric in an earlier article: Testing Jetpack Compose UI on the JVM. It’s a different approach and worth knowing about if you’re working on an Android-only module alongside your KMP code.)


Compose Multiplatform 1.11.0-beta02 makes runComposeUiTest a first-class citizen on non-Android targets. The setup is a handful of Gradle lines, the API keeps itself minimal, and the desktop feedback loop is fast enough to use on every save. The one thing to account for before upgrading is the dispatcher change: if your tests depended on eager coroutine execution, add waitForIdle() or waitUntil() (Depending on what you need) and treat the failures as the API finally telling you the truth.

The telemetry was always available. The only question is whether you check it before the race starts or after something goes wrong on track. 🏁


The full demo for this article is available on GitHub.

The KMP Bits app is available on App Store and Google Play — built entirely with KMP.


Share this post on:

Comments

0 / 250

Loading comments...


Next Post
Smooth Handoff