Testing Multiplatform Code

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

The promise of Kotlin Multiplatform (KMP) is that business logic is written once, and the strongest argument for sharing it is that it can also be tested once: a single OfflineFirstTrailRepositoryTest in commonTest runs on the JVM, on the iOS simulator, in Node.js and in a browser, and each run exercises the same source against that platform’s real compiler, runtime and memory model. A bug that only appears in Kotlin/Native (for instance a dispatcher assumption) or in Kotlin/Wasm shows up in the shared test suite instead of in production.

This page targets the section baseline: Kotlin 2.4.20, kotlin-test 2.4.20, kotlinx-coroutines-test 1.11.0, Turbine 1.2.1, Mokkery 3.5.0, Kotest 6.2.5, Ktor 3.6.0, SQLDelight 2.4.0, Room 3 (androidx.room3) 3.0.3, Koin 4.2.2 and Compose Multiplatform (CMP) 1.12.1, with Android through the com.android.kotlin.multiplatform.library plugin (AGP 9.4). Stability is stated where it is not Stable: the CMP UI test API runComposeUiTest (v2) is Experimental, Kotlin/Wasm and CMP for web are Beta, and Mokkery and Turbine are third-party libraries with their own release cadence. The examples slice the running example, TrailMate Everywhere (package com.example.trailmate), introduced in What Kotlin Multiplatform Is and When to Use It. The Kotlin testing basics (kotlin.test annotations, assertions, JUnit 5 on the JVM) are not re-taught; see Testing in Kotlin. Android-only techniques (Robolectric, Espresso, Hilt test rules) live in Android: Unit Testing and Android: Instrumented and UI Testing.

kotlin.test in commonTest

kotlin.test is the multiplatform testing API: annotations (@Test, @BeforeTest, @AfterTest, @Ignore) and assertion functions (assertEquals, assertTrue, assertFailsWith, …​) that are expect/actual declarations mapped onto JUnit 5, XCTest-free Kotlin/Native runners, Mocha-style JS runners and so on. You declare it once in commonTest, and the Kotlin Gradle plugin resolves the right adapter for each target.

// sharedLogic/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKotlinMultiplatformLibrary)
}

kotlin {
    android {
        namespace = "com.example.trailmate.shared"
        compileSdk = 36
        minSdk = 24
        // Android host (local JVM) tests: source set androidHostTest, task testAndroidHostTest
        withHostTest { isIncludeAndroidResources = true }
    }
    jvm()
    iosArm64()
    iosSimulatorArm64()
    wasmJs { browser() }

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
            implementation(libs.kotlinx.coroutines.test)
            implementation(libs.turbine)
        }
    }
}

The common-test source set sees commonMain automatically, so no extra dependency is needed to reach Trail. The platform source sets (jvmTest, iosTest, wasmJsTest) see commonTest as their parent in the hierarchy described in Project Structure and Source Sets, so a test written in commonTest is compiled once per target rather than copied.

Running Tests per Target and Reading Reports

Each target gets its own test task, and an aggregate task runs them all. The task names are derived from the target name, which is why the Android task has a different shape under the Android-KMP library plugin.

Task What it runs Notes

allTests

The tests of every target plus an aggregated HTML report

Fails the build if any target fails, but still runs the others first

jvmTest

commonTest and jvmTest on the JVM

Fastest feedback loop; use it while developing

testAndroidHostTest

commonTest and androidHostTest as local JVM tests

Exists only after withHostTest \{ }; replaces the legacy testDebugUnitTest

iosSimulatorArm64Test

Tests on a booted iOS simulator via xcrun simctl

macOS only; Tier 1 target

macosArm64Test

Native tests on the host Mac

macOS only; no simulator needed

wasmJsBrowserTest / wasmJsNodeTest

Tests in headless Chrome or in Node.js

Wasm is Beta; the wasmJsTest aggregate runs the configured runtime(s)

jsNodeTest

Kotlin/JS tests in Node.js

Stable

# run from the TrailMate Everywhere repository root
./gradlew :sharedLogic:jvmTest
./gradlew :sharedLogic:testAndroidHostTest
./gradlew :sharedLogic:iosSimulatorArm64Test          # macOS only
./gradlew :sharedLogic:wasmJsTest
./gradlew :sharedLogic:allTests                       # every target + aggregated report
./gradlew :sharedLogic:jvmTest --tests "*OfflineFirstTrailRepositoryTest"

