
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 stablerunComposeUiTestAPI 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, notandroidx.compose.ui.test.runComposeUiTest. The package without.v2is 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:
-
Semantic tags must be added explicitly.
onNodeWithTag()only works if you’ve addedModifier.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. -
The
@OptInannotation is per file. You can’t declare it once at module level and have it propagate. Every test file usingrunComposeUiTestneeds its own@OptIn(ExperimentalTestApi::class). -
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.
-
@Previewcomposables are not tests. They don’t run incommonTest. 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.
Comments
Loading comments...