๐Ÿงช Testing, Debugging, and Performance

Testing ViewModels with Coroutines and Flow

ViewModels launch coroutines on Dispatchers.Main, collect Flows and use delays, all of which make naive tests hang, flake or pass for the wrong reason. With kotlinx-coroutines-test and a few patterns, ViewModel tests become fast and deterministic.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. The testing model
  2. 1. Replace Dispatchers.Main
  3. The ViewModel under test
  4. 2. Tests with virtual time
  5. 3. Turbine for flow assertions
  6. 4. Inject the other dispatchers too
  7. 5. SavedStateHandle
  8. What to cover
  9. Common mistakes
  10. Frequently asked questions
  11. Conclusion

The first ViewModel test many developers write fails with "Module with the Main dispatcher had failed to initialize". The second passes, even though the code is broken, because the assertion ran before the coroutine finished. Both come from not controlling dispatchers and time.

The testing model

Controlling dispatchers and time in coroutine tests MainDispatcherRule replaces Dispatchers.Main with a test dispatcher. runTest provides a TestScope with a virtual-time scheduler shared with the test dispatcher. Injected dispatchers in repositories also use the test dispatcher. advanceUntilIdle and advanceTimeBy move virtual time so delays complete instantly. MainDispatcherRuleMain โ†’ TestDispatcher runTest { }virtual time scheduler Injected dispatchersIO/Default โ†’ test advanceUntilIdle() ยท advanceTimeBy(ms)delays complete instantly, deterministically
Replace every dispatcher with test dispatchers that share one scheduler, and you control time completely.

1. Replace Dispatchers.Main

MainDispatcherRule.kt (src/test)Kotlin
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) = Dispatchers.setMain(testDispatcher)
    override fun finished(description: Description) = Dispatchers.resetMain()
}

UnconfinedTestDispatcher starts coroutines eagerly, which suits simple state assertions. StandardTestDispatcher queues them until you advance the scheduler, which suits testing intermediate states such as Loading.

The ViewModel under test

SearchViewModel.ktKotlin
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {
    private val query = MutableStateFlow("")

    @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
    val state: StateFlow<SearchState> = query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { q ->
            if (q.isBlank()) flowOf(SearchState.Idle)
            else flow {
                emit(SearchState.Loading)
                emit(runCatching { repo.search(q) }.fold(
                    onSuccess = { SearchState.Results(it) },
                    onFailure = { SearchState.Error("Search failed") },
                ))
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)

    fun onQueryChange(q: String) { query.value = q }
}

2. Tests with virtual time

SearchViewModelTest.ktKotlin
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
    @get:Rule val mainRule = MainDispatcherRule(StandardTestDispatcher())

    private class FakeRepo(var fail: Boolean = false) : SearchRepository {
        var calls = 0
        override suspend fun search(q: String): List<String> {
            calls++
            if (fail) throw IOException()
            return listOf("$q result")
        }
    }

    @Test
    fun `debounces typing and shows results`() = runTest(mainRule.testDispatcher) {
        val repo = FakeRepo()
        val vm = SearchViewModel(repo)
        val states = mutableListOf<SearchState>()
        backgroundScope.launch { vm.state.toList(states) }      // keeps WhileSubscribed active

        vm.onQueryChange("k"); vm.onQueryChange("ko"); vm.onQueryChange("kotlin")
        advanceTimeBy(299); runCurrent()
        assertEquals(0, repo.calls)                            // still within debounce window

        advanceUntilIdle()
        assertEquals(1, repo.calls)                            // only the final query searched
        assertEquals(SearchState.Results(listOf("kotlin result")), states.last())
        assertTrue(SearchState.Loading in states)              // intermediate state observed
    }

    @Test
    fun `failure shows error`() = runTest(mainRule.testDispatcher) {
        val vm = SearchViewModel(FakeRepo(fail = true))
        backgroundScope.launch { vm.state.collect {} }
        vm.onQueryChange("x")
        advanceUntilIdle()
        assertEquals(SearchState.Error("Search failed"), vm.state.value)
    }
}

Passing mainRule.testDispatcher to runTest makes the test and viewModelScope share one scheduler, so advancing time affects both.

3. Turbine for flow assertions

With TurbineKotlin
@Test
fun `emits loading then results`() = runTest(mainRule.testDispatcher) {
    val vm = SearchViewModel(FakeRepo())
    vm.state.test {
        assertEquals(SearchState.Idle, awaitItem())
        vm.onQueryChange("compose")
        assertEquals(SearchState.Loading, awaitItem())
        assertEquals(SearchState.Results(listOf("compose result")), awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}

Turbine's test {} collects in order and fails on unexpected or missing emissions. That's excellent for testing sequences.

4. Inject the other dispatchers too

If a repository uses withContext(Dispatchers.IO) directly, tests run that code on real threads and lose determinism. Inject dispatchers and pass the test dispatcher in tests. See Hilt qualifiers.

5. SavedStateHandle

Restoring state in testsKotlin
val vm = FiltersViewModel(SavedStateHandle(mapOf("filter" to "UNREAD")))
assertEquals(Filter.UNREAD, vm.uiState.value.filter)

What to cover

Happy path

The event leads to the expected final state.

Failure paths

Network errors, empty results and retries.

Timing

Debounce, timeouts and cancellation of outdated requests.

Common mistakes

  • Using Thread.sleep or real delays in tests.
  • Asserting stateIn(WhileSubscribed) values without any collector, so the upstream never runs.
  • Creating a new StandardTestDispatcher() in the rule and another in runTest, giving two schedulers where time doesn't advance together.
  • Using runBlocking instead of runTest, which runs real time and is slow.

Frequently asked questions

Why does runTest skip my delays?

That's the point: virtual time. delay(5_000) completes instantly when the scheduler advances.

Do I need Turbine?

No, collecting into a list works. Turbine makes sequence assertions clearer and fails fast on unexpected events.

How do I test LiveData?

Use InstantTaskExecutorRule from androidx.arch.core:core-testing, or convert to Flow with asFlow().

Conclusion

Deterministic ViewModel tests need three things: Main replaced with a test dispatcher, runTest sharing that scheduler, and injected dispatchers everywhere else. Collect StateFlows in backgroundScope or use Turbine, and drive time explicitly.

Next step: test what the user actually sees with UI Testing in Jetpack Compose.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Kotlin fluency makes everything else in Android easier, from coroutines and Flow to Compose and clean architecture. The fastest way to build that fluency is to write real Kotlin every day and get feedback on it.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp