Kotlin Multiplatform Testing Guide: Shared Code Testing Strategies

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 tests

The 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:allTests

For 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:

  1. Thread confinement — Kotlin/Native enforces strict object ownership rules (pre-1.7.20) or uses the new memory model
  2. Missing platform APIsjava.util.* doesn't exist on iOS
  3. 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:iosSimulatorArm64Test

iOS 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:

  1. Put as much logic as possible in commonMain — then it gets tested once and verified everywhere
  2. Use kotlin.test in commonTest — platform-agnostic, runs on all targets
  3. Use runTest for coroutines — works across JVM, iOS, and JS
  4. Use expect/actual for test fakes — inject platform dependencies cleanly
  5. Run tests on real targets in CI — especially iOS, where Native behavior differs from JVM
  6. 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.

Read more