kmp-testing-patterns
Skill15 starsChanged 22 days agoApache-2.0View on GitHub
Use when writing or fixing unit tests in a Kotlin Multiplatform project - commonTest, kotlin.test, runTest, StandardTestDispatcher, Dispatchers.setMain, Turbine, testing a ViewModel's StateFlow and one-shot effects, fakes versus mocks (MockK fails on iOS), Ktor MockEngine, Koin test overrides, or making new tests run the same on the JVM and on iOS.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- android-to-kmp-migrationSkill
- compose-design-system-componentSkill
- compose-multiplatform-localizationSkill
- compose-multiplatform-navigationSkill
- compose-multiplatform-screenSkill
- figma-to-mobile-uiSkill
- kmp-api-endpointSkill
- kmp-build-checkSkill
- kmp-build-failure-triageSkill
- kmp-ci-github-actionsSkill
- kmp-code-reviewSkill
- kmp-crash-triageSkill
- kmp-data-layerSkill
- kmp-dev-machine-cleanupSkill
- kmp-expect-actualSkill
- kmp-feature-moduleSkill
- kmp-flaky-test-triageSkill
- kmp-gradle-version-catalogSkill
- kmp-koin-diSkill
- kmp-module-healthSkill
- kmp-room-databaseSkill
- kmp-secure-storageSkill
- kmp-swiftui-interopSkill
- kmp-test-coverage-backfillSkill
- kmp-xcframework-distributionSkill
- mobile-accessibility-test-idsSkill
- mobile-bug-fixSkill
- mobile-feature-implementSkill
- mobile-feature-planSkill
- mobile-performance-fixSkill
- mobile-platform-paritySkill
- mobile-releaseSkill
- mobile-security-reviewSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 4 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 36
Before writing a test, read two or three existing tests and the class under test.
- Distinctive: line 54
Choose what to test: Test everything that has behaviour:
- Distinctive: line 61
Do not unit-test Composables, generated code, DI modules or plain DTOs by default.
- Distinctive: line 70
Build the test doubles: Prefer hand-written fakes: an implementation of the interface with settable results, call counters and recorded …
- Very specific: line 93
Never wait with delay() in the test body, Thread.sleep, or real-time polling.
- Very specific: line 115
Keep each test independent: Use Arrange-Act-Assert and one behaviour per test.
- Very specific: line 175
Run one class: ./gradlew :shared:jvmTest --tests "com.example.app.notes.NotesViewModelTest".
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
KMP testing patterns
Business logic in commonMain is tested once, in commonTest, and runs on every target: JVM,
Android host and the iOS simulator. A reliable test controls time and threads with test dispatchers,
subscribes to effects before it acts, asserts both state and effects, and uses hand-written fakes
that run on every target.
When to use
- Writing tests for a ViewModel, use case, repository, mapper, reducer or validator in a KMP module.
- A test you are writing hangs, misses an emission, or needs dispatcher control.
- Choosing between fakes and mocks, or setting up Turbine,
MockEngineor Koin for tests. - Deciding which Gradle task runs which tests.
When not to use
- Raising a module's coverage number in a loop: use
kmp-test-coverage-backfill, which applies these patterns. - Checking that the whole repo builds and passes on both platforms: use
kmp-build-check. - Locators for UI or end-to-end tests: use
mobile-accessibility-test-ids. - Diagnosing an existing test that passes and fails without code changes, or fails only in CI or
only on iOS: use
kmp-flaky-test-triage.
Step 0 - Read the project first
Before writing a test, read two or three existing tests and the class under test. Write in the project's idiom, not this skill's defaults.
| Find out | Where to look | Follow it |
| Test names | existing *Test.kt | default should_<expected>_when_<condition>; keep the project's own scheme if it has one |
| Assertion library | imports | kotlin.test, AssertK, Kotest assertions: whichever is there |
| Flow testing | imports of app.cash.turbine | Turbine if present; otherwise manual collection |
| Fakes | commonTest/**/Fake*.kt, *Fakes.kt, InMemory* in commonMain | reuse before writing new ones |
| Mocking | dev.mokkery, io.mockk in the build files | do not add a mocking library that is not already there |
| Dispatchers | a DispatcherProvider (or similar) on constructors | inject a test dispatcher through it |
| ViewModel base | ViewModel, or a project base such as MviViewModel<State, Effect> | test through its public state/effect API |
| DI | Koin, kotlin-inject, Metro, manual | construct directly in unit tests; use DI only for wiring tests |
| Source sets / tasks | build.gradle.kts, ./gradlew :m:tasks --all | see the Quick reference |
| Versions | gradle/libs.versions.toml | add test libraries there, never inline |
Workflow
Choose what to test. Test everything that has behaviour:
- ViewModels and state holders: every action, every resulting state, every emitted effect;
- use cases;
- repositories (mapping, caching, error handling);
- mappers (nullable and unknown input);
- reducers, validators and state machines.
Do not unit-test Composables, generated code, DI modules or plain DTOs by default. Put UI behaviour in a small number of Compose UI tests (see references/data-and-di-tests.md). Done when you have a list of behaviours, one test per behaviour.
- Put the test in the right source set.
commonTestfor anything incommonMain. UseandroidHostTest/androidUnitTest/jvmTest/iosTestonly foractualimplementations and platform code. Use the same package as the class under test, sointernalmembers are visible. - Build the test doubles. Prefer hand-written fakes: an implementation of the interface with
settable results, call counters and recorded arguments.
- Fakes run on every target. MockK and Mockito need the JVM, so they fail iOS test compilation.
- Mokkery is a KMP mocking compiler plugin. Use it only if the project already does.
- Reuse in-memory production implementations as fakes.
- Control coroutines.
- Wrap every suspend test in
runTest { }. Delays use virtual time, so a 30-second timeout finishes instantly. - Inject dispatchers through a
DispatcherProviderand pass test dispatchers that sharetestScheduler. AwithContext(Dispatchers.IO)hard-coded in production escapes the scheduler, soadvanceUntilIdle()cannot drive it. - Anything on
viewModelScopeorDispatchers.MainneedsDispatchers.setMain(testDispatcher)in@BeforeTestandDispatchers.resetMain()in@AfterTest. JVM tests have no Main dispatcher. In an iOS test binary, work posted to the main queue may never run. Pick the dispatcher on purpose:
Dispatcher Behaviour Use when StandardTestDispatcherqueues work until runCurrent()/advanceUntilIdle()/advanceTimeBy()you assert intermediate states or ordering UnconfinedTestDispatcherruns launched work eagerly the ViewModel loads in init, or for effect collectors- Never wait with
delay()in the test body,Thread.sleep, or real-time polling.
- Wrap every suspend test in
Subscribe to effects before you act. One-shot effects (navigate, toast, dialog) are lost or delayed when nobody is collecting. A
MutableSharedFlow(replay = 0)drops an emission that has no subscriber. Start the collector first:val effects = mutableListOf<NotesEffect>() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.effects.toList(effects) } viewModel.onAction(NotesAction.Open(id = "42")) // act AFTER subscribing advanceUntilIdle()Or, with Turbine:
turbineScope { val e = viewModel.effects.testIn(backgroundScope); ... }.backgroundScopeis cancelled when the test ends, so an endless collector does not hangrunTest.- Assert state and effects, every time. Compare the whole
UiStatewithassertEquals, so an unexpected field change fails the test. Assert the exact effect list,emptyList()included, so an extra navigation fails too. For an in-flight state (loading), hold the fake on aCompletableDeferredgate and callrunCurrent().StateFlowconflates, so without a gate a fast fake skips straight past loading. - Keep each test independent. Use Arrange-Act-Assert and one behaviour per test. Create fakes per
test or in
@BeforeTest. Reset every global in@AfterTest:Dispatchers.resetMain(),stopKoin(), and any singleton you configured. Tests must pass alone, in any order, and on every target. - Run the tests on the JVM and on iOS (Quick reference). A test that passes only on the JVM is not done.
A complete feature, with the ViewModel, fakes, dispatcher provider, seven tests and the Turbine
variants, is in references/viewmodel-test-worked-example.md.
MockEngine, repository, Koin, Mokkery and Compose UI test patterns are in
references/data-and-di-tests.md.
Patterns at a glance
@OptIn(ExperimentalCoroutinesApi::class)
class NotesViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val notesRepository = FakeNotesRepository()
@BeforeTest fun setUp() = Dispatchers.setMain(testDispatcher)
@AfterTest fun tearDown() = Dispatchers.resetMain()
@Test
fun should_keepNoteAndShowMessage_when_deleteFails() = runTest {
// Arrange
notesRepository.notes = listOf(note("1"))
notesRepository.deleteSucceeds = false
val viewModel = NotesViewModel(notesRepository, TestDispatcherProvider(testDispatcher))
viewModel.onAction(NotesAction.Refresh)
advanceUntilIdle()
val effects = mutableListOf<NotesEffect>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.effects.toList(effects) }
// Act
viewModel.onAction(NotesAction.Delete(id = "1"))
advanceUntilIdle()
// Assert: state AND effects
assertEquals(listOf(note("1")), viewModel.state.value.notes)
assertEquals(listOf<NotesEffect>(NotesEffect.ShowMessage("Could not delete the note")), effects)
}
}Quick reference
Which Gradle task runs which tests. Task names depend on the plugins, so confirm them with
./gradlew :<module>:tasks --all.
| Task | Runs | Notes |
:m:allTests | every target's tests, plus an aggregated report | includes iOS simulator tests on macOS |
:m:testDebugUnitTest | commonTest + androidUnitTest on the JVM | modules on com.android.library / application with androidTarget() |
:m:testAndroidHostTest | commonTest + androidHostTest on the JVM | modules on com.android.kotlin.multiplatform.library. Exists only after withHostTest {} in its android {} block; host tests are off by default. |
:m:jvmTest | commonTest + jvmTest | modules with a jvm() target |
:m:iosSimulatorArm64Test | commonTest + iosTest on the iOS simulator | Apple-silicon Macs; iosX64Test on Intel. Device targets cannot run tests on the Mac. |
:m:connectedAndroidTest (or connectedDebugAndroidTest) | device or emulator tests | needs a running emulator or device |
Run one class: ./gradlew :shared:jvmTest --tests "com.example.app.notes.NotesViewModelTest". The
same --tests filter works for the Android unit-test and iOS simulator test tasks.
| API | Use |
runTest { } | every suspend test; virtual time |
testScheduler, backgroundScope | inside runTest: share the scheduler; host endless collectors |
runCurrent() / advanceUntilIdle() / advanceTimeBy(ms) | run due tasks / run everything / move virtual time. After advanceTimeBy, call runCurrent() for tasks due exactly then. |
Dispatchers.setMain(d) / Dispatchers.resetMain() | replace Main for viewModelScope |
Turbine flow.test { awaitItem() } | assert a sequence of emissions; 3 s wall-clock timeout by default |
Turbine turbineScope { flow.testIn(backgroundScope) } | several flows at once (state plus effects) |
Ktor MockEngine { respond(...) } + engine.requestHistory | API-layer tests without a network |
startKoin { modules(prod, testOverrides) } / stopKoin() | wiring tests; the last module wins |
Verify
./gradlew :shared:<jvm test task> # testDebugUnitTest, testAndroidHostTest or jvmTest
./gradlew :shared:iosSimulatorArm64Test # macOS with Xcode only
./gradlew :shared:allTests # everything this host can runReport each task as PASS (exit 0), FAIL (quote the first failing test and its assertion) or NOT RUN, with the reason: for example "iOS tests NOT RUN: no Xcode on this machine". Never call new tests "passing" when only the JVM half ran.
Common mistakes
| Mistake | Why it bites | Fix |
No Dispatchers.setMain for a ViewModel test | JVM: "Module with the Main dispatcher is missing" (Android: "Method getMainLooper in android.os.Looper not mocked"). iOS: work posted to Main may never run. | setMain in @BeforeTest, resetMain in @AfterTest |
A new StandardTestDispatcher() per collaborator | Several schedulers; advanceUntilIdle() drives only one | One dispatcher, or StandardTestDispatcher(testScheduler) |
Dispatchers.IO hard-coded in production | Work escapes virtual time; the test is flaky or slow | Inject a DispatcherProvider |
| Triggering the action, then collecting effects | The emission is gone (SharedFlow) or the test races (Channel) | Collect in backgroundScope first, or Turbine testIn |
| Asserting state only | A wrong navigation or toast goes unnoticed | Assert the exact effect list too |
| Asserting a loading state with a fast fake | StateFlow conflates; the loading state is never seen | Hold the fake on a CompletableDeferred, then runCurrent() |
MockK or Mockito in commonTest | JVM-only; iOS test compilation fails | Hand-written fakes (or Mokkery if adopted) |
advanceUntilIdle() with a ticker or polling loop | The loop reschedules forever; the test hangs | advanceTimeBy(n) then runCurrent() |
Asserting HashMap / HashSet iteration order | The order is unspecified and differs between JVM and Native | Compare as sets or maps, or sort first |
| Tests that depend on the system time zone, locale or clock | Pass locally, fail in CI or on the simulator | Inject a clock; use fixed time zones |
| A test MockEngine client built by hand | It tests different plugins and config than production uses | Use the production client factory with the mock engine |
Forgetting stopKoin() | The next startKoin throws; definitions leak between tests | stopKoin() in @AfterTest |
| Only JVM tests run in CI | iOS-only failures ship | Run iosSimulatorArm64Test on a macOS runner |
Related skills
kmp-test-coverage-backfill: apply these patterns in a measured loop to reach a coverage target.kmp-build-check: run the tests on both platforms, with PASS / FAIL / NOT RUN reporting.mobile-accessibility-test-ids: stabletestTags for Compose UI tests and end-to-end tests.kmp-flaky-test-triage: when an existing test is flaky or fails only on one platform.compose-multiplatform-screenandkmp-data-layer: the production shapes these tests target.- cashapp/turbine: Flow testing library.
- skydoves/android-testing-skills: Android testing skills.
- chrisbanes/skills: Kotlin coroutines/Flow and Compose skills, including Compose UI testing.
- AvdLee/Swift-Testing-Agent-Skill: Swift Testing for the iOS side.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

