Kmp testing
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.From its SKILL.md
npx -y skills add almasumdev/awesome-kotlin-multiplatform-agent-skills --skill kmp-testingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.3 KB, ~1.3k tokens by cl100k_base, as published. Nobody here has run it
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
// shared/build.gradle.kts
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) // only if UI-adjacent code needs Looper
}
}
2. A common unit test
// src/commonTest/kotlin/com/example/pricing/DiscountTest.kt
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()) // cache hit first
repo.refresh()
assertEquals(listOf(remoteArticle, cachedArticle), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
runTestreplacesrunBlockingfor 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
// commonTest
expect fun inMemoryDriver(): SqlDriver
// androidUnitTest
actual fun inMemoryDriver(): SqlDriver =
AndroidSqliteDriver(AppDatabase.Schema.synchronous(),
ApplicationProvider.getApplicationContext(), name = null)
// iosTest
actual fun inMemoryDriver(): SqlDriver =
NativeSqliteDriver(AppDatabase.Schema.synchronous(), name = ":memory:")
// jvmTest
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:
// src/androidUnitTest/kotlin/com/example/AndroidSecureStoreTest.kt
@RunWith(AndroidJUnit4::class)
class AndroidSecureStoreTest {
@Test fun persistsAcrossInstances() { /* … */ }
}
// src/iosTest/kotlin/com/example/IosSecureStoreTest.kt
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
- Business logic tested in
commonTest— not duplicated per platform. -
runTest+ Turbine used for coroutine/flow assertions. - Ktor
MockEnginecovers success + every error branch. -
expect fun inMemoryDriver()with actuals in every target. -
Clock/Random/ UUID providers are injectable; no global singletons in tests. - Platform-only behavior (Context, Keychain, file paths) covered in
androidUnitTest/iosTest. -
./gradlew allTestsgreen on every target in CI.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.