Koin Testing Guide: koin-test, Module Declarations in Tests, checkModules & Mock Injection

Koin Testing Guide: koin-test, Module Declarations in Tests, checkModules & Mock Injection

Koin is the lightweight dependency injection framework that dominates Android Kotlin projects. Unlike Dagger/Hilt which uses code generation, Koin operates at runtime—which means misconfigured modules fail at startup rather than compile time. Testing your Koin setup properly catches these failures before they hit production.

Why Koin Testing Matters

Koin's runtime nature is both its strength and its risk:

  • No code generation means faster build times
  • Runtime resolution means misconfigured modules crash the app at launch, not at build time
  • Module overriding is trivial for testing—just provide a test module with the same bindings

Testing covers three concerns: verifying the DI graph is complete (checkModules), injecting test doubles in unit tests, and validating Koin initialization in integration tests.

Setting Up koin-test

// build.gradle.kts
dependencies {
    testImplementation("io.insert-koin:koin-test:$koin_version")
    testImplementation("io.insert-koin:koin-test-junit4:$koin_version")
    testImplementation("io.insert-koin:koin-test-junit5:$koin_version") // If using JUnit5
    testImplementation("io.insert-koin:koin-android-test:$koin_version")
    testImplementation("io.mockk:mockk:$mockk_version")
}

Verifying Module Declarations with checkModules

checkModules() is Koin's built-in DI graph validator—it creates every registered definition and verifies all dependencies can be resolved:

class KoinModuleVerificationTest : KoinTest {

    @Test
    fun verifyProductionModules() {
        // Verifies all modules can instantiate their definitions
        checkModules {
            modules(
                networkModule,
                repositoryModule,
                viewModelModule,
                analyticsModule
            )
        }
    }
}

What checkModules Catches

// This module has a bug: OrderRepository depends on UserRepository,
// but UserRepository isn't declared
val brokenModule = module {
    single<OrderRepository> { OrderRepositoryImpl(get()) }  // UserRepository missing!
}

@Test
fun shouldCatchMissingDependency() {
    assertThrows<NoBeanDefFoundException> {
        checkModules {
            modules(brokenModule)
        }
    }
}

Providing Test Parameters for checkModules

Some definitions require runtime parameters (values, external objects). Provide them in checkModules:

val featureModule = module {
    // Requires an API base URL parameter
    single { ApiClient(baseUrl = getProperty("API_BASE_URL")) }
    factory<UserRepository> { (userId: String) -> UserRepositoryImpl(userId, get()) }
}

@Test
fun verifyModulesWithParameters() {
    checkModules {
        properties("API_BASE_URL" to "https://api.test.example.com")

        // Provide factory parameters
        withParameter<UserRepository> { "test-user-id" }

        modules(featureModule)
    }
}

Module Declarations in Tests

For unit tests, declare test-specific Koin modules that override or replace production definitions:

Test Module Pattern

@RunWith(AndroidJUnit4::class)
class UserProfileViewModelTest : KoinTest {

    private val mockRepository = mockk<UserRepository>()
    private val mockAnalytics = mockk<AnalyticsService>(relaxed = true)

    // Override production module with test doubles
    private val testModule = module {
        factory<UserRepository> { mockRepository }
        single<AnalyticsService> { mockAnalytics }
    }

    @Before
    fun setUp() {
        startKoin {
            modules(
                networkModule,      // Real network (or replace with test module)
                testModule          // Override with mocks
            )
        }
    }

    @After
    fun tearDown() {
        stopKoin()
    }

    @Test
    fun shouldLoadUserProfile() = runTest {
        val userId = "user-123"
        val expectedUser = User(id = userId, name = "Alice", email = "alice@example.com")

        coEvery { mockRepository.getUser(userId) } returns Result.success(expectedUser)

        val viewModel: UserProfileViewModel = get()
        viewModel.loadUser(userId)

        advanceUntilIdle()

        assertEquals(expectedUser, viewModel.uiState.value.user)
        verify { mockAnalytics.logEvent("profile_viewed", any()) }
    }
}

Using KoinTestRule (JUnit4)

@RunWith(AndroidJUnit4::class)
class CheckoutFlowTest {

    private val mockCartRepository = mockk<CartRepository>()
    private val mockPaymentService = mockk<PaymentService>()

