| name | kmp-testing |
| description | Testing strategy for KMP — `commonTest` with `kotlin.test`, `expect`/`actual` test doubles, Turbine for Flow assertions, Ktor `MockEngine`, and in-memory SQLDelight drivers. Use when writing tests for shared code. |
KMP Testing
Instructions
Write tests once in commonTest; they run automatically on every target your commonMain compiles to. Platform-specific tests stay in androidUnitTest, iosTest, jvmTest.
1. Dependencies
kotlin.sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.ktor.client.mock)
}
androidUnitTest.dependencies {
implementation(libs.robolectric)
}
}
2. A common unit test
class DiscountTest {
@Test
fun tenPercentOffOneHundred() {
val d = Discount("WELCOME10", 10)
assertEquals(9_000L, d.apply(cents = 10_000L))
}
@Test
fun zeroDiscountIsNoOp() {
assertEquals(10_000L, Discount("NONE", 0).apply(10_000L))
}
}
Run on every target: ./gradlew allTests. Or target one: ./gradlew :shared:iosSimulatorArm64Test.
3. Coroutines & Flows with Turbine
@Test
fun feedEmitsCachedThenFresh() = runTest {
val api = fakeApi(listOf(remoteArticle))
val dao = inMemoryDao().apply { upsertAll(listOf(cachedArticle)) }
val repo = ArticleRepositoryImpl(api, dao, clock = TestClock)
repo.observeLatest().test {
assertEquals(listOf(cachedArticle), awaitItem())
repo.refresh()
assertEquals(listOf(remoteArticle, cachedArticle), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
runTest replaces runBlocking for coroutines tests; it skips delays and advances the virtual clock.
- Turbine's
.test { } block consumes the flow deterministically.
4. HTTP with Ktor MockEngine
@Test
fun articleApiParsesList() = runTest {
val engine = MockEngine { request ->
assertEquals("/articles", request.url.encodedPath)
assertEquals("1", request.url.parameters["page"])
respond(
content = """{"items":[{"id":1,"title":"Hi"}],"nextPage":null}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
expectSuccess = true
}
val api = ArticleApi(client)
val page = api.latest(page = 1)
assertEquals(1L, page.items.single().id)
}
5. In-memory SQLDelight
expect fun inMemoryDriver(): SqlDriver
actual fun inMemoryDriver(): SqlDriver =
AndroidSqliteDriver(AppDatabase.Schema.synchronous(),
ApplicationProvider.getApplicationContext(), name = null)
actual fun inMemoryDriver(): SqlDriver =
NativeSqliteDriver(AppDatabase.Schema.synchronous(), name = ":memory:")
actual fun inMemoryDriver(): SqlDriver =
JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).also { AppDatabase.Schema.synchronous().create(it) }
6. Clock & randomness
Inject, never call global Clock.System.now() directly:
class ArticleRepositoryImpl(
private val api: ArticleApi,
private val dao: ArticleDao,
private val clock: Clock = Clock.System,
)
object TestClock : Clock {
var current: Instant = Instant.parse("2026-01-01T00:00:00Z")
override fun now(): Instant = current
}
7. Platform-only tests
Code that touches Context, NSUserDefaults, or file paths belongs in the platform test source set:
@RunWith(AndroidJUnit4::class)
class AndroidSecureStoreTest {
@Test fun persistsAcrossInstances() { }
}
class IosSecureStoreTest {
@Test fun roundTrip() {
val s = IosSecureStore()
s.putString("k", "v")
assertEquals("v", s.getString("k"))
}
}
8. Fakes vs mocks
Prefer hand-written fakes over reflective mocking libraries; reflection-based mocks don't work on Kotlin/Native. For interfaces, implement a tiny FakeFoo in commonTest:
class FakeArticleApi(private val items: List<ArticleDto>) : ArticleApi {
var refreshCount = 0
override suspend fun latest(page: Int) = PagedResponse(items, nextPage = null)
.also { refreshCount++ }
}
If you really need mocking, mokkery supports Kotlin/Native.
Checklist