On this page
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
1. Replace Dispatchers.Main
@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
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
@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
@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
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.sleepor 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 inrunTest, giving two schedulers where time doesn't advance together. - Using
runBlockinginstead ofrunTest, 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.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
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.