KMP Unit Testing with kotlin.test and MockK

KMP Unit Testing with kotlin.test and MockK

Kotlin Multiplatform (KMP) brings a challenge that pure Android or pure iOS developers don't face: your unit tests need to run on multiple runtimes. The kotlin.test library and MockK together form the standard testing stack for KMP projects. Here's how to use them effectively.

The kotlin.test Library

kotlin.test is Kotlin's built-in multiplatform test framework. It provides:

  • @Test — marks a test function
  • @BeforeTest / @AfterTest — setup and teardown
  • @Ignore — skip a test
  • Assertion functions: assertEquals, assertNotNull, assertTrue, assertFails, assertFailsWith, etc.

On the JVM, kotlin.test delegates to JUnit 4 or JUnit 5 (depending on your configuration). On Kotlin/Native (iOS), it uses XCTest. On Kotlin/JS, it uses Jest or Mocha. You write the tests once; the platform handles execution.

Basic Setup

In your shared/build.gradle.kts:

kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "11"
            }
        }
    }
    iosX64()
    iosArm64()
    iosSimulatorArm64()

    sourceSets {
        commonTest.dependencies {
            implementation(kotlin("test"))
        }
    }
}

That's all you need for kotlin.test. No additional test runner configuration for commonTest.

Writing Your First Tests

Consider a simple domain class:

// commonMain
data class Money(val amount: Long, val currency: String) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) {
            "Cannot add $currency and ${other.currency}"
        }
        return Money(amount + other.amount, currency)
    }

    fun toDisplayString(): String = "$currency ${amount / 100}.${amount % 100}"
}

Tests in commonTest:

import kotlin.test.*

class MoneyTest {
    @Test
    fun `adds same currency amounts`() {
        val a = Money(1000, "USD")
        val b = Money(500, "USD")
        val result = a + b
        assertEquals(Money(1500, "USD"), result)
    }

    @Test
    fun `throws when adding different currencies`() {
        val usd = Money(1000, "USD")
        val eur = Money(500, "EUR")
        assertFailsWith<IllegalArgumentException> {
            usd + eur
        }
    }

    @Test
    fun `formats display string correctly`() {
        val money = Money(1099, "USD")
        assertEquals("USD 10.99", money.toDisplayString())
    }
}

MockK in KMP Projects

MockK is the standard mocking library for Kotlin. However, its KMP support has historically been limited — the mockk artifact only works on JVM/Android, not Kotlin/Native.

For KMP, you have two main options:

Option 1: mockk-common (JVM only, but cross-module)

If your tests only need to run on JVM/Android (acceptable for many teams), add MockK normally:

sourceSets {
    val androidUnitTest by getting {
        dependencies {
            implementation("io.mockk:mockk:1.13.8")
        }
    }
    val jvmTest by getting {
        dependencies {
            implementation("io.mockk:mockk:1.13.8")
        }
    }
}

Option 2: Interface fakes (all platforms)

The recommended approach for true multiplatform testing is to avoid mocking frameworks entirely and use hand-written fakes:

// commonMain
interface UserRepository {
    suspend fun findById(id: String): User?
    suspend fun save(user: User)
}

// commonTest — works on all platforms
class FakeUserRepository : UserRepository {
    private val users = mutableMapOf<String, User>()
    var saveCallCount = 0

    override suspend fun findById(id: String): User? = users[id]

    override suspend fun save(user: User) {
        users[user.id] = user
        saveCallCount++
    }
}

Then in tests:

class UserServiceTest {
    private val repo = FakeUserRepository()
    private val service = UserService(repo)

    @Test
    fun `creates user and persists it`() = runTest {
        service.createUser(name = "Alice", email = "alice@example.com")
        assertEquals(1, repo.saveCallCount)
    }

    @Test
    fun `returns existing user`() = runTest {
        val existing = User(id = "u1", name = "Bob", email = "bob@example.com")
        repo.save(existing)

        val found = service.getUser("u1")
        assertEquals("Bob", found?.name)
    }
}

This approach is more verbose but runs on every KMP target without any mocking framework dependency.

Using MockK on Android/JVM Specifically

When you need MockK's power (verifying call order, argument matchers, spy objects), restrict it to JVM/Android test source sets:

// androidUnitTest — NOT in commonTest
import io.mockk.*

