docs/reference/koin-android/instrumented-testing.md
Instrumented tests run on Android devices or emulators and test your app's integration with the Android framework. Unlike unit tests where you control Koin's lifecycle, instrumented tests require special handling because Koin is started by your Application class.
| Aspect | Unit Tests | Instrumented Tests |
|---|---|---|
| Execution | JVM only | Android device/emulator |
| Koin Start | In test class (startKoin) | In Application.onCreate() |
| Speed | Fast | Slower |
| Android APIs | Mocked | Real |
| Test Isolation | Easy (each test starts fresh) | Requires careful setup |
| Use Case | Business logic, ViewModels | UI, Android components integration |
✅ Good for instrumented tests:
❌ Better as unit tests:
Create a separate Application class for tests with test-specific modules.
Use JUnit rules to configure Koin per test class or test method.
Keep production Application but override specific definitions for testing.
Let's explore each strategy in detail.
Unlike unit tests, where you effectively call start Koin in each test class (i.e. startKoin or KoinTestExtension), in Instrumented tests Koin is started by your Application class.
For overriding production Koin modules, loadModules and unloadModules are often unsafe because the changes are not applied immediately. Instead, the recommended approach is to add a module of your overrides to modules used by startKoin in the Application class.
If you want to keep the class that extends Application of your application untouched, you can create another one inside the AndroidTest package like:
class TestApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
modules(productionModule, instrumentedTestModule)
}
}
}
In order to use this custom Application in yours Instrumentation tests you may need to create a custom AndroidJUnitRunner like:
class InstrumentationTestRunner : AndroidJUnitRunner() {
override fun newApplication(
classLoader: ClassLoader?,
className: String?,
context: Context?
): Application {
return super.newApplication(classLoader, TestApplication::class.java.name, context)
}
}
And then register it inside your gradle file with:
testInstrumentationRunner "com.example.myapplication.InstrumentationTestRunner"
If you want more flexibility, you still have to create the custom AndroidJUnitRunner but instead of having startKoin { ... } inside the custom application, you can put it inside a custom test rule like:
class KoinTestRule(
private val modules: List<Module>
) : TestWatcher() {
override fun starting(description: Description) {
if (getKoinApplicationOrNull() == null) {
startKoin {
androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
modules(modules)
}
} else {
loadKoinModules(modules)
}
}
override fun finished(description: Description) {
unloadKoinModules(modules)
}
}
In this way we can potentially override the definitions directly from our test classes, like:
private val instrumentedTestModule = module {
factory<Something> { FakeSomething() }
}
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(productionModule, instrumentedTestModule)
)
declareMock() (Recommended):::info
Koin 4.2+: Use declareMock() to quickly mock dependencies on-the-fly in tests without creating separate test modules.
:::
class UserViewModelTest : KoinTest {
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(
module {
viewModelOf(::UserViewModel)
// Other production dependencies
}
)
}
@Test
fun `test user loading`() {
// Declare mock on the fly
declareMock<UserRepository> {
coEvery { getUser(any()) } returns User("1", "Test User")
}
val viewModel: UserViewModel by inject()
// Test with mocked repository
}
}
Benefits of declareMock():
Replace real implementations with mocks or fakes for testing:
// Production module
val productionModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
single { ApiService.create() }
}
// Test module with fakes
val testModule = module {
single<UserRepository> { FakeUserRepository() }
single<ApiService> { FakeApiService() }
}
// Fake implementation
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override suspend fun getUser(id: String): User {
return users.find { it.id == id } ?: throw UserNotFoundException()
}
override suspend fun saveUser(user: User) {
users.add(user)
}
// Test-specific methods
fun clearUsers() {
users.clear()
}
}
// Test module with MockK
val mockModule = module {
single<UserRepository> {
mockk<UserRepository> {
coEvery { getUser(any()) } returns User("1", "Test User")
coEvery { saveUser(any()) } just Runs
}
}
}
// Test application
class TestApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@TestApplication)
modules(mockModule)
}
}
}
Replace only specific dependencies:
val testModule = module {
// Keep real implementations
single { Database.create(androidContext()) }
// Mock network layer
single<ApiService> { mockk<ApiService>() }
// Use real repository with mocked API
single<UserRepository> { UserRepositoryImpl(get()) }
}
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
viewModel { LoginViewModel(get()) }
single<AuthService> { FakeAuthService() }
}
)
)
@Test
fun testSuccessfulLogin() {
val scenario = ActivityScenario.launch(LoginActivity::class.java)
onView(withId(R.id.email)).perform(typeText("[email protected]"))
onView(withId(R.id.password)).perform(typeText("password123"))
onView(withId(R.id.loginButton)).perform(click())
onView(withId(R.id.successMessage)).check(matches(isDisplayed()))
scenario.close()
}
}
@RunWith(AndroidJUnit4::class)
class ProfileFragmentTest {
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
viewModel { ProfileViewModel(get()) }
single<UserRepository> {
mockk {
coEvery { getUser(any()) } returns User("1", "Test User")
}
}
}
)
)
@Test
fun testProfileDisplaysUserInfo() {
val scenario = launchFragmentInContainer<ProfileFragment>()
onView(withId(R.id.userName)).check(matches(withText("Test User")))
scenario.close()
}
}
@RunWith(AndroidJUnit4::class)
class HomeViewModelTest : KoinTest {
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
viewModelOf(::HomeViewModel)
single<UserRepository> { FakeUserRepository() }
}
)
)
private val viewModel: HomeViewModel by inject()
@Test
fun testLoadUserData() = runTest {
viewModel.loadUser("123")
val state = viewModel.userState.value
assertEquals("Test User", state.name)
}
}
@Test
fun testViewModelStateReflectsInUI() {
val scenario = ActivityScenario.launch(HomeActivity::class.java)
scenario.onActivity { activity ->
val viewModel: HomeViewModel = activity.viewModel
// Trigger ViewModel action
viewModel.loadUser("123")
// Verify UI updated
onView(withId(R.id.userName)).check(matches(withText("Test User")))
}
}
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
viewModelOf(::LoginViewModel)
single<AuthService> { FakeAuthService() }
}
)
)
@Test
fun testLoginFlow() {
composeTestRule.setContent {
KoinContext {
LoginScreen()
}
}
composeTestRule.onNodeWithTag("email_field")
.performTextInput("[email protected]")
composeTestRule.onNodeWithTag("password_field")
.performTextInput("password123")
composeTestRule.onNodeWithTag("login_button")
.performClick()
composeTestRule.onNodeWithTag("success_message")
.assertIsDisplayed()
}
}
@Composable
fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
val user by viewModel.user.collectAsState()
Text(text = user?.name ?: "Loading...")
}
// Test
@Test
fun testHomeScreenDisplaysUser() {
composeTestRule.setContent {
KoinContext {
HomeScreen()
}
}
composeTestRule.onNodeWithText("Test User")
.assertIsDisplayed()
}
@RunWith(AndroidJUnit4::class)
class CheckoutActivityTest {
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
activityScope {
scoped { CheckoutState() }
}
}
)
)
@Test
fun testActivityScopeSharedAcrossFragments() {
val scenario = ActivityScenario.launch(CheckoutActivity::class.java)
scenario.onActivity { activity ->
val state1 = activity.scope.get<CheckoutState>()
state1.selectedAddress = Address("123 Main St")
// Navigate to next fragment
activity.supportFragmentManager.commit {
replace(R.id.container, PaymentFragment())
}
// Same scope accessible in fragment
val fragment = activity.supportFragmentManager
.findFragmentById(R.id.container) as PaymentFragment
val state2 = fragment.scope.get<CheckoutState>()
assertEquals(state1, state2)
assertEquals("123 Main St", state2.selectedAddress?.street)
}
}
}
@Test
fun testCustomScopeLifecycle() {
val testModule = module {
scope(named("session")) {
scoped { UserSession() }
}
}
koinApplication {
modules(testModule)
// Create scope
val sessionScope = koin.createScope("test_session", named("session"))
val session = sessionScope.get<UserSession>()
session.login("[email protected]")
assertTrue(session.isLoggedIn)
// Close scope
sessionScope.close()
// Scope is closed, can't access
assertThrows<ClosedScopeException> {
sessionScope.get<UserSession>()
}
}
}
@RunWith(AndroidJUnit4::class)
class MultiModuleTest {
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
// Core modules
networkModule,
databaseModule,
// Feature modules
loginModule,
homeModule,
// Test overrides
module {
single<ApiService>(override = true) { FakeApiService() }
}
)
)
@Test
fun testFeatureIntegration() {
// Test that login feature works with home feature
val loginViewModel: LoginViewModel by inject()
val homeViewModel: HomeViewModel by inject()
runBlocking {
loginViewModel.login("[email protected]", "password")
homeViewModel.loadUserData()
}
assertEquals("[email protected]", homeViewModel.userState.value.email)
}
}
class ModuleVerificationTest {
@Test
fun verifyAllModules() {
// Verify all definitions are satisfied
appModule.verify() // appModule includes other modules
}
@Test
fun verifyTestModules() {
testAppModule.verify()
}
}
:::info
Both verify() and checkModules() will be replaced by native compile-time safety in the Koin Compiler Plugin. See Module Verification for details.
:::
@RunWith(AndroidJUnit4::class)
@LargeTest
class CheckoutFlowTest {
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
viewModel { CheckoutViewModel(get(), get()) }
single<CartRepository> { FakeCartRepository() }
single<PaymentService> { FakePaymentService() }
}
)
)
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun testCompleteCheckoutFlow() {
// Navigate to cart
onView(withId(R.id.cartButton)).perform(click())
// Add items to cart
onView(withId(R.id.addItemButton)).perform(click())
onView(withId(R.id.cartItemCount)).check(matches(withText("1")))
// Proceed to checkout
onView(withId(R.id.checkoutButton)).perform(click())
// Fill shipping address
onView(withId(R.id.addressField))
.perform(typeText("123 Main St"))
onView(withId(R.id.nextButton)).perform(click())
// Enter payment info
onView(withId(R.id.cardNumberField))
.perform(typeText("4111111111111111"))
onView(withId(R.id.completeOrderButton)).perform(click())
// Verify order confirmation
onView(withId(R.id.confirmationMessage))
.check(matches(isDisplayed()))
}
}
@Test
fun testNavigationWithSharedState() {
onView(withId(R.id.loginButton)).perform(click())
// Login screen
onView(withId(R.id.emailField)).perform(typeText("[email protected]"))
onView(withId(R.id.passwordField)).perform(typeText("password"))
onView(withId(R.id.submitButton)).perform(click())
// Should navigate to home
onView(withId(R.id.homeTitle)).check(matches(isDisplayed()))
// User data should be available (shared through Koin)
onView(withId(R.id.welcomeMessage))
.check(matches(withText("Welcome, [email protected]")))
}
class KoinIsolationTestRule : TestWatcher() {
override fun starting(description: Description) {
// Start fresh Koin instance
startKoin {
androidContext(InstrumentationRegistry.getInstrumentation().targetContext)
modules(emptyList())
}
}
override fun finished(description: Description) {
// Clean up after each test
stopKoin()
}
}
@RunWith(AndroidJUnit4::class)
class IsolatedTest {
@get:Rule
val isolationRule = KoinIsolationTestRule()
@Test
fun test1() {
loadKoinModules(module { single { "Test1" } })
assertEquals("Test1", get<String>())
}
@Test
fun test2() {
// Fresh Koin instance, no pollution from test1
loadKoinModules(module { single { "Test2" } })
assertEquals("Test2", get<String>())
}
}
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override suspend fun getUser(id: String): User =
users.find { it.id == id } ?: throw UserNotFoundException()
fun reset() {
users.clear()
}
}
@RunWith(AndroidJUnit4::class)
class UserTest {
private val fakeRepo = FakeUserRepository()
@get:Rule
val koinTestRule = KoinTestRule(
modules = listOf(
module {
single<UserRepository> { fakeRepo }
}
)
)
@Before
fun setup() {
fakeRepo.reset()
}
@Test
fun test1() {
// Test with clean repository
}
@Test
fun test2() {
// Test with clean repository (reset was called)
}
}
// TestModules.kt in androidTest package
object TestModules {
val fakeNetworkModule = module {
single<ApiService> { FakeApiService() }
single { OkHttpClient() }
}
val fakeDatabaseModule = module {
single { createInMemoryDatabase() }
single<UserDao> { get<AppDatabase>().userDao() }
}
val fakeDataModule = module {
single<UserRepository> { FakeUserRepository() }
}
fun createInMemoryDatabase(): AppDatabase {
return Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java
).build()
}
}
// Use in tests
@get:Rule
val koinTestRule = KoinTestRule(
modules = TestModules.fakeNetworkModule + TestModules.fakeDataModule
)
class TestConfig {
companion object {
const val TEST_API_URL = "http://localhost:8080"
const val TEST_TIMEOUT_MS = 1000L
}
}
val testConfigModule = module {
single {
OkHttpClient.Builder()
.connectTimeout(TestConfig.TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
}
single {
Retrofit.Builder()
.baseUrl(TestConfig.TEST_API_URL)
.client(get())
.build()
}
}
@RunWith(AndroidJUnit4::class)
class FlexibleTest : KoinTest {
@get:Rule
val koinTestRule = KoinTestRule(modules = emptyList())
@Test
fun testWithFakeRepo() {
loadKoinModules(module {
single<UserRepository> { FakeUserRepository() }
})
// Test code
}
@Test
fun testWithMockRepo() {
loadKoinModules(module {
single<UserRepository> { mockk<UserRepository>() }
})
// Test code
}
@After
fun cleanup() {
unloadKoinModules(/* modules loaded in test */)
}
}
Problem:
org.koin.core.error.KoinAppAlreadyStartedException: A Koin Application has already been started
Solution:
class SafeKoinTestRule : TestWatcher() {
override fun starting(description: Description) {
// Check if Koin is already started
if (getKoinApplicationOrNull() == null) {
startKoin {
modules(testModules)
}
} else {
// Load modules into existing Koin instance
loadKoinModules(testModules)
}
}
override fun finished(description: Description) {
// Don't stop Koin, just unload test modules
unloadKoinModules(testModules)
}
}
Problem: Test definition doesn't replace production definition.
Solution:
// Use override = true
val testModule = module {
single<UserRepository>(override = true) { FakeUserRepository() }
}
// Or use includes to replace
val testModule = module {
includes(productionModule)
} + module {
single<UserRepository>(override = true) { FakeUserRepository() }
}
Problem:
org.koin.core.error.NoBeanDefFoundException: No definition found for class X
Solution:
// Ensure scope is created before accessing
val scenario = ActivityScenario.launch(MyActivity::class.java)
scenario.onActivity { activity ->
// Scope exists here
val dependency = activity.scope.get<MyDependency>()
}
Problem: Tests pass individually but fail when run together.
Solution:
// Proper cleanup between tests
@After
fun tearDown() {
// Close scopes
getKoin().scopeRegistry.deleteScope("test_scope")
// Reset fakes
fakeRepository.reset()
// Unload test modules
unloadKoinModules(testModules)
}
Problem: ViewModel state changes but UI doesn't update in tests.
Solution:
// Use Espresso's IdlingResource for async operations
@get:Rule
val activityRule = ActivityScenarioRule(MyActivity::class.java)
@Test
fun testViewModelUpdatesUI() = runTest {
activityRule.scenario.onActivity { activity ->
val viewModel: MyViewModel = activity.viewModel
// Trigger async action
viewModel.loadData()
// Wait for LiveData/StateFlow to emit
advanceUntilIdle()
// Then verify UI
onView(withId(R.id.dataText))
.check(matches(withText("Data Loaded")))
}
}
val testDatabaseModule = module {
single {
Room.inMemoryDatabaseBuilder(
androidContext(),
AppDatabase::class.java
).build()
}
}
// ✅ Good - Focused test module
val loginTestModule = module {
viewModel { LoginViewModel(get()) }
single<AuthService> { FakeAuthService() }
}
// ❌ Bad - Too broad
val hugeTestModule = module {
// 50+ definitions...
}
// Create reusable test doubles
object TestDoubles {
fun createFakeUserRepository() = FakeUserRepository().apply {
addUser(User("1", "Test User"))
}
fun createMockApiService() = mockk<ApiService> {
coEvery { getUser(any()) } returns User("1", "Test User")
}
}
// Test real Room + Repository integration
@Test
fun testDatabaseIntegration() = runTest {
val database = Room.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java
).build()
val repo = UserRepositoryImpl(database.userDao())
repo.saveUser(User("1", "Test"))
val user = repo.getUser("1")
assertEquals("Test", user.name)
}
// ✅ Good
@Test
fun loginWithValidCredentials_navigatesToHomeScreen()
@Test
fun loginWithInvalidEmail_showsEmailError()
// ❌ Bad
@Test
fun test1()
@Test
fun testLogin()
Key points for instrumented testing with Koin:
override = true or test-specific modulesKoinContextverify() catches configuration errors early