    @get:Rule
    val koinTestRule = KoinTestRule.create {
        printLogger()
        modules(
            module {
                single<CartRepository> { mockCartRepository }
                single<PaymentService> { mockPaymentService }
                factory { CheckoutViewModel(get(), get()) }
            }
        )
    }

    @Test
    fun shouldCompleteCheckoutSuccessfully() = runTest {
        val cart = Cart(items = listOf(CartItem("prod-1", 2, 29.99)))
        coEvery { mockCartRepository.getCart() } returns cart
        coEvery { mockPaymentService.processPayment(any()) } returns PaymentResult.Success("txn-abc")

        val viewModel: CheckoutViewModel = get()
        viewModel.submitOrder()

        advanceUntilIdle()

        assertEquals(CheckoutState.Success("txn-abc"), viewModel.state.value)
        coVerify { mockPaymentService.processPayment(any()) }
    }
}

Mock Injection Patterns

Injecting Mocks by Lazy Delegation

class ProductDetailTest : KoinTest {

    // Lazy injection from Koin
    private val viewModel: ProductDetailViewModel by inject()
    private val mockRepository: ProductRepository by inject()

    private val testModule = module {
        val mock = mockk<ProductRepository>()
        single<ProductRepository> { mock }
        factory { ProductDetailViewModel(get()) }
    }

    @Before
    fun setUp() {
        startKoin { modules(testModule) }
    }

    @After
    fun tearDown() {
        stopKoin()
    }

    @Test
    fun shouldShowProductDetails() = runTest {
        val product = Product("prod-1", "Camera", 599.99, inStock = true)
        coEvery { mockRepository.getProduct("prod-1") } returns product

        viewModel.loadProduct("prod-1")
        advanceUntilIdle()

        assertEquals(product, viewModel.uiState.value.product)
    }
}

Scoped Definitions in Tests

class SessionScopedTest : KoinTest {

    @get:Rule
    val koinTestRule = KoinTestRule.create {
        modules(module {
            scope<UserSession> {
                scoped { UserPreferences(get()) }
                scoped<NotificationService> { mockk(relaxed = true) }
            }
            single { mockk<PreferencesDataStore>(relaxed = true) }
        })
    }

    @Test
    fun shouldInjectScopedDependencies() {
        // Create scope instance
        val scope = getKoin().createScope<UserSession>("session-1")

        val preferences: UserPreferences = scope.get()
        val notifications: NotificationService = scope.get()

        assertNotNull(preferences)
        assertNotNull(notifications)

        // Same scope returns same instances
        assertSame(preferences, scope.get<UserPreferences>())

        scope.close()
    }
}

Testing ViewModel Factory with Koin

When using Koin with by viewModel() delegation in Fragments/Activities:

// Production code
class MainFragment : Fragment() {
    private val viewModel: MainViewModel by viewModel()
}

// Test with fake Activity
@RunWith(AndroidJUnit4::class)
class MainViewModelTest : KoinTest {

    private val mockDataSource = mockk<DataSource>()

    @get:Rule
    val koinTestRule = KoinTestRule.create {
        androidContext(InstrumentationRegistry.getInstrumentation().targetContext)
        modules(
            viewModelModule,  // Production module
            module {
                // Override just what we need
                single<DataSource> { mockDataSource }
            }
        )
    }

    @Test
    fun shouldLoadDataOnInit() = runTest {
        val testData = listOf("item1", "item2", "item3")
        coEvery { mockDataSource.fetchData() } returns testData

        val viewModel: MainViewModel = get()
        advanceUntilIdle()

        assertEquals(testData, viewModel.items.value)
    }
}

Testing Koin with Compose

@RunWith(AndroidJUnit4::class)
class ComposeKoinTest : KoinTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private val mockRepository = mockk<ProductRepository>()

    @get:Rule
    val koinTestRule = KoinTestRule.create {
        modules(module {
            single<ProductRepository> { mockRepository }
            viewModel { ProductListViewModel(get()) }
        })
    }

    @Test
    fun shouldShowProductsFromViewModel() = runTest {
        val products = listOf(
            Product("1", "Product A", 9.99, true),
            Product("2", "Product B", 19.99, false)
        )
        coEvery { mockRepository.getAllProducts() } returns products

        composeTestRule.setContent {
            MaterialTheme {
                ProductListScreen()  // Uses Koin internally via by viewModel()
            }
        }

        composeTestRule.waitForIdle()
        composeTestRule.onNodeWithText("Product A").assertIsDisplayed()
        composeTestRule.onNodeWithText("Product B").assertIsDisplayed()
    }
}

