Testing Compose Multiplatform UI Across Platforms
Compose Multiplatform extends Jetpack Compose beyond Android, bringing the same declarative UI model to iOS, desktop (macOS, Windows, Linux), and web. But testing a UI that runs on four platforms is a different challenge than testing a single-platform app.
This guide covers how to write and run Compose Multiplatform UI tests effectively.
How Compose Testing Works
Jetpack Compose's testing library works through the semantics tree — a parallel representation of your UI that exposes accessibility metadata: roles, content descriptions, actions, and states. Tests interact with this tree rather than with pixels.
This approach has a key advantage for multiplatform: the semantics-based testing API works the same way on Android and desktop. iOS is more restricted (see below), but Android and desktop share the same testing model.
Setting Up the Test Dependencies
In shared/build.gradle.kts (or your UI module's Gradle file):
kotlin {
androidTarget()
jvm("desktop")
sourceSets {
val androidMain by getting {
dependencies {
implementation(compose.ui)
implementation(compose.material3)
}
}
val androidInstrumentedTest by getting {
dependencies {
implementation("androidx.compose.ui:ui-test-junit4:1.6.0")
implementation("androidx.compose.ui:ui-test-manifest:1.6.0")
}
}
val desktopTest by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation("org.jetbrains.compose.ui:ui-test-junit4:1.6.0")
}
}
}
}Writing Composable Tests on Android
Android tests use ComposeTestRule via createComposeRule():
// androidInstrumentedTest
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.*
import org.junit.Rule
import org.junit.Test
class CounterScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun incrementsCounterOnButtonClick() {
composeTestRule.setContent {
CounterScreen()
}
composeTestRule.onNodeWithText("Count: 0").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Increment").performClick()
composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed()
}
@Test
fun disablesDecrementAtZero() {
composeTestRule.setContent {
CounterScreen()
}
composeTestRule
.onNodeWithContentDescription("Decrement")
.assertIsNotEnabled()
}
}Writing Composable Tests on Desktop
Desktop tests use the same API, but via runComposeUiTest:
// desktopTest
import androidx.compose.ui.test.*
import kotlin.test.Test
class CounterScreenDesktopTest {
@Test
fun incrementsCounterOnButtonClick() = runComposeUiTest {
setContent {
CounterScreen()
}
onNodeWithText("Count: 0").assertIsDisplayed()
onNodeWithContentDescription("Increment").performClick()
onNodeWithText("Count: 1").assertIsDisplayed()
}
}The API is nearly identical — setContent, onNodeWithText, performClick, assertIsDisplayed. The same test logic works on both platforms with minor structural differences.
Semantics Matchers Reference
The most commonly used finders and assertions:
// Finding nodes
onNodeWithText("Submit")
onNodeWithContentDescription("Close button")
onNodeWithTag("login-form")
onAllNodesWithTag("list-item")
// Assertions
.assertIsDisplayed()
.assertIsEnabled()
.assertIsNotEnabled()
.assertIsSelected()
.assertHasClickAction()
.assertTextEquals("Expected text")
.assertCountEquals(5)
// Actions
.performClick()
.performTextInput("user@example.com")
.performTextClearance()
.performScrollToIndex(10)
.performImeAction() // triggers keyboard "Done", "Search", etc.
// Waiting for async state
waitUntil(timeoutMillis = 5_000L) {
onAllNodesWithTag("loaded-item").fetchSemanticsNodes().isNotEmpty()
}Adding Semantic Metadata to Composables
Tests work best when your composables expose clear semantics:
@Composable
fun LoginButton(onClick: () -> Unit, isLoading: Boolean) {
Button(
onClick = onClick,
modifier = Modifier
.semantics {
contentDescription = if (isLoading) "Logging in..." else "Log in"
}
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("Log in")
}
}
}Use testTag for elements that don't have natural text or descriptions:
LazyColumn {
items(products) { product ->
ProductCard(
product = product,
modifier = Modifier.testTag("product-${product.id}")
)
}
}Testing State-Dependent UI
Many composable tests need to control the ViewModel or state holder:
@Test
fun showsErrorOnNetworkFailure() = runComposeUiTest {
val fakeViewModel = FakeLoginViewModel(
loginResult = LoginResult.NetworkError
)
setContent {
LoginScreen(viewModel = fakeViewModel)
}
onNodeWithText("Log in").performClick()
waitUntil {
onAllNodesWithText("Network error. Try again.").fetchSemanticsNodes().isNotEmpty()
}
onNodeWithText("Network error. Try again.").assertIsDisplayed()
}The fake ViewModel implements the same interface as your real one, but returns controlled results:
// commonTest
class FakeLoginViewModel(
private val loginResult: LoginResult
) : LoginViewModelInterface {
override val state = MutableStateFlow(LoginState.Idle)
override suspend fun login(email: String, password: String) {
state.value = LoginState.Loading
delay(100) // simulate async
state.value = when (loginResult) {
LoginResult.Success -> LoginState.LoggedIn
LoginResult.NetworkError -> LoginState.Error("Network error. Try again.")
LoginResult.InvalidCredentials -> LoginState.Error("Invalid email or password.")
}
}
}Screenshot Testing
Screenshot tests catch visual regressions that semantics tests miss — wrong colors, layout shifts, missing icons.
For Android, use paparazzi or Roborazzi:
// build.gradle.kts (app/androidMain module)
plugins {
id("app.cash.paparazzi") version "1.3.3"
}class LoginScreenSnapshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "Theme.MyApp"
)
@Test
fun loginScreenIdleState() {
paparazzi.snapshot {
LoginScreen(viewModel = FakeLoginViewModel(LoginResult.Success))
}
}
@Test
fun loginScreenErrorState() {
paparazzi.snapshot {
LoginScreen(viewModel = FakeLoginViewModel(LoginResult.NetworkError))
}
}
}On first run, Paparazzi records golden images. On subsequent runs, it compares against them and fails if pixels differ beyond a threshold.
iOS Testing Limitations
Compose Multiplatform on iOS renders through a Kotlin/Native-backed UIKit/SwiftUI wrapper. As of Compose Multiplatform 1.6.x:
- No official Compose test framework for iOS —
ComposeTestRuledoesn't work on the iOS target - XCUITest can interact with the app from the outside, but semantics aren't directly accessible
- Manual and visual testing remain the primary iOS UI verification methods
The practical approach: test shared business logic in commonTest with unit tests, test UI components on Android/desktop with Compose testing, and use integration testing tools (like HelpMeTest) for end-to-end iOS validation.
Running Tests
# Android UI tests (requires emulator or device)
./gradlew :shared:connectedAndroidTest
<span class="hljs-comment"># Desktop tests (JVM, no device needed)
./gradlew :shared:desktopTest
<span class="hljs-comment"># Screenshot tests
./gradlew :app:recordPaparazziDebug <span class="hljs-comment"># Record golden images
./gradlew :app:verifyPaparazziDebug <span class="hljs-comment"># Compare against goldensCI Configuration
jobs:
compose-ui-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
# Desktop tests run without a device
- name: Run desktop UI tests
run: ./gradlew :shared:desktopTest
# Screenshot tests
- name: Verify screenshots
run: ./gradlew :app:verifyPaparazziDebug
android-ui-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Run Android emulator tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
script: ./gradlew :shared:connectedAndroidTestTest Strategy Summary
| Platform | What to test | Tool |
|---|---|---|
| Android | Composable behavior, state, interactions | ComposeTestRule (JUnit4) |
| Desktop | Same behavior | runComposeUiTest |
| iOS | Business logic only (UI via integration) | kotlin.test + HelpMeTest |
| All platforms | Visual regression | Paparazzi (Android), manual (iOS) |
End-to-End Testing
Compose Multiplatform UI tests verify your composables in isolation. For real-world validation — testing the full user journey across your mobile app and backend — HelpMeTest provides continuous end-to-end testing. Write scenarios in plain English, and HelpMeTest runs them 24/7 against your production or staging environment.
This catches the category of bugs that Compose tests can't: API contract violations, backend regressions, and integration failures that only appear in the full system.
Summary
- Use
ComposeTestRuleon Android andrunComposeUiTeston desktop for Compose UI testing - Add
testTagandcontentDescriptionto composables for testability - Use fake ViewModels/state holders to test state-dependent UI
- Screenshot test with Paparazzi or Roborazzi for visual regression coverage
- Accept that iOS Compose testing is immature — compensate with integration tests
- Run desktop tests in CI without a device; Android tests need an emulator or device runner