Reports are written per target under sharedLogic/build/reports/tests/<taskName>/index.html, and allTests adds an aggregated report that lists the same test class once per target, so a failing platform is obvious at a glance. Raw XML results (useful for CI annotations) are in build/test-results/<taskName>/. When Kotlin/Native tests fail, the HTML report contains the Kotlin stack trace, but crashes in the runtime (a segmentation fault, for instance) only show in the console log; see Kotlin/Native Memory, Performance and Debugging.

macOS-only Apple tests

Kotlin/Native compiles Apple targets with Xcode’s toolchain, so iosSimulatorArm64Test, macosArm64Test and every other Apple test task are registered only on a macOS host; on Linux or Windows the tasks do not exist, and allTests silently skips them. The implication for CI is concrete: a Linux runner can verify jvmTest, wasmJsTest and testAndroidHostTest, but only a macOS runner verifies iOS behaviour. A common layout runs the cheap targets on Linux for every push and the Apple targets on macOS before merging. The simulator must be bootable, so pin the Xcode version (26.4 for Kotlin 2.4.20) as described in Gradle Configuration and Compatibility.

Testing Coroutines with kotlinx-coroutines-test

Shared code is full of suspend functions and Flows, and real delays and real dispatchers make tests slow and flaky. kotlinx-coroutines-test gives you a virtual clock: a test scheduler that runs coroutines deterministically and skips delay instantly. It is a multiplatform library, so the same runTest runs on every target, including Kotlin/Wasm where blocking is not allowed (there runTest returns a promise the framework awaits).

API Purpose

runTest \{ …​ }

Runs the body in a TestScope with virtual time; delay(1000) completes immediately. Fails on uncaught coroutine exceptions and on a 60-second real-time timeout (configurable)

StandardTestDispatcher

Queues coroutines and runs them only when the test yields (advanceUntilIdle(), runCurrent(), or suspension); the default inside runTest and the best model of real dispatchers

UnconfinedTestDispatcher

Runs coroutines eagerly like Dispatchers.Unconfined; convenient for collecting a StateFlow without extra calls

TestScope.backgroundScope

A scope cancelled automatically when the test ends; ideal for long-lived collectors

Dispatchers.setMain / resetMain

Replaces Dispatchers.Main so ViewModels that use viewModelScope work off a device

Dispatchers.Main does not exist in a plain JVM or Kotlin/Native test run, so a shared ViewModel needs it replaced. Wrap that in a small reusable rule so every test class stays short.

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/testing/MainDispatcherRule.kt
package com.example.trailmate.testing

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain

@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    val dispatcher: TestDispatcher = StandardTestDispatcher(),
) {
    fun start() = Dispatchers.setMain(dispatcher)
    fun stop() = Dispatchers.resetMain()
}

There is no JUnit @Rule in common code, so the "rule" is a plain class driven from @BeforeTest and @AfterTest. The repository test below combines it with runTest, a fake API and an in-memory database (both introduced later in this page).

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/data/OfflineFirstTrailRepositoryTest.kt
package com.example.trailmate.data

import com.example.trailmate.testing.FakeTrailApiClient
import com.example.trailmate.testing.inMemoryTrailDatabase
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class OfflineFirstTrailRepositoryTest {
    private val api = FakeTrailApiClient()
    private val database = inMemoryTrailDatabase()
    private val repository = OfflineFirstTrailRepository(api, database)

    @Test
    fun refreshStoresRemoteTrailsLocally() = runTest {
        api.trails = listOf(sampleTrail("t1"), sampleTrail("t2"))

        repository.refresh()

        assertEquals(listOf("t1", "t2"), database.trailQueries.selectAll().executeAsList().map { it.id })
    }
}

The expression-body form = runTest \{ …​ } matters on Kotlin/JS and Wasm: the test function must return the result of runTest (a TestResult, which is a promise on those targets) so the framework can await it. Writing fun test() \{ runTest \{ …​ } } compiles and silently passes on JS without running the assertions, one of the most common multiplatform testing mistakes. For the coroutine concepts themselves, see Coroutines, Flow and Concurrency in Shared Code and Kotlin Coroutines Basics.

