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 |
|---|---|---|
|
The tests of every target plus an aggregated HTML report |
Fails the build if any target fails, but still runs the others first |
|
|
Fastest feedback loop; use it while developing |
|
|
Exists only after |
|
Tests on a booted iOS simulator via |
macOS only; Tier 1 target |
|
Native tests on the host Mac |
macOS only; no simulator needed |
|
Tests in headless Chrome or in Node.js |
Wasm is Beta; the |
|
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 |
|---|---|
|
Runs the body in a |
|
Queues coroutines and runs them only when the test yields ( |
|
Runs coroutines eagerly like |
|
A scope cancelled automatically when the test ends; ideal for long-lived collectors |
|
Replaces |
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 |
|
An |
Room 3 ( |
|
Per target, or through the |
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. Usekotlin.test, fakes and Mokkery, and keep JVM libraries injvmTest. -
Writing
fun test() \{ runTest \{ } }. On JS and Wasm the test ends before the coroutine runs. ReturnrunTestfrom the function. -
Treating Turbine as hot-flow aware. Cancel collection explicitly for
StateFlowandSharedFlow.