Integration Testing: Koin in Full App Context

@RunWith(AndroidJUnit4::class)
class KoinIntegrationTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)

    @Test
    fun shouldStartApplicationWithKoinInitialized() {
        activityRule.scenario.onActivity { activity ->
            // Verify Koin is running in the app
            val koin = activity.application.getKoin()
            assertNotNull(koin)

            // Verify key services can be resolved
            assertNotNull(koin.getOrNull<UserRepository>())
            assertNotNull(koin.getOrNull<AnalyticsService>())
        }
    }
}

Testing Koin Properties (External Configuration)

val configModule = module {
    single { AppConfig(
        apiUrl = getProperty("API_URL"),
        timeout = getProperty("TIMEOUT", 30_000L)
    ) }
}

@Test
fun shouldLoadConfigFromProperties() {
    startKoin {
        properties(mapOf(
            "API_URL" to "https://api.staging.example.com",
            "TIMEOUT" to 15_000L
        ))
        modules(configModule)
    }

    val config: AppConfig = getKoin().get()
    assertEquals("https://api.staging.example.com", config.apiUrl)
    assertEquals(15_000L, config.timeout)

    stopKoin()
}

JUnit5 Support

@ExtendWith(KoinTestExtension::class)
class OrderServiceTest : KoinTest {

    @JvmField
    @RegisterExtension
    val koinTestExtension = KoinTestExtension.create {
        modules(
            module {
                single<OrderRepository> { mockk() }
                single { OrderService(get()) }
            }
        )
    }

    private val orderRepository: OrderRepository by inject()
    private val orderService: OrderService by inject()

    @Test
    fun shouldCreateOrderSuccessfully() = runTest {
        val order = Order(id = "order-1", total = 59.99)
        coEvery { orderRepository.save(any()) } returns order

        val result = orderService.createOrder(listOf("item-1", "item-2"))

        assertEquals(order.id, result.id)
        coVerify { orderRepository.save(any()) }
    }
}

Koin Testing with HelpMeTest

While Koin tests run on the device, HelpMeTest validates the end-to-end experience that depends on your DI-wired services:

*** Test Cases ***
App Initializes And Shows Dashboard
    Go To    https://app.example.com
    Wait Until Element Is Visible    [data-testid=dashboard]    timeout=10s
    Element Should Be Visible    [data-testid=user-greeting]
    Element Should Be Visible    [data-testid=recent-activity]

If Koin misses a dependency, the app crashes at launch—HelpMeTest catches this as a "page failed to load" failure before real users experience it.

Common Mistakes

1. Forgetting to call stopKoin() in @After Without teardown, Koin's singleton context leaks between tests. Each test should call stopKoin() or use KoinTestRule which handles it automatically.

2. Starting Koin twice If your test's @Before calls startKoin but Koin is already started (by the Android Application class in instrumented tests), you'll get KoinApplicationAlreadyStartedException. Use loadKoinModules() to add test modules to a running context instead.

3. Not using relaxed = true for analytics mocks Analytics services typically have many methods. mockk(relaxed = true) auto-stubs all methods to return default values, saving you from specifying every possible method.

4. Using get() instead of inject() when class is not KoinTest In regular tests, by inject() lazy delegation only works in KoinTest subclasses. Use getKoin().get<Type>() in test helper functions.

Summary

Koin testing is straightforward once you understand the patterns:

  • checkModules() verifies the complete DI graph before runtime—run this in CI
  • KoinTestRule handles Koin lifecycle per test with clean setup/teardown
  • Module overriding is Koin's killer feature for testing—provide test modules with mocks
  • by inject() in KoinTest for lazy resolution, get() for eager resolution
  • Scoped definitions can be tested by creating scopes explicitly in tests
  • JUnit5 extension is available for modern test setups

The combination of checkModules at the module level and mock-injected tests at the unit level gives you complete confidence in your DI configuration.

Read more