Testing Flows and ViewModels with Turbine

A Flow is a stream, and asserting on a stream means asserting on a sequence of emissions. Turbine (Cash App) turns a Flow into a channel you pull from with awaitItem(), awaitError() and awaitComplete(), and it fails the test if any emission is left unconsumed or if an event does not arrive. It is a multiplatform library, so it works in commonTest for every target.

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/presentation/TrailListViewModelTest.kt
package com.example.trailmate.presentation

import app.cash.turbine.test
import com.example.trailmate.testing.FakeTrailRepository
import com.example.trailmate.testing.MainDispatcherRule
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

class TrailListViewModelTest {
    private val mainRule = MainDispatcherRule()
    private val repository = FakeTrailRepository()

    @BeforeTest fun setUp() = mainRule.start()
    @AfterTest fun tearDown() = mainRule.stop()

    @Test
    fun emitsLoadingThenContent() = runTest {
        repository.trails = listOf(sampleTrail("t1"))
        val viewModel = TrailListViewModel(repository)

        viewModel.uiState.test {
            assertEquals(TrailListUiState.Loading, awaitItem())
            mainRule.dispatcher.scheduler.advanceUntilIdle()
            val content = awaitItem()
            assertIs<TrailListUiState.Content>(content)
            assertEquals(1, content.trails.size)
            cancelAndIgnoreRemainingEvents()
        }
    }
}

Three details are worth remembering. A StateFlow always replays its current value, so the first awaitItem() is the initial state, not the first update. Turbine has its own 3-second wall-clock timeout per awaitItem, which applies even under virtual time, so give the TestScheduler something to run (advanceUntilIdle) before awaiting. Finally, cancelAndIgnoreRemainingEvents() is required for hot flows that never complete, otherwise Turbine reports unconsumed events or a missing cancel. ViewModels themselves are described in Architecture, Shared ViewModels and State.

Test Doubles: Fakes Versus Mocks on Kotlin/Native

A test double replaces a real collaborator. Mock libraries on the JVM generate a subclass at runtime through reflection and bytecode manipulation. Kotlin/Native compiles ahead of time to machine code, has no runtime class generation and (for the Kotlin source) no JVM reflection, so that technique is impossible in commonTest.

Approach How it works Works in commonTest?

Hand-written fake

A real class implementing the interface with in-memory state

Yes, on every target

MockK, Mockito

Runtime proxy generation, bytecode and reflection

No, JVM and Android only

Mokkery

Kotlin compiler plugin generating mocks at compile time

Yes, all targets

This is why the Kotlin community defaulted to fakes: they are plain Kotlin, they work everywhere, and they test behaviour (state after a call) instead of implementation (which methods were called). Because the shared module exposes small interfaces (TrailRepository, TrailApiClient, LocationProvider), a fake is usually a dozen lines.

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/testing/FakeTrailRepository.kt
package com.example.trailmate.testing

import com.example.trailmate.domain.Trail
import com.example.trailmate.domain.TrailRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow

class FakeTrailRepository : TrailRepository {
    private val state = MutableStateFlow<List<Trail>>(emptyList())

    var trails: List<Trail>
        get() = state.value
        set(value) { state.value = value }

    var refreshCalls = 0
        private set

    override fun observeTrails(): Flow<List<Trail>> = state

    override suspend fun refresh() {
        refreshCalls++
    }

    override suspend fun toggleFavorite(id: String) {
        state.value = state.value.map { if (it.id == id) it.copy(isFavorite = !it.isFavorite) else it }
    }
}

Mokkery: compiler-plugin mocks

When a fake would be larger than the test (an interface with twenty methods, or a need to verify an interaction such as "analytics was called once"), Mokkery provides mocks and spies for every KMP target. It is a Kotlin compiler plugin plus a runtime library: it rewrites mock creation at compile time, which is also why it is tied to Kotlin versions (Mokkery 3.5.0 targets the Kotlin 2.4 line) and why it cannot mock a final class that the plugin did not see compiled. Mokkery is a third-party project and follows its own compatibility table.

