Skip to main content
Version: 4.2 - LTS

Testing Composables with Koin

This guide covers testing strategies for Compose applications using Koin, from Android Studio previews to comprehensive unit tests.

KoinApplicationPreview​

Use KoinApplicationPreview for Android Studio previews with Koin dependencies:

@Preview
@Composable
fun UserScreenPreview() {
KoinApplicationPreview(application = {
modules(module {
viewModel { UserViewModel(FakeUserRepository()) }
})
}) {
UserScreen()
}
}

Multiple Previews​

@Preview(name = "Light Mode")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large Font", fontScale = 1.5f)
@Composable
fun UserCardPreviews() {
KoinApplicationPreview(application = {
modules(previewModule)
}) {
UserCard(user = sampleUser)
}
}

val previewModule = module {
single<UserRepository> { FakeUserRepository() }
viewModel { UserViewModel(get()) }
}

Preview with Different States​

@Preview(name = "Loading")
@Composable
fun LoadingPreview() {
KoinApplicationPreview(application = {
modules(module {
viewModel { UserViewModel(LoadingRepository()) }
})
}) {
UserScreen()
}
}

@Preview(name = "Error")
@Composable
fun ErrorPreview() {
KoinApplicationPreview(application = {
modules(module {
viewModel { UserViewModel(ErrorRepository()) }
})
}) {
UserScreen()
}
}

@Preview(name = "Success")
@Composable
fun SuccessPreview() {
KoinApplicationPreview(application = {
modules(module {
viewModel { UserViewModel(SuccessRepository(sampleUsers)) }
})
}) {
UserScreen()
}
}

Unit Testing with ComposeTestRule​

Basic Setup​

class UserScreenTest : KoinTest {

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(module {
single<UserRepository> { FakeUserRepository() }
viewModel { UserViewModel(get()) }
})
}

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun displaysUserList() {
composeTestRule.setContent {
UserScreen()
}

composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("Bob").assertIsDisplayed()
}
}

Testing User Interactions​

@Test
fun clickingUserShowsDetails() {
composeTestRule.setContent {
UserScreen()
}

// Click on a user
composeTestRule.onNodeWithText("Alice").performClick()

// Verify navigation or state change
composeTestRule.onNodeWithText("alice@example.com").assertIsDisplayed()
}

@Test
fun searchFiltersUsers() {
composeTestRule.setContent {
UserScreen()
}

// Enter search query
composeTestRule.onNodeWithTag("searchField").performTextInput("Ali")

// Verify filtered results
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("Bob").assertDoesNotExist()
}

Testing with ViewModel States​

@Test
fun showsLoadingIndicator() {
val loadingRepository = object : UserRepository {
override suspend fun getUsers(): List<User> {
delay(Long.MAX_VALUE) // Never completes
return emptyList()
}
}

startKoin {
modules(module {
single<UserRepository> { loadingRepository }
viewModel { UserViewModel(get()) }
})
}

composeTestRule.setContent {
UserScreen()
}

composeTestRule.onNodeWithTag("loadingIndicator").assertIsDisplayed()

stopKoin()
}

@Test
fun showsErrorMessage() {
val errorRepository = object : UserRepository {
override suspend fun getUsers(): List<User> {
throw IOException("Network error")
}
}

startKoin {
modules(module {
single<UserRepository> { errorRepository }
viewModel { UserViewModel(get()) }
})
}

composeTestRule.setContent {
UserScreen()
}

composeTestRule.waitUntil(5000) {
composeTestRule
.onAllNodesWithText("Network error")
.fetchSemanticsNodes()
.isNotEmpty()
}

stopKoin()
}

Mocking Dependencies​

Using MockK​

class UserScreenMockTest : KoinTest {

private val mockRepository = mockk<UserRepository>()

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(module {
single { mockRepository }
viewModel { UserViewModel(get()) }
})
}

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun loadsUsersOnStart() = runTest {
coEvery { mockRepository.getUsers() } returns listOf(
User("Test User", "test@example.com")
)

composeTestRule.setContent {
UserScreen()
}

composeTestRule.waitUntil(5000) {
composeTestRule
.onAllNodesWithText("Test User")
.fetchSemanticsNodes()
.isNotEmpty()
}

coVerify { mockRepository.getUsers() }
}

