Testing KMP Applications with Koin: Strategies That Actually Work
Dependency Injection in Kotlin Multiplatform with Koin Part 7 of 8A well-structured KMP test suite might run hundreds of tests in under 30 seconds. Most run on JVM for speed. A subset runs on iOS simulator to catch platform-specific issues. And every PR triggers a verification check that catches misconfigured Koin modules before they reach production.
Achieving this requires understanding what doesn't work in KMP testing. Mocking libraries that only run on JVM. Test setups that leak state between tests. Verification approaches that miss actual runtime issues.
This article covers patterns that work well in production KMP apps.
The KMP Testing Reality
Testing in KMP is harder than single-platform testing because your code runs on multiple runtimes:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Where Your Tests Run β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β commonTest (JVM) β Fast, most tests here β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β’ Unit tests β Hundreds of tests, runs quickly β
β β’ Integration tests β Real DB (in-memory), fake network β
β β’ ViewModel tests β Full coroutine testing support β
β β
β androidTest β Android-specific only β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β’ Platform APIs β Few tests, requires emulator β
β β’ Context-dependent β SharedPreferences, SQLite driver β
β β
β iosTest β iOS-specific only β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β’ Platform APIs β Few tests, requires simulator β
β β’ NSUserDefaults β Keychain, Darwin networking β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Recommended distribution: ~98% commonTest, ~2% platform testsThe key insight: most tests should run in commonTest. Only test platform-specific code in platform test source sets. This keeps your test suite fast and catches most bugs without device/simulator overhead.
Test Module Architecture
A well-organized approach structures test modules in layers, similar to production modules:
// commonTest/kotlin/com/budgettracker/di/TestModules.kt
package com.budgettracker.di
import org.koin.dsl.module
/**
* Base test module - provides core test infrastructure.
* Always include this in test setups.
*/
val baseTestModule = module {
// Test dispatchers - use Unconfined for synchronous testing
single<CoroutineDispatcher>(named("IO")) { UnconfinedTestDispatcher() }
single<CoroutineDispatcher>(named("Main")) { UnconfinedTestDispatcher() }
single<CoroutineDispatcher>(named("Default")) { UnconfinedTestDispatcher() }
// Test clock for time-dependent tests
single<Clock> { FakeClock() }
// Test logger that captures logs for assertions
single<Logger> { TestLogger() }
}
/**
* Network test module - fake API responses.
*/
val networkTestModule = module {
single<HttpClientEngine> {
MockEngine { request ->
// Default: return empty success
respond(
content = "{}",
status = HttpStatusCode.OK,
headers = headersOf("Content-Type" to listOf("application/json"))
)
}
}
single { FakeTransactionApi() }
single { FakeUserApi() }
single { FakeBudgetApi() }
}
/**
* Database test module - in-memory database.
*/
val databaseTestModule = module {
single<SqlDriver> {
JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).also { driver ->
BudgetTrackerDatabase.Schema.create(driver)
}
}
single { BudgetTrackerDatabase(get()) }
}
/**
* Repository test module - real implementations with fake dependencies.
*/
val repositoryTestModule = module {
single<TransactionRepository> {
TransactionRepositoryImpl(
api = get(),
database = get(),
dispatcher = get(named("IO"))
)
}
single<UserRepository> {
UserRepositoryImpl(
api = get(),
secureStorage = get()
)
}
}
/**
* Full test configuration.
*/
fun testModules() = listOf(
baseTestModule,
networkTestModule,
databaseTestModule,
repositoryTestModule
)The layered structure lets you mix and match:
- Unit tests:
baseTestModule+ direct fake injection - Integration tests:
baseTestModule+databaseTestModule+ real repositories - Full stack tests: All modules with controlled fake network
Isolated Test Base Class
The biggest testing mistake: tests that share global Koin state. Test A modifies Koin, test B fails because it sees A's state. Debugging these flaky tests wastes hours.
The solution is an isolated test base class:
// commonTest/kotlin/com/budgettracker/testing/IsolatedKoinTest.kt
package com.budgettracker.testing
import org.koin.core.Koin
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.core.module.Module
import org.koin.dsl.koinApplication
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
/**
* Base class for tests that need Koin isolation.
*
* Each test class gets its own Koin instance that doesn't affect other tests.
* Tests can run in parallel safely.
*/
abstract class IsolatedKoinTest {
private lateinit var koinApp: org.koin.core.KoinApplication
protected val testKoin: Koin get() = koinApp.koin
@BeforeTest
fun setupKoin() {
// Create isolated Koin instance (not global)
koinApp = koinApplication {
modules(provideTestModules())
}
}
@AfterTest
fun teardownKoin() {
koinApp.close()
}
/**
* Override to provide custom modules for specific test class.
*/
open fun provideTestModules(): List<Module> = testModules()
/**
* Get dependency from test's isolated Koin.
*/
inline fun <reified T : Any> get(): T = testKoin.get()
inline fun <reified T : Any> get(qualifier: org.koin.core.qualifier.Qualifier): T =
testKoin.get(qualifier)
/**
* Declare a dependency in test Koin (useful for per-test fakes).
*/
inline fun <reified T : Any> declare(instance: T) {
testKoin.declare(instance)
}
}Usage:
class TransactionRepositoryTest : IsolatedKoinTest() {
@Test
fun `fetches transactions from API on first call`() = runTest {
val fakeApi = get<FakeTransactionApi>()
fakeApi.nextResponse = listOf(
Transaction("1", "Coffee", -4.50),
Transaction("2", "Salary", 3000.00)
)
val repository = get<TransactionRepository>()
val transactions = repository.getTransactions()
assertEquals(2, transactions.size)
assertEquals("Coffee", transactions[0].description)
}
@Test
fun `caches transactions after first fetch`() = runTest {
val fakeApi = get<FakeTransactionApi>()
fakeApi.nextResponse = listOf(Transaction("1", "Test", 10.0))
val repository = get<TransactionRepository>()
repository.getTransactions()
repository.getTransactions() // Second call
assertEquals(1, fakeApi.fetchCount, "Should only hit API once")
}
}Each test method runs with fresh state. No test pollution.
Fakes Over Mocks
Fakes work better than mocks in KMP. Here's why:
Mocks (MockK, Mockito):
- Usually JVM-only
- Require framework knowledge
- Test implementation details ("verify X was called")
- Generated at runtime via reflection
Fakes:
- Pure Kotlinβrun everywhere
- Self-documenting
- Test behavior, not implementation
- Easy to debug (it's just code you wrote)
Here's a well-designed fake:
// commonTest/kotlin/com/budgettracker/fakes/FakeTransactionApi.kt
package com.budgettracker.fakes
import com.budgettracker.data.api.TransactionApi
import com.budgettracker.domain.model.Transaction
/**
* Fake implementation of TransactionApi for testing.
*
* Tracks calls and lets tests configure responses.
*/
class FakeTransactionApi : TransactionApi {
// Configurable responses
var nextResponse: List<Transaction> = emptyList()
var nextError: Exception? = null
var responseDelay: Long = 0L
// Call tracking
var fetchCount = 0
private set
var lastFetchParams: FetchParams? = null
private set
data class FetchParams(
val startDate: String?,
val endDate: String?,
val limit: Int
)
override suspend fun getTransactions(
startDate: String?,
endDate: String?,
limit: Int
): List<Transaction> {
fetchCount++
lastFetchParams = FetchParams(startDate, endDate, limit)
if (responseDelay > 0) {
delay(responseDelay)
}
nextError?.let { throw it }
return nextResponse
}
// Test helper methods
fun reset() {
nextResponse = emptyList()
nextError = null
responseDelay = 0L
fetchCount = 0
lastFetchParams = null
}
fun givenTransactions(vararg transactions: Transaction) {
nextResponse = transactions.toList()
}
fun givenNetworkError() {
nextError = IOException("Network unavailable")
}
fun givenServerError() {
nextError = HttpException(500, "Internal Server Error")
}
}Tests read naturally:
@Test
fun `shows error state when network fails`() = runTest {
val fakeApi = get<FakeTransactionApi>()
fakeApi.givenNetworkError()
val viewModel = TransactionListViewModel(get())
viewModel.loadTransactions()
advanceUntilIdle()
assertTrue(viewModel.state.value is TransactionListState.Error)
}The Fake Builder Pattern
For complex fakes with many configuration options, use the builder pattern:
// commonTest/kotlin/com/budgettracker/fakes/FakeTransactionApiBuilder.kt
class FakeTransactionApiBuilder {
private val transactions = mutableListOf<Transaction>()
private var error: Exception? = null
private var delay: Long = 0L
private val errorsByQuery = mutableMapOf<String, Exception>()
fun withTransaction(
id: String = UUID.randomUUID().toString(),
description: String,
amount: Double,
date: String = "2024-01-15"
) = apply {
transactions.add(Transaction(id, description, amount, date))
}
fun withTransactions(vararg items: Pair<String, Double>) = apply {
items.forEach { (desc, amount) ->
withTransaction(description = desc, amount = amount)
}
}
fun withError(exception: Exception) = apply {
error = exception
}
fun withNetworkError() = withError(IOException("Network unavailable"))
fun withDelay(millis: Long) = apply {
delay = millis
}
fun withErrorForQuery(query: String, exception: Exception) = apply {
errorsByQuery[query] = exception
}
fun build(): FakeTransactionApi {
return FakeTransactionApi().apply {
nextResponse = transactions.toList()
nextError = error
responseDelay = delay
}
}
}
// Extension for cleaner test setup
fun fakeTransactionApi(block: FakeTransactionApiBuilder.() -> Unit): FakeTransactionApi {
return FakeTransactionApiBuilder().apply(block).build()
}Tests become expressive:
@Test
fun `displays transactions sorted by date`() = runTest {
val fakeApi = fakeTransactionApi {
withTransaction(description = "Coffee", amount = -4.50, date = "2024-01-15")
withTransaction(description = "Lunch", amount = -12.00, date = "2024-01-14")
withTransaction(description = "Salary", amount = 3000.00, date = "2024-01-01")
}
declare(fakeApi) // Override default fake in Koin
val viewModel = TransactionListViewModel(get(), get())
viewModel.loadTransactions()
advanceUntilIdle()
val state = viewModel.state.value as TransactionListState.Success
assertEquals("Salary", state.transactions[0].description) // Oldest first
}Testing ViewModels
ViewModels need special handling because they use coroutines and expose state over time.
// commonTest/kotlin/com/budgettracker/feature/transaction/list/TransactionListViewModelTest.kt
class TransactionListViewModelTest : IsolatedKoinTest() {
override fun provideTestModules() = listOf(
baseTestModule,
module {
single { FakeTransactionApi() }
single { FakeTransactionRepository(get()) }
factory { GetTransactionsUseCase(get()) }
}
)
@Test
fun `initial state is Loading`() {
val viewModel = TransactionListViewModel(get())
assertEquals(TransactionListState.Loading, viewModel.state.value)
}
@Test
fun `loads transactions on init`() = runTest {
val fakeApi = get<FakeTransactionApi>()
fakeApi.givenTransactions(
Transaction("1", "Coffee", -4.50),
Transaction("2", "Groceries", -85.00)
)
val viewModel = TransactionListViewModel(get())
viewModel.loadTransactions()
// Wait for all coroutines to complete
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state is TransactionListState.Success)
assertEquals(2, (state as TransactionListState.Success).transactions.size)
}
@Test
fun `state transitions correctly during load`() = runTest {
val fakeApi = get<FakeTransactionApi>()
fakeApi.responseDelay = 100L
fakeApi.givenTransactions(Transaction("1", "Test", 10.0))
val viewModel = TransactionListViewModel(get())
val states = mutableListOf<TransactionListState>()
// Collect all state emissions
val job = launch(UnconfinedTestDispatcher()) {
viewModel.state.toList(states)
}
viewModel.loadTransactions()
advanceTimeBy(50) // Mid-loading
advanceUntilIdle() // Complete
job.cancel()
// Verify state progression
assertEquals(3, states.size)
assertTrue(states[0] is TransactionListState.Loading)
assertTrue(states[1] is TransactionListState.Loading) // Still loading at 50ms
assertTrue(states[2] is TransactionListState.Success)
}
@Test
fun `handles error gracefully`() = runTest {
val fakeApi = get<FakeTransactionApi>()
fakeApi.givenNetworkError()
val viewModel = TransactionListViewModel(get())
viewModel.loadTransactions()
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state is TransactionListState.Error)
assertTrue((state as TransactionListState.Error).message.contains("network"))
}
@Test
fun `refresh clears cache and reloads`() = runTest {
val fakeRepo = get<FakeTransactionRepository>()
fakeRepo.givenCachedTransactions(Transaction("1", "Old", 10.0))
fakeRepo.givenFreshTransactions(Transaction("2", "New", 20.0))
val viewModel = TransactionListViewModel(get())
viewModel.loadTransactions()
advanceUntilIdle()
// First load returns cached
var state = viewModel.state.value as TransactionListState.Success
assertEquals("Old", state.transactions[0].description)
// Refresh gets fresh data
viewModel.refresh()
advanceUntilIdle()
state = viewModel.state.value as TransactionListState.Success
assertEquals("New", state.transactions[0].description)
assertTrue(fakeRepo.cacheWasCleared)
}
}Integration Tests with Real Dependencies
Some tests need real implementations to verify correct integration:
// commonTest/kotlin/com/budgettracker/integration/SyncIntegrationTest.kt
class SyncIntegrationTest : IsolatedKoinTest() {
override fun provideTestModules() = listOf(
baseTestModule,
networkTestModule, // Fake network
databaseTestModule, // Real in-memory database
module {
// Real repositories with fake network
single<TransactionRepository> {
TransactionRepositoryImpl(
api = get<FakeTransactionApi>(),
database = get(),
dispatcher = get(named("IO"))
)
}
single { SyncManager(get(), get()) }
}
)
@Test
fun `sync persists transactions to database`() = runTest {
// Setup: API returns transactions
val fakeApi = get<FakeTransactionApi>()
fakeApi.givenTransactions(
Transaction("1", "Coffee", -4.50),
Transaction("2", "Lunch", -12.00)
)
// Act: Run sync
val syncManager = get<SyncManager>()
syncManager.sync()
advanceUntilIdle()
// Assert: Data is in database
val database = get<BudgetTrackerDatabase>()
val stored = database.transactionQueries.selectAll().executeAsList()
assertEquals(2, stored.size)
assertEquals("Coffee", stored[0].description)
}
@Test
fun `sync failure preserves existing data`() = runTest {
// Setup: Existing data in database
val database = get<BudgetTrackerDatabase>()
database.transactionQueries.insert("existing", "Existing", -10.0, "2024-01-01")
// Setup: API fails
val fakeApi = get<FakeTransactionApi>()
fakeApi.givenNetworkError()
// Act: Sync fails
val syncManager = get<SyncManager>()
val result = syncManager.sync()
advanceUntilIdle()
// Assert: Existing data preserved
assertTrue(result.isFailure)
val stored = database.transactionQueries.selectAll().executeAsList()
assertEquals(1, stored.size)
assertEquals("Existing", stored[0].description)
}
}Module Verification
Koin's verify() API catches misconfigured modules at test time:
// commonTest/kotlin/com/budgettracker/di/KoinModuleVerificationTest.kt
class KoinModuleVerificationTest {
@Test
fun `all production modules verify successfully`() {
// Verify each module can resolve its dependencies
coreModule.verify(
extraTypes = listOf(
PlatformDependencies::class,
Settings::class,
SqlDriver::class,
HttpClientEngine::class
)
)
}
@Test
fun `network module verifies`() {
networkModule.verify(
extraTypes = listOf(
HttpClientEngine::class,
AppInfo::class
)
)
}
@Test
fun `data module verifies`() {
dataModule.verify(
extraTypes = listOf(
SqlDriver::class,
Settings::class,
SecureStorage::class
)
)
}
@Test
fun `complete module graph verifies`() {
// Test all modules together
koinApplication {
modules(
testPlatformModule, // Provides platform deps for verification
networkModule,
dataModule,
coreModule
)
}.also {
// Verify the complete graph
it.koin.get<TransactionRepository>()
it.koin.get<UserRepository>()
it.koin.get<GetTransactionsUseCase>()
it.close()
}
}
}Using Annotations for Cleaner Verification (Koin 4.x)
For definitions with injected parameters, Koin 4.x introduces @InjectedParam and @Provided annotations to simplify verification. Instead of complex DSL configuration, these annotations help Koin understand injection contracts:
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.Provided
// Mark constructor parameter as injected at call site
class ComponentB(@InjectedParam val a: ComponentA)
// Mark constructor parameter as dynamically provided
class ComponentBProvided(@Provided val a: ComponentA)Add verification to your CI pipeline:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
verify-koin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Verify Koin Modules
run: ./gradlew :shared:jvmTest --tests "*KoinModuleVerificationTest*"
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Run JVM Tests
run: ./gradlew :shared:jvmTest
ios-tests:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Run iOS Tests
run: ./gradlew :shared:iosSimulatorArm64TestTest Organization
A recommended test structure:
shared/src/
βββ commonTest/kotlin/com/budgettracker/
β βββ di/
β β βββ TestModules.kt # Test module definitions
β β βββ KoinModuleVerificationTest.kt
β βββ fakes/
β β βββ FakeTransactionApi.kt
β β βββ FakeUserApi.kt
β β βββ FakeSecureStorage.kt
β β βββ FakeClock.kt
β βββ testing/
β β βββ IsolatedKoinTest.kt # Base test class
β β βββ TestExtensions.kt # Test utilities
β βββ unit/
β β βββ repository/
β β β βββ TransactionRepositoryTest.kt
β β βββ usecase/
β β β βββ GetTransactionsUseCaseTest.kt
β β βββ viewmodel/
β β βββ TransactionListViewModelTest.kt
β βββ integration/
β βββ SyncIntegrationTest.kt
βββ androidTest/kotlin/com/budgettracker/
β βββ platform/
β βββ AndroidSecureStorageTest.kt
βββ iosTest/kotlin/com/budgettracker/
βββ platform/
βββ IosSecureStorageTest.ktCommon Testing Mistakes
Mistake 1: Not Cleaning Up Koin
// BAD: Koin state leaks to next test
class BadTest {
@Test
fun test1() {
startKoin { modules(testModule) }
// No stopKoin()!
}
@Test
fun test2() {
startKoin { modules(testModule) } // Crashes: Koin already started
}
}
// GOOD: Use IsolatedKoinTest or manual cleanup
class GoodTest : IsolatedKoinTest() {
@Test
fun test1() {
// Automatic setup/teardown
}
}Mistake 2: Testing Implementation, Not Behavior
// BAD: Testing internal calls
@Test
fun `test calls repository`() {
viewModel.loadData()
verify { repository.getData() } // Who cares HOW it works?
}
// GOOD: Testing observable outcome
@Test
fun `loads data successfully`() {
fakeRepo.givenData(testData)
viewModel.loadData()
assertEquals(testData, viewModel.state.value.data)
}Mistake 3: Forgetting advanceUntilIdle
// BAD: Assertion runs before coroutine completes
@Test
fun `async test`() = runTest {
viewModel.loadData()
// Coroutine hasn't completed yet!
assertNotNull(viewModel.data.value) // Fails randomly
}
// GOOD: Wait for coroutines
@Test
fun `async test`() = runTest {
viewModel.loadData()
advanceUntilIdle() // Wait for completion
assertNotNull(viewModel.data.value)
}Summary: Testing Checklist
- Use
IsolatedKoinTestfor test isolation - Prefer fakes over mocks for cross-platform compatibility
- Test behavior, not implementation - assert on state, not calls
- Use
advanceUntilIdle()for coroutine tests - Add module verification tests - catch misconfigurations early
- Run most tests in
commonTest- keep feedback loop fast - Reserve platform tests for platform-specific code only
What's Next
We can write and test our code. But what happens when it runs in production at scale?
In the final article, we'll cover production patterns: lazy module loading for faster startup, debugging dependency issues in production, health monitoring, and incident response patterns that can save your team from 3 AM debugging sessions.