Add the plugin (alias(libs.plugins.mokkery), id dev.mokkery) to sharedLogic/build.gradle.kts and the mokkery-coroutines helper to commonTest.dependencies; then mocks are created with mock<T>().

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/presentation/TrailDetailViewModelTest.kt
package com.example.trailmate.presentation

import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlin.test.Test

class TrailDetailViewModelTest {
    private val repository = mock<TrailRepository>()
    private val analytics = mock<AnalyticsTracker>(MockMode.autofill)

    @Test
    fun favouriteTapIsTracked() = runTest {
        every { repository.observeTrail("t1") } returns flowOf(sampleTrail("t1"))
        everySuspend { repository.toggleFavorite("t1") } returns Unit
        val viewModel = TrailDetailViewModel("t1", repository, analytics)

        viewModel.onFavoriteClicked()

        verifySuspend { repository.toggleFavorite("t1") }
        verify(exactly(1)) { analytics.track("favorite_toggled") }
    }
}

Why MockK is JVM-only

MockK and Mockito depend on java.lang.reflect, JVM bytecode instrumentation (ByteBuddy, Objenesis) and the JVM attach API, none of which exist on Kotlin/Native, Kotlin/JS or Kotlin/Wasm. They therefore belong in jvmTest or androidHostTest only, for tests of actual implementations already tied to the JVM or Android; in commonTest they break the iOS and Wasm builds at dependency resolution. Keep the shared suite portable with fakes first and Mokkery second; see Android: Unit Testing for MockK on Android.

Kotest in Multiplatform Projects

Kotest is a multiplatform test framework and assertion library. Its assertions (shouldBe, shouldContainExactly, shouldThrow) and property-based testing run in commonTest on all targets, and since Kotest 6 the framework engine also supports JVM, JS, Wasm and Native through a Gradle plugin. You can adopt only the assertion library and keep kotlin.test as the runner, which is the lowest-friction option.

Typical usage mirrors kotlin.test with infix matchers, for example result.map \{ it.id } shouldContainExactly listOf("t2") and result.size shouldBe 1 in a commonTest class. See Kotest — official documentation.

Testing the Network Layer with Ktor MockEngine

The Ktor client separates plugins from the engine, so tests replace only the engine. MockEngine (artifact ktor-client-mock, a common dependency) answers each request through a handler lambda, which means the real ContentNegotiation plugin, the real defaultRequest and the real JSON parsing are exercised, with no sockets. This is the technique behind FakeTrailApiClient: tests can either use a hand-written fake of the TrailApiClient interface (to test the repository) or a MockEngine (to test TrailApiClient itself).

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/data/TrailApiClientTest.kt
package com.example.trailmate.data

import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals

class TrailApiClientTest {
    private fun client(handler: MockEngine) = TrailApiClient(
        HttpClient(handler) {
            install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
            defaultRequest { url("https://api.example.com/v1/") }
            expectSuccess = true
        },
    )

    @Test
    fun parsesTrailList() = runTest {
        val engine = MockEngine { request ->
            assertEquals("/v1/trails", request.url.encodedPath)
            respond(
                content = """[{"id":"t1","name":"Ridge Loop","region":"Alps","lengthKm":18.5,"difficulty":"HARD"}]""",
                status = HttpStatusCode.OK,
                headers = headersOf(HttpHeaders.ContentType, "application/json"),
            )
        }

        val trails = client(engine).trails(page = 1, region = null)

        assertEquals("Ridge Loop", trails.single().name)
    }
}

See Ktor — Testing. The networking layer itself is described in Networking with the Ktor Client.

In-Memory Databases: SQLDelight and Room

A repository test should exercise real SQL without touching the disk or sharing state between tests. Both persistence libraries support this, but the mechanism differs because the drivers differ per platform.

Library In-memory option Where it is declared

SQLDelight 2.4.0

JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) on the JVM; NativeSqliteDriver with DatabaseConfiguration(inMemory = true) on Native; inMemoryDriver helpers per target

An expect fun createTestDriver() in commonTest with actual`s in `jvmTest, iosTest, …​

Room 3 (androidx.room3) 3.0.3

Room.inMemoryDatabaseBuilder with the bundled SQLite driver and Dispatchers.Unconfined query context

Per target, or through the expect/actual @ConstructedBy factory

