Testing Compose Multiplatform UI Across Platforms

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 iOSComposeTestRule doesn'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 goldens

CI 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:connectedAndroidTest

Test 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 ComposeTestRule on Android and runComposeUiTest on desktop for Compose UI testing
  • Add testTag and contentDescription to 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

Read more