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 CIKoinTestRulehandles Koin lifecycle per test with clean setup/teardown- Module overriding is Koin's killer feature for testing—provide test modules with mocks
by inject()inKoinTestfor 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.