← All shorts
51Kotlin 1.9+2 min read

Testing Shared KMP Code Across Targets

kotlin.test gives commonTest one assertion API that runs as JUnit on Android and as a native binary on iOS.

Shared code is worth exactly as much as your confidence in it. OrderRepository ships to Android and iOS, so it has to be proven on both. kotlin.test, plus the multiplatform kotlinx-coroutines-test artifact, lets one test class in commonTest run against every target's own test runner.

#The old way

OrderRepositoryTest existed twice. A JUnit test on Android, a separate XCTest suite on iOS, each hand-writing its own fake OrdersApi and asserting the same mapping behavior.

So a regression in the shared logic could pass on one platform's suite and fail on the other's. Or worse, get duplicated incorrectly on both.

#The new way

interface OrdersApi {
    suspend fun fetchOrders(): List<Order>
}

data class Order(val id: String, val status: String, val totalCents: Int)

class OrderRepository(private val api: OrdersApi) {
    suspend fun fetchOrders(): List<Order> = api.fetchOrders()
}

class FakeOrdersApi : OrdersApi {
    var ordersToReturn: List<Order> = emptyList()
    override suspend fun fetchOrders(): List<Order> = ordersToReturn
}

class OrderRepositoryTest {
    @Test
    fun fetchOrdersReturnsSeededResults() = runTest {
        val fakeApi = FakeOrdersApi().apply {
            ordersToReturn = listOf(Order(id = "1", status = "PAID", totalCents = 4200))
        }
        val repository = OrderRepository(fakeApi)

        val result = repository.fetchOrders()

        assertEquals(1, result.size)
        assertEquals("PAID", result.first().status)
    }
}
sourceSets {
    commonTest.dependencies {
        implementation(kotlin("test"))
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
    }
}
./gradlew allTests

allTests does both jobs from that one class. For androidUnitTest it runs OrderRepositoryTest on the JVM via JUnit; for each iosSimulatorArm64Test-style target it compiles the same code to a native binary that executes directly.

#Why it matters

  • One test class validates the mapping logic on every platform it actually ships to
  • Native targets catch Kotlin/Native-specific bugs (initialization order, native memory issues) that a JVM-only suite can't see
  • Fakes like FakeOrdersApi live in commonTest too, so test doubles don't fork the way production code used to

#Gotcha

runTest fast-forwards virtual time for any delay() inside the coroutines it controls. Outside that scope it does nothing. Thread.sleep on the JVM side, or a platform timer reached through native interop, still blocks for real.

A test that mixes the two doesn't fail outright. It runs slower and less predictably than the virtual-time parts suggest, which is easy to misdiagnose as flakiness in the production code instead of the test itself.

kmpkotlincross-platform

Related shorts