Kotlin Multiplatform Testing Guide: Shared Code Testing Strategies
Kotlin Multiplatform (KMP) lets you write business logic once and share it across Android, iOS, desktop, and web.
Kotlin Multiplatform (KMP) lets you write business logic once and share it across Android, iOS, desktop, and web. But sharing code means shared bugs — and shared testing responsibilities. This guide covers everything you need to test KMP projects effectively.
Why Testing KMP Is Different
In a typical Android project, your test setup is well-understood: JUnit for unit tests, Espresso for UI, Robolectric for instrumented tests on the JVM. In KMP, the ground shifts.
Your shared code runs on multiple platforms — each with different runtime environments, concurrency models, and standard libraries. A bug that only appears on iOS but not Android is entirely possible if you're not testing on both targets.
The core challenge: shared code needs shared tests, but platform-specific targets need platform-specific runners.
Project Structure for Testability
A well-structured KMP project separates concerns clearly:
shared/
├── commonMain/ # Shared business logic
├── commonTest/ # Tests that run on all platforms
├── androidMain/ # Android-specific implementations
├── androidTest/ # Android-specific tests
├── iosMain/ # iOS-specific implementations
└── iosTest/ # iOS-specific testsThe commonTest source set is where you want most of your test coverage. Code here compiles and runs against every configured target — JVM, Android, iOS (via Kotlin/Native), and JS.
Setting Up the Test Environment
In your shared/build.gradle.kts:
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
}
}
}The kotlin("test") dependency pulls in platform-appropriate assertions — JUnit on JVM/Android, XCTest bindings on iOS, and Jest on JS.
Writing Tests in commonTest
Tests in commonTest use the kotlin.test API:
// shared/src/commonTest/kotlin/UserRepositoryTest.kt
import kotlin.test.*
class UserRepositoryTest {
private lateinit var repo: UserRepository
@BeforeTest
fun setup() {
repo = UserRepository(FakeUserDataSource())
}
@Test
fun `returns user by id`() {
val user = repo.findById("user-123")
assertNotNull(user)
assertEquals("user-123", user.id)
}
@Test
fun `returns null for unknown id`() {
val user = repo.findById("unknown")
assertNull(user)
}
@AfterTest
fun teardown() {
repo.clear()
}
}These annotations — @BeforeTest, @Test, @AfterTest — are part of kotlin.test and map to platform equivalents at compile time.
Handling Platform Dependencies in Tests
Business logic often depends on platform implementations. Use expect/actual to inject test fakes:
// commonMain
expect class PlatformClock() {
fun now(): Long
}
// androidMain
actual class PlatformClock {
actual fun now(): Long = System.currentTimeMillis()
}
// iosMain
actual class PlatformClock {
actual fun now(): Long = NSDate().timeIntervalSince1970().toLong() * 1000
}In tests, provide a fake implementation:
// commonTest
class FakeClock(private val fixedTime: Long) : Clock {
override fun now(): Long = fixedTime
}
class SchedulerTest {
@Test
fun `schedules task in the future`() {
val clock = FakeClock(1_000_000L)
val scheduler = Scheduler(clock)
val task = scheduler.schedule(delay = 5_000L)
assertEquals(1_005_000L, task.runAt)
}
}Testing Coroutines Across Platforms
Coroutines work in KMP, but coroutine testing requires care. Use runTest from kotlinx-coroutines-test:
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class DataSyncTest {
@Test
fun `syncs data on launch`() = runTest {
val fakeApi = FakeDataApi()
val sync = DataSync(fakeApi)
sync.syncNow()
assertEquals(1, fakeApi.callCount)
}
}runTest replaces the dispatcher with a test dispatcher, advancing virtual time automatically. It works on all KMP targets.
For more complex scenarios involving delays:
@Test
fun `retries on failure with backoff`() = runTest {
val fakeApi = FakeDataApi(failTimes = 2)
val sync = DataSync(fakeApi, retryDelayMs = 1_000L)
sync.syncNow()
advanceTimeBy(2_500L)
assertEquals(3, fakeApi.callCount) // initial + 2 retries
}Running Tests on Each Platform
From the command line:
# Run commonTest on JVM (fast, good for development)
./gradlew :shared:jvmTest
# Run on Android (emulator or device required)
./gradlew :shared:connectedAndroidTest
# Run on iOS simulator (macOS only)
./gradlew :shared:iosSimulatorArm64Test
# Run on all configured targets
./gradlew :shared:allTestsFor iOS tests specifically, Gradle invokes xcodebuild under the hood — you need Xcode installed and a simulator booted.
Dealing with Platform-Specific Failures
The most common pattern: code compiles fine on JVM but fails on Kotlin/Native (iOS) due to:
- Thread confinement — Kotlin/Native enforces strict object ownership rules (pre-1.7.20) or uses the new memory model
- Missing platform APIs —
java.util.*doesn't exist on iOS - Freezing — Objects passed between threads must be frozen on older K/N
Check your Kotlin and coroutines versions. Kotlin 1.7.20+ and kotlinx-coroutines-core 1.6.4+ use the new memory model, which eliminates most freezing issues.
Coverage Strategy
Aim for this distribution:
| Layer | Target Coverage | Where |
|---|---|---|
| Domain models | 90%+ | commonTest |
| Use cases / interactors | 85%+ | commonTest |
| Repository logic | 80%+ | commonTest |
| Platform adapters | 70%+ | Platform-specific test sets |
| UI | Manual + screenshot | Platform-specific |
The bulk of your logic should live in commonMain and be tested in commonTest. Platform-specific code should be thin adapters.
CI Configuration
On GitHub Actions:
jobs:
test-jvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- run: ./gradlew :shared:jvmTest
test-ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- run: ./gradlew :shared:iosSimulatorArm64TestiOS tests require macOS runners — they cost more. Run them on every PR, but consider running JVM tests on every commit (faster feedback).
End-to-End Validation with HelpMeTest
Unit tests cover business logic, but they won't catch integration failures — your API contract changing, your app failing to launch on a specific OS version, or a backend regression breaking your data sync.
HelpMeTest runs full browser and app-level tests against your live endpoints 24/7. Write tests in plain English, and HelpMeTest executes them on real devices at regular intervals. When something breaks in production — not in your unit test mocks — you get notified immediately.
This is especially valuable for KMP apps where the shared business logic is well-tested but the mobile UI layer and backend integration can drift over time.
Summary
Testing KMP effectively means:
- Put as much logic as possible in
commonMain— then it gets tested once and verified everywhere - Use
kotlin.testincommonTest— platform-agnostic, runs on all targets - Use
runTestfor coroutines — works across JVM, iOS, and JS - Use
expect/actualfor test fakes — inject platform dependencies cleanly - Run tests on real targets in CI — especially iOS, where Native behavior differs from JVM
- Complement with integration testing — unit tests don't catch production regressions
KMP's strength is code sharing. Your test strategy should reflect that — shared tests for shared logic, platform tests only where necessary.