@Test
fun refreshCallsRepository() = runTest {
coEvery { mockRepository.getUsers() } returns emptyList()

composeTestRule.setContent {
UserScreen()
}

// Trigger refresh
composeTestRule.onNodeWithTag("refreshButton").performClick()

coVerify(exactly = 2) { mockRepository.getUsers() }
}
}

Using Fake Implementations​

class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
var shouldFail = false

override suspend fun getUsers(): List<User> {
if (shouldFail) throw IOException("Fake error")
return users.toList()
}

override suspend fun addUser(user: User) {
users.add(user)
}

fun setUsers(vararg newUsers: User) {
users.clear()
users.addAll(newUsers)
}
}

class UserScreenFakeTest : KoinTest {

private val fakeRepository = FakeUserRepository()

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(module {
single<UserRepository> { fakeRepository }
viewModel { UserViewModel(get()) }
})
}

@get:Rule
val composeTestRule = createComposeRule()

@Before
fun setup() {
fakeRepository.setUsers(
User("Alice", "alice@example.com"),
User("Bob", "bob@example.com")
)
}

@Test
fun displaysUsers() {
composeTestRule.setContent {
UserScreen()
}

composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
composeTestRule.onNodeWithText("Bob").assertIsDisplayed()
}

@Test
fun handlesError() {
fakeRepository.shouldFail = true

composeTestRule.setContent {
UserScreen()
}

composeTestRule.waitUntil(5000) {
composeTestRule
.onAllNodesWithText("Error")
.fetchSemanticsNodes()
.isNotEmpty()
}
}
}

Testing Navigation​

class NavigationTest : KoinTest {

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(testModule)
}

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun navigatesToDetail() {
lateinit var navController: NavHostController

composeTestRule.setContent {
navController = rememberNavController()
AppNavigation(navController)
}

// Navigate to detail
composeTestRule.onNodeWithText("View Details").performClick()

// Verify navigation
assertEquals("detail/123", navController.currentDestination?.route)
}

@Test
fun backNavigationWorks() {
lateinit var navController: NavHostController

composeTestRule.setContent {
navController = rememberNavController()
AppNavigation(navController)
}

// Navigate forward
composeTestRule.onNodeWithText("View Details").performClick()

// Navigate back
composeTestRule.onNodeWithContentDescription("Back").performClick()

// Verify back at home
assertEquals("home", navController.currentDestination?.route)
}
}

Testing with Coroutines​

class CoroutineTest : KoinTest {

@get:Rule
val koinTestRule = KoinTestRule.create {
modules(module {
single<UserRepository> { FakeUserRepository() }
viewModel { UserViewModel(get()) }
})
}

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun asyncOperationCompletes() = runTest {
composeTestRule.setContent {
UserScreen()
}

// Wait for async operation
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("userList")
.fetchSemanticsNodes()
.isNotEmpty()
}

// Verify result
composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
}
}

Multiplatform Testing​

For Compose Multiplatform, create expect/actual test helpers:

// commonTest
expect fun createTestComposeRule(): ComposeTestRule

// androidTest
actual fun createTestComposeRule(): ComposeTestRule = createComposeRule()

// Common test
class CommonUserScreenTest : KoinTest {

@get:Rule
val composeTestRule = createTestComposeRule()

@Test
fun displaysContent() {
startKoin {
modules(testModule)
}

composeTestRule.setContent {
UserScreen()
}

// Assertions...

stopKoin()
}
}

Best Practices​

  1. Use KoinTestRule - handles setup/teardown automatically

    @get:Rule
    val koinTestRule = KoinTestRule.create { modules(testModule) }
  2. Prefer Fakes over Mocks - more predictable, easier to understand

  3. Test one behavior at a time - focused tests are easier to maintain

  4. Use semantic test tags - make tests resilient to UI changes

    Modifier.testTag("submitButton")
  5. Wait for async operations - use waitUntil for async state

    composeTestRule.waitUntil(5000) { condition }
  6. Clean up Koin - if not using rules, call stopKoin() in @After

Next Steps​