SQLDelight’s schema is generated in common code, so the only platform-specific piece is the driver, which fits the expect/actual mechanism of Expect/Actual and Platform APIs.

// sharedLogic/src/commonTest/kotlin/com/example/trailmate/testing/TestDatabase.kt
package com.example.trailmate.testing

import app.cash.sqldelight.db.SqlDriver
import com.example.trailmate.db.TrailMateDatabase

expect fun createTestDriver(): SqlDriver

fun inMemoryTrailDatabase(): TrailMateDatabase {
    val driver = createTestDriver()
    TrailMateDatabase.Schema.create(driver)
    return TrailMateDatabase(driver)
}
// sharedLogic/src/jvmTest/kotlin/com/example/trailmate/testing/TestDatabase.jvm.kt
package com.example.trailmate.testing

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver

actual fun createTestDriver(): SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)

The iOS actual (in iosTest) is NativeSqliteDriver(TrailMateDatabase.Schema, "test.db", onConfiguration = \{ it.copy(inMemory = true) }).

With Room 3 the same idea uses Room.inMemoryDatabaseBuilder with BundledSQLiteDriver and setQueryCoroutineContext(Dispatchers.Unconfined) in androidHostTest; see Android Developers — Set up Room Database for KMP. Persistence design is covered in Local Storage: SQLDelight, Room and DataStore.

Verifying the Koin Graph

A dependency-injection graph fails at runtime when a binding is missing, which on iOS means a crash at launch. Koin’s verify() checks the module’s definitions against their constructor parameters at test time, so a forgotten single \{ …​ } fails the build instead of the app. Koin 4.x provides it in the koin-test artifact, and it is JVM-only: it reads constructors through reflection, so it runs in jvmTest or androidHostTest, not in commonTest or on Native.

// sharedLogic/src/androidHostTest/kotlin/com/example/trailmate/di/TrailMateModuleTest.kt
package com.example.trailmate.di

import com.example.trailmate.domain.LocationProvider
import kotlinx.coroutines.CoroutineDispatcher
import org.koin.test.verify.verify
import kotlin.test.Test

class TrailMateModuleTest {
    @Test
    fun trailMateModuleIsComplete() {
        trailMateModule.verify(
            // types provided at runtime by the platform (androidContext, Swift, ...)
            extraTypes = listOf(LocationProvider::class, CoroutineDispatcher::class),
        )
    }
}

See Koin — Verify your Koin configuration. verify() validates constructor types only (not qualifiers or get() calls inside definitions); it cannot detect a wrong implementation, and the Koin Compiler Plugin can replace it where it checks the whole graph at build time. Modules and platform wiring are described in Dependency Injection: Koin and Metro.

Platform Tests

Anything that touches a platform API (actual classes, Android Context, Core Location) must be tested where that API exists. KMP offers a place for each.

androidHostTest (task testAndroidHostTest) runs fast local JVM tests of AndroidLocationProvider; androidDeviceTest (connectedAndroidDeviceTest) needs an emulator for permissions and system services; iosTest runs on the simulator; jvmTest covers desktop actual implementations.

The Android source sets exist only after withHostTest \{ } and withDeviceTest \{ } are enabled in the android block; see The Android Target and AGP 9.

Platform tests in iosTest use Apple APIs directly through platform.Foundation (for example NSUserDefaults for IosLocationProvider’s cache) and run on the simulator with `iosSimulatorArm64Test. See Kotlin docs — Test your multiplatform app.

XCTest calling the shared framework

Some bugs live in the Swift-to-Kotlin boundary: nullability, suspend functions mapped to completion handlers or async, enums that became classes, a Flow collected from SwiftUI. Kotlin tests cannot see those, so you also write XCTest cases in the Xcode project that call the exported framework exactly as the app does. Keep them thin: they check the mapping and the wiring, while the logic is already covered in commonTest.

// iosApp/iosAppTests/SharedFrameworkTests.swift
import XCTest
import SharedLogic

final class SharedFrameworkTests: XCTestCase {
    func testPlatformNameIsExposedToSwift() {
        XCTAssertEqual(PlatformKt.platformName(), "iOS")
    }

    func testSuspendFunctionIsAwaitableFromSwift() async throws {
        let repository = RepositoryFactory.shared.inMemoryRepository()
        // suspend fun refresh() maps to an async throws method (Swift export, Alpha)
        // or to a completion-handler / async bridge when using the Obj-C header
        try await repository.refresh()
        let trails = try await repository.currentTrails()
        XCTAssertTrue(trails.isEmpty)
    }
}

See Kotlin docs — Test your multiplatform app. The bridging rules are in Objective-C and Swift Interop and Coroutines and Flow from Swift. Run the same XCTest bundle in CI with xcodebuild test; Xcode’s own test tooling is described in Apple: UI Tests, Test Plans and Coverage, and Swift Testing in Apple: Testing with Swift Testing. The choice of how to integrate the framework is covered in iOS Integration Options.

Testing Compose Multiplatform UI with runComposeUiTest

When the UI is shared with CMP, UI tests can be shared too. runComposeUiTest (from org.jetbrains.compose.ui:ui-test) sets up a Compose test environment, runs your composable and lets you find nodes with semantics matchers, perform clicks and assert state, in commonTest, with no Activity or Xcode needed. The v2 API (androidx.compose.ui.test.v2.runComposeUiTest) uses the standard test dispatcher by default, so tests behave like production composition; the older v1 API used an unconfined dispatcher. The API is Experimental and requires @OptIn(ExperimentalTestApi::class). Runtime support covers Android, iOS, desktop and web, with desktop and JVM the most mature; the Compose Hot Reload and Compose Previews tooling does not apply to tests.

// sharedUI/build.gradle.kts
kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
            @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
            implementation(compose.uiTest)
        }
        jvmTest.dependencies {
            implementation(compose.desktop.currentOs) // desktop test runtime
        }
    }
}
// sharedUI/src/commonTest/kotlin/com/example/trailmate/ui/TrailListScreenTest.kt
package com.example.trailmate.ui

import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runComposeUiTest
import com.example.trailmate.presentation.TrailListUiState
import kotlin.test.Test
import kotlin.test.assertEquals

@OptIn(ExperimentalTestApi::class)
class TrailListScreenTest {
    @Test
    fun showsTrailsAndReportsClicks() = runComposeUiTest {
        var clicked: String? = null
        setContent {
            TrailMateTheme {
                TrailListScreen(
                    state = TrailListUiState.Content(listOf(sampleTrail("t1", name = "Ridge Loop"))),
                    onTrailClick = { clicked = it },
                )
            }
        }

        onNodeWithText("Ridge Loop").assertExists().performClick()

        assertEquals("t1", clicked)
    }
}

Pass state and callbacks into the screen rather than a ViewModel, as above (state hoisting), so the test needs no Koin or dispatcher plumbing. Desktop projects may also use the JUnit-rule style (createComposeRule()); see Kotlin docs — Test Compose Multiplatform UI on desktop. The semantics tree that these tests query is the same one used for accessibility, covered in Compose Multiplatform: Adaptive UI and Accessibility and in Compose Multiplatform Fundamentals. Wasm UI tests run in a browser and are Beta; see Web with Kotlin/Wasm and Kotlin/JS. Compose basics themselves are in Android: Compose Fundamentals.

Coverage with Kover

Kover (org.jetbrains.kotlinx.kover) is JetBrains' coverage tool for Kotlin. It supports JVM and Android code, and since it uses JVM instrumentation, it measures the code that runs on the JVM and Android targets only; Kotlin/Native and JS/Wasm code is not instrumented. For a shared module that is still valuable, because most commonMain code runs in jvmTest. Add the plugin and call koverHtmlReport or koverVerify; wire a minimum into CI to stop regressions.

Apply alias(libs.plugins.kover) and configure a kover { reports { verify { rule { minBound(70) } } } } rule, then run koverHtmlReport or koverVerify in CI. See Kover — Gradle plugin documentation.

Common Mistakes

  • Importing JUnit or MockK in commonTest. The JVM build passes and iOS fails to compile. Use kotlin.test, fakes and Mokkery, and keep JVM libraries in jvmTest.

  • Writing fun test() \{ runTest \{ } }. On JS and Wasm the test ends before the coroutine runs. Return runTest from the function.

  • Treating Turbine as hot-flow aware. Cancel collection explicitly for StateFlow and SharedFlow.