Lesson 40 of 83 advanced

ViewModel & Flow Testing: Coroutine Test Utilities

Control virtual time and assert exact state sequences from ViewModels

Open interactive version (quiz + challenge)

Real-world analogy

Testing a ViewModel with coroutines is like directing a movie scene: you need to control when each actor (coroutine) moves, pause time on demand, and verify that every scene (UI state) appeared in the right order. StandardTestDispatcher gives you the director's clapperboard — nothing happens until you call 'action'. UnconfinedTestDispatcher is the improv actor — runs everything immediately without waiting.

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

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. 1. MutableStateFlow(DashboardUiState()) initializes with isLoading=true as the default — the ViewModel starts in a loading state before any data is fetched.
  2. 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. 3. StandardTestDispatcher() is created before the ViewModel so it can be passed to Dispatchers.setMain() before the ViewModel's viewModelScope is initialized.
  4. 4. Dispatchers.setMain(testDispatcher) in @BeforeEach ensures the ViewModel's viewModelScope.launch uses the test dispatcher instead of crashing on JVM.
  5. 5. Dispatchers.resetMain() in @AfterEach is essential — failing to reset leaks the test dispatcher into subsequent tests, causing non-deterministic failures.
  6. 6. viewModel.uiState.test { } from Turbine starts collecting the StateFlow — the first awaitItem() immediately receives the current value (initial state with isLoading=true).
  7. 7. viewModel.loadProjects() queues the coroutine but does NOT run it yet with StandardTestDispatcher.
  8. 8. advanceUntilIdle() runs all queued coroutines to completion — after this, the StateFlow has emitted all intermediate and final states.
  9. 9. Each awaitItem() call retrieves one emission in order — the test enforces the exact sequence: initial → loading → success (or error).
  10. 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?
Look at when the ViewModel is created relative to setMain, missing advanceUntilIdle, skipped intermediate states, and missing runTest wrapper.
Show answer
Bug 1: val viewModel = OrdersViewModel(mockUseCase) is initialized as a class-level property — this means the ViewModel is created BEFORE @BeforeEach runs, so Dispatchers.setMain() has not been called yet when the ViewModel's viewModelScope is initialized. This causes the ViewModel to use the real Main dispatcher (which crashes on JVM) for any coroutines launched during initialization. Fix: move viewModel creation into @BeforeEach after Dispatchers.setMain(): lateinit var viewModel: OrdersViewModel, then in setUp(): val dispatcher = StandardTestDispatcher(); Dispatchers.setMain(dispatcher); viewModel = OrdersViewModel(mockUseCase). Bug 2: The first test calls viewModel.loadOrders() and immediately reads viewModel.uiState.value — but with StandardTestDispatcher, coroutines do not run until you call advanceUntilIdle(). The state will still be the initial loading state when read. Fix: add advanceUntilIdle() before reading the state: viewModel.loadOrders(); advanceUntilIdle(); val state = viewModel.uiState.value. Bug 3: The second test switches to UnconfinedTestDispatcher (after StandardTestDispatcher was set in @BeforeEach) and correctly runs eagerly, but the Turbine block only calls awaitItem() once — skipping the initial StateFlow emission. With StateFlow, test { } always receives the current value first. If the ViewModel starts with isLoading=true, the first awaitItem() returns the initial state, and the test would incorrectly assert the initial state as the 'success' state. Fix: call awaitItem() twice — first for initial/loading state, second for success state. Bug 4: The 'loadOrders shows error on failure' test is missing the runTest { } wrapper entirely — it calls a suspend function (via viewModel.loadOrders() which launches a coroutine) from a regular non-coroutine function. The coroutine will be queued but never run, so viewModel.uiState.value.error will always be null and the assertion will always fail. Fix: annotate with runTest and add advanceUntilIdle().

Explain like I'm 5

Imagine your ViewModel is a vending machine. Normally when you press a button, it takes time to dispense the snack. In a test you do not want to wait 10 seconds for the snack. advanceUntilIdle() is like a fast-forward button that makes the machine dispense instantly. Turbine is like a tray that catches every snack in order so you can check: 'Did the loading light turn on? Did the snack come out? Was it the right snack?'

Fun fact

The Kotlin coroutines team at JetBrains completely rewrote the coroutines test library between version 1.5 and 1.6, replacing TestCoroutineScope and TestCoroutineDispatcher with StandardTestDispatcher and runTest. The old APIs had subtle bugs where coroutines would run at unexpected times, making tests non-deterministic. The new APIs were designed with the principle that coroutines should never run unless you explicitly tell them to — making tests fully deterministic.

Hands-on challenge

Write a complete test class for a SearchViewModel that: (a) debounces search input by 300ms, (b) emits Loading then Success states after the debounce, (c) emits an Error state if the search use case fails. Use StandardTestDispatcher, advanceTimeBy(), and Turbine. Verify that typing 'and' then 'android' within 200ms only triggers one API call (debounce working correctly).

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery