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. 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
<span class="hljs-comment"># Run on Android (emulator or device required)
./gradlew :shared:connectedAndroidTest
<span class="hljs-comment"># Run on iOS simulator (macOS only)
./gradlew :shared:iosSimulatorArm64Test
<span class="hljs-comment"># 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.