class PaymentGatewayTest {
    private val gateway = mockk<PaymentGateway>()
    private val service = PaymentService(gateway)

    @Test
    fun `calls gateway with correct amount`() = runTest {
        coEvery { gateway.charge(any(), any()) } returns PaymentResult.Success

        service.processPayment(userId = "u1", amount = 5000L)

        coVerify { gateway.charge("u1", 5000L) }
    }

    @Test
    fun `handles gateway failure gracefully`() = runTest {
        coEvery { gateway.charge(any(), any()) } throws NetworkException("timeout")

        val result = service.processPayment(userId = "u1", amount = 5000L)

        assertTrue(result is PaymentResult.Failed)
    }
}

Coroutine Testing

Use runTest from kotlinx-coroutines-test:

commonTest.dependencies {
    implementation(kotlin("test"))
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class CacheTest {
    @Test
    fun `caches result after first load`() = runTest {
        var loadCount = 0
        val cache = Cache {
            loadCount++
            "data"
        }

        cache.get()
        cache.get()
        cache.get()

        assertEquals(1, loadCount, "Should load data only once")
    }
}

runTest automatically advances virtual time, so delays in your code don't slow down tests:

@Test
fun `expires cache after ttl`() = runTest {
    val cache = Cache(ttlMs = 60_000L) { "data" }
    cache.get()
    advanceTimeBy(61_000L)

    var loadCount = 0
    cache.get()
    assertEquals(2, loadCount, "Should reload after TTL expires")
}

Parameterized Tests

kotlin.test doesn't support parameterized tests natively. Work around it with loops or extension functions:

@Test
fun `validates various email formats`() {
    val validEmails = listOf(
        "user@example.com",
        "user+tag@example.org",
        "user.name@sub.domain.com"
    )
    val invalidEmails = listOf(
        "not-an-email",
        "@nodomain.com",
        "missingat.com"
    )

    validEmails.forEach { email ->
        assertTrue(
            EmailValidator.isValid(email),
            "Expected '$email' to be valid"
        )
    }

    invalidEmails.forEach { email ->
        assertFalse(
            EmailValidator.isValid(email),
            "Expected '$email' to be invalid"
        )
    }
}

Organizing Tests for Readability

Use Kotlin's backtick syntax for readable test names:

class OrderProcessorTest {
    @Test
    fun `places order when user has sufficient balance`() { ... }

    @Test
    fun `rejects order when balance is insufficient`() { ... }

    @Test
    fun `sends confirmation email after successful order`() { ... }

    @Test
    fun `rolls back on payment failure`() { ... }
}

Group related tests in nested classes — note that kotlin.test supports nested test classes via inner classes:

class UserAuthTest {
    class LoginTests {
        @Test
        fun `succeeds with valid credentials`() { ... }

        @Test
        fun `fails with wrong password`() { ... }
    }

    class LogoutTests {
        @Test
        fun `clears session on logout`() { ... }
    }
}

Running Tests

# JVM — fast, good for development iteration
./gradlew :shared:jvmTest

<span class="hljs-comment"># Android (needs emulator or device)
./gradlew :shared:connectedAndroidTest

<span class="hljs-comment"># iOS Simulator (macOS only)
./gradlew :shared:iosSimulatorArm64Test

<span class="hljs-comment"># All targets at once
./gradlew :shared:allTests

End-to-End Coverage with HelpMeTest

Unit tests with kotlin.test verify your shared business logic in isolation. They're fast and run locally. But they don't verify that your mobile app actually works end-to-end — that the login screen connects to the right endpoint, that data syncs correctly, or that the app handles network errors gracefully.

HelpMeTest fills that gap with continuous integration testing. Write test scenarios in plain English, and HelpMeTest runs them against your live app 24/7 — catching regressions that unit tests can't see.

Summary

  • kotlin.test is the cross-platform testing API — use it in commonTest for all shared code tests
  • MockK is JVM/Android only — use interface fakes for true multiplatform mocking
  • runTest handles coroutine tests on all platforms
  • Keep fakes simple and stateful — they're easier to debug than mock expectations
  • Run tests on real targets in CI — JVM behavior doesn't always match Kotlin/Native

The combination of kotlin.test for assertions and hand-crafted fakes for dependencies gives you fast, reliable, cross-platform unit tests with zero platform-specific tooling.

Read more