Testing KMP Applications with Koin: Strategies That Actually Work

πŸ“š Dependency Injection in Kotlin Multiplatform with Koin Part 7 of 8

A 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 tests

The 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:

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):

Fakes:

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:iosSimulatorArm64Test

Test 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.kt

Common 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

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.