ViewModel & Flow Testing: Coroutine Test Utilities
Control virtual time and assert exact state sequences from ViewModels
Open interactive version (quiz + challenge)Real-world analogy
What is it?
ViewModel and Flow testing requires replacing Android's main dispatcher with a TestDispatcher, controlling coroutine execution with advanceUntilIdle() or advanceTimeBy(), and collecting StateFlow emissions in sequence. StandardTestDispatcher gives explicit control over when coroutines run. UnconfinedTestDispatcher runs eagerly. Turbine provides a clean DSL for asserting ordered Flow emissions. Together these tools let you verify that your ViewModel transitions through exactly the right UI states in exactly the right order.
Real-world relevance
In a SaaS project management app, the DashboardViewModel fetched project statistics from three separate network calls (total projects, due today, overdue). The original implementation had a bug: it emitted the success state after only the first API call completed, leaving the other two counts as zero in the UI. A ViewModel test using Turbine caught this: the test asserted three sequential state emissions and failed when it received a 'success' state with zero counts prematurely. The fix — using combine() to wait for all three flows — was confirmed by the test passing with all three counts correct.
Key points
- Why ViewModel testing is harder — ViewModels launch coroutines on Dispatchers.Main (which does not exist on JVM) and emit state via StateFlow/SharedFlow over time. Tests run synchronously on JVM. You need: (1) a test replacement for Dispatchers.Main, (2) a way to control when coroutines execute, (3) a way to collect multiple StateFlow emissions in sequence.
- TestDispatcher setup — val dispatcher = StandardTestDispatcher(); Dispatchers.setMain(dispatcher) replaces the Main dispatcher with a test-controlled one. Call Dispatchers.resetMain() in @AfterEach. This allows your ViewModel's viewModelScope.launch { } to run in the test-controlled dispatcher instead of crashing on JVM.
- StandardTestDispatcher — explicit control — With StandardTestDispatcher, coroutines do NOT run until you explicitly advance time. Call testScheduler.advanceUntilIdle() to run all pending coroutines to completion. Or advanceTimeBy(1000) to simulate 1 second passing. Gives maximum control — ideal for testing state sequences with intermediate loading states.
- UnconfinedTestDispatcher — eager execution — With UnconfinedTestDispatcher, coroutines run immediately on the same thread without needing advanceUntilIdle(). Simpler for tests that do not care about intermediate states — just need the final result. Caution: can miss intermediate loading states that StandardTestDispatcher would expose.
- runTest { } with TestDispatcher — runTest(dispatcher) { } runs the entire test body in the test dispatcher. Inside runTest, you can call advanceUntilIdle() at any point to drain pending coroutines. Use advanceTimeBy() to simulate delays (e.g., debounce timers, retry delays) without actually waiting.
- Collecting StateFlow in tests — The naive approach: viewModel.uiState.value — only reads current value, misses intermediate states. Correct approach: launch a collector coroutine, collect emissions into a list, then assert on the list. Or use Turbine's stateFlow.test { } for clean sequential assertions.
- Turbine for StateFlow sequences — stateFlow.test { val loading = awaitItem(); assertThat(loading.isLoading).isTrue(); val success = awaitItem(); assertThat(success.data).isNotNull(); cancel() }. Turbine enforces that emissions arrive in order and provides clear failure messages when the wrong state is emitted.
- Testing ViewModel state sequences — A typical ViewModel: emits Loading → Success (or Error). Test must verify BOTH states in order. If you only check the final state, you miss bugs where the loading state was never shown (causing the UI to freeze) or where the error state was shown before loading completed.
- Testing SharedFlow side effects — SharedFlow is used for one-shot events (navigation, snackbars). Test with Turbine: events.test { viewModel.onSubmit(); assertThat(awaitItem()).isInstanceOf(NavigateToHome::class.java); cancel() }. Ensure SharedFlow replay is 0 for events so they are not replayed to new observers.
- Mocking ViewModel dependencies — ViewModels typically depend on use cases. In tests: mock the use cases (coEvery), not the repositories. This tests the ViewModel's responsibility: translating use case results into UI state. The use case tests (lesson 39) already verified the business logic.
- Testing error states — Test that when the use case throws or returns Result.Failure, the ViewModel emits an error UiState. Assert that error.message is a user-friendly string (not a raw exception message). Error state tests are critical for apps like fintech where an unhanded error state can be catastrophic.
- advanceTimeBy for debounce and retry — viewModel.onSearchQuery('android'); advanceTimeBy(300) — simulates a 300ms debounce delay without waiting. advanceUntilIdle() after that runs the search coroutine. Essential for testing search ViewModels with input debouncing without making tests slow.
Code example
// ViewModel under test
data class DashboardUiState(
val isLoading: Boolean = true,
val projects: List<Project> = emptyList(),
val error: String? = null
)
class DashboardViewModel(
private val getProjectsUseCase: GetProjectsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<DashboardEvent>(replay = 0)
val events: SharedFlow<DashboardEvent> = _events.asSharedFlow()
fun loadProjects() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getProjectsUseCase()
.onSuccess { projects ->
_uiState.update { it.copy(isLoading = false, projects = projects) }
}
.onFailure { error ->
_uiState.update { it.copy(isLoading = false, error = error.message) }
}
}
}
fun onProjectClick(project: Project) {
viewModelScope.launch {
_events.emit(DashboardEvent.NavigateToProject(project.id))
}
}
}
// Test class
class DashboardViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockUseCase = mockk<GetProjectsUseCase>()
private lateinit var viewModel: DashboardViewModel
@BeforeEach
fun setUp() {
Dispatchers.setMain(testDispatcher)
viewModel = DashboardViewModel(mockUseCase)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `loadProjects emits loading then success state in order`() = runTest {
val projects = listOf(Project("1", "Field Ops"), Project("2", "School Portal"))
coEvery { mockUseCase() } returns Result.success(projects)
viewModel.uiState.test {
assertThat(awaitItem().isLoading).isTrue() // initial state
viewModel.loadProjects()
advanceUntilIdle() // run all pending coroutines
val loadingState = awaitItem()
assertThat(loadingState.isLoading).isTrue() // loading emitted
assertThat(loadingState.projects).isEmpty()
val successState = awaitItem()
assertThat(successState.isLoading).isFalse() // success emitted
assertThat(successState.projects).hasSize(2)
assertThat(successState.error).isNull()
cancel()
}
}
@Test
fun `loadProjects emits error state when use case fails`() = runTest {
coEvery { mockUseCase() } returns Result.failure(IOException("Server error"))
viewModel.uiState.test {
awaitItem() // consume initial state
viewModel.loadProjects()
advanceUntilIdle()
awaitItem() // loading state
val errorState = awaitItem()
assertThat(errorState.isLoading).isFalse()
assertThat(errorState.error).isNotNull()
cancel()
}
}
@Test
fun `onProjectClick emits navigation event`() = runTest {
val project = Project("1", "Field Ops")
viewModel.events.test {
viewModel.onProjectClick(project)
advanceUntilIdle()
val event = awaitItem()
assertThat(event).isInstanceOf(DashboardEvent.NavigateToProject::class.java)
assertThat((event as DashboardEvent.NavigateToProject).projectId).isEqualTo("1")
cancel()
}
}
}Line-by-line walkthrough
- 1. MutableStateFlow(DashboardUiState()) initializes with isLoading=true as the default — the ViewModel starts in a loading state before any data is fetched.
- 2. MutableSharedFlow(replay = 0) creates an event bus — replay=0 means new subscribers do not receive past events, preventing navigation from replaying on screen rotation.
- 3. StandardTestDispatcher() is created before the ViewModel so it can be passed to Dispatchers.setMain() before the ViewModel's viewModelScope is initialized.
- 4. Dispatchers.setMain(testDispatcher) in @BeforeEach ensures the ViewModel's viewModelScope.launch uses the test dispatcher instead of crashing on JVM.
- 5. Dispatchers.resetMain() in @AfterEach is essential — failing to reset leaks the test dispatcher into subsequent tests, causing non-deterministic failures.
- 6. viewModel.uiState.test { } from Turbine starts collecting the StateFlow — the first awaitItem() immediately receives the current value (initial state with isLoading=true).
- 7. viewModel.loadProjects() queues the coroutine but does NOT run it yet with StandardTestDispatcher.
- 8. advanceUntilIdle() runs all queued coroutines to completion — after this, the StateFlow has emitted all intermediate and final states.
- 9. Each awaitItem() call retrieves one emission in order — the test enforces the exact sequence: initial → loading → success (or error).
- 10. The navigation event test uses viewModel.events.test { } for SharedFlow — since replay=0, no event exists yet; we emit one and then awaitItem() retrieves it.
Spot the bug
class OrdersViewModelTest {
// Bug 1
private val mockUseCase = mockk<GetOrdersUseCase>()
private val viewModel = OrdersViewModel(mockUseCase)
@BeforeEach
fun setUp() {
val dispatcher = StandardTestDispatcher()
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `loadOrders shows success state`() = runTest {
val orders = listOf(Order("1"), Order("2"))
coEvery { mockUseCase() } returns Result.success(orders)
viewModel.loadOrders()
// Bug 2
val state = viewModel.uiState.value
assertThat(state.orders).hasSize(2)
assertThat(state.isLoading).isFalse()
}
@Test
fun `loadOrders emits correct states`() = runTest {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
coEvery { mockUseCase() } returns Result.success(listOf(Order("1")))
viewModel.uiState.test {
viewModel.loadOrders()
// Bug 3
val success = awaitItem()
assertThat(success.isLoading).isFalse()
cancel()
}
}
@Test
// Bug 4
fun `loadOrders shows error on failure`() {
coEvery { mockUseCase() } returns Result.failure(Exception("error"))
viewModel.loadOrders()
assertThat(viewModel.uiState.value.error).isNotNull()
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- kotlinx-coroutines-test Guide (Kotlin)
- Testing Kotlin Flows on Android (Android Developers)
- Turbine GitHub Repository (Cash App)
- Testing ViewModels (Android Guide) (Android Developers)