Test your Compose ViewModels in milliseconds: fakes, runTest, the Main dispatcher and Turbine
The ViewModel holds a Compose screen's behavior and runs on the plain JVM, which makes it the best-value test in an Android app. A hand-written fake, a MainDispatcherRule, runTest's virtual time, Turbine for state sequences, and the StateFlow and stateIn traps that make these tests fail for the wrong reason.

The ViewModel is where a Compose screen's behavior lives: what the screen shows while loading, what happens when the user saves, which error appears when the network fails. It is plain Kotlin and runs on the JVM, so its tests take milliseconds and need no emulator. That makes it the best-value test in an Android app. This post tests a small note editor's ViewModel with a fake repository, coroutine test dispatchers, Turbine and SavedStateHandle.
The code under test
The editor loads a note by id, lets the user change the title and body, and saves. Its state is a single StateFlow<EditorUiState>, and the important parts look like this:
class EditorViewModel(
private val repository: NotesRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val noteId: Long? = savedStateHandle.get<Long>("noteId")
private val _state = MutableStateFlow(EditorUiState())
val state: StateFlow<EditorUiState> = _state.asStateFlow()
init {
viewModelScope.launch {
val note = noteId?.let { repository.getNote(it) }
_state.update {
it.copy(
title = savedStateHandle["draftTitle"] ?: note?.title.orEmpty(),
body = note?.body.orEmpty(),
isLoading = false,
)
}
}
}
fun save() {
val current = _state.value
if (current.title.isBlank()) {
_state.update { it.copy(error = "Title is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true) }
when (val result = repository.save(noteId, current.title, current.body)) {
is SaveResult.Ok -> _state.update { it.copy(isSaving = false, saved = true) }
is SaveResult.Failed -> _state.update {
it.copy(isSaving = false, error = result.message)
}
}
}
}
// onTitleChange() also writes savedStateHandle["draftTitle"]
}
NotesRepository is an interface. That one decision makes the ViewModel testable: a test can hand it anything that implements the interface.
Fakes, not mocks, by default
A mock (MockK, for example) is configured per test: you tell it what to return and afterwards verify which calls were made. A fake is a small, real implementation written by hand, usually backed by a map in memory. Google's testing guidance recommends fakes as the default test double, for practical reasons:
- A fake behaves like the real thing, so tests check behavior ("after saving, the note is in the repository") rather than implementation details ("
savewas called once with these arguments"). - A fake is written once and reused by every test. Mocks are configured again in every test.
- Refactoring the ViewModel does not break tests that use a fake, as long as the behavior stays the same.
Keep mocks for the edges: a third-party class you cannot implement easily, or a check that something was not called.
class FakeNotesRepository : NotesRepository {
private val notes = mutableMapOf<Long, Note>()
private var nextId = 1L
var failNextSave: String? = null
var saveDelayMs = 0L
fun seed(vararg seed: Note) {
seed.forEach { notes[it.id] = it }
nextId = (notes.keys.maxOrNull() ?: 0L) + 1
}
override suspend fun getNote(id: Long): Note? = notes[id]
override suspend fun save(id: Long?, title: String, body: String): SaveResult {
delay(saveDelayMs) // virtual time in tests
failNextSave?.let { failNextSave = null; return SaveResult.Failed(it) }
val note = Note(id ?: nextId++, title, body)
notes[note.id] = note
return SaveResult.Ok(note.id)
}
fun all(): List<Note> = notes.values.toList()
}
Step 1: replace the Main dispatcher
viewModelScope launches on Dispatchers.Main. On the JVM there is no Android main looper, so a test that creates this ViewModel fails with an error about the Main dispatcher missing. The fix is Dispatchers.setMain and Dispatchers.resetMain from kotlinx-coroutines-test, and the Android Developers coroutine testing guide recommends wrapping them in a JUnit rule so no test forgets:
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Every ViewModel test then starts with one line: @get:Rule val mainDispatcherRule = MainDispatcherRule().
Step 2: understand runTest and the two dispatchers
runTest runs the test body as a coroutine with virtual time: a delay(5_000) inside it completes instantly, while the test still sees five seconds pass. There are two test dispatchers, and the difference matters:
StandardTestDispatcherqueues new coroutines. They do not run until the test yields, withadvanceUntilIdle(),advanceTimeBy(),runCurrent()or by suspending. It is the default inrunTest, and it lets you check intermediate states such as "saving is in progress".UnconfinedTestDispatcherstarts new coroutines eagerly until their first suspension. Simple tests read shorter, but you cannot observe the state between "launched" and "finished".
When MainDispatcherRule installs a test dispatcher as Main, runTest reuses its scheduler, so advanceUntilIdle() in the test also drives coroutines launched in viewModelScope.
Step 3: the first tests
class EditorViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
private val repository = FakeNotesRepository()
private fun viewModel(noteId: Long? = null) = EditorViewModel(
repository,
SavedStateHandle(mapOf("noteId" to noteId)),
)
@Test fun saving_blank_note_shows_error_and_saves_nothing() = runTest {
val vm = viewModel()
advanceUntilIdle()
vm.onTitleChange(" ")
vm.save()
advanceUntilIdle()
assertThat(vm.state.value.error).isEqualTo("Title is required")
assertThat(repository.all()).isEmpty()
}
@Test fun saving_shows_progress_then_marks_saved() = runTest {
repository.saveDelayMs = 2_000
val vm = viewModel()
advanceUntilIdle()
vm.onTitleChange("Groceries")
vm.save()
runCurrent() // the save starts, then suspends in delay()
assertThat(vm.state.value.isSaving).isTrue()
advanceUntilIdle() // two virtual seconds pass instantly
assertThat(vm.state.value.saved).isTrue()
}
}
The classic mistake is asserting before advancing. With StandardTestDispatcher, the coroutine launched in init has not run when the constructor returns, so a test that reads state.value.title straight away sees the default empty title and fails, or worse, passes for the wrong reason.
Step 4: sequences of states with Turbine
When the order of states is the behavior, Turbine collects the flow and lets you await each item:
@Test fun save_emits_saving_then_saved() = runTest {
repository.saveDelayMs = 1_000 // a suspension between the two updates
val vm = viewModel()
advanceUntilIdle()
vm.onTitleChange("Packing list")
vm.state.test {
assertThat(awaitItem().isSaving).isFalse() // current state
vm.save()
assertThat(awaitItem().isSaving).isTrue()
assertThat(awaitItem().saved).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
StateFlow is conflated: if two updates happen before the collector runs, it sees only the latest, and equal consecutive values are dropped. A test that expects a state that was immediately replaced waits and times out. That is why the fake has a delay: the suspension between "saving" and "saved" lets the collector see both. Assert on the states that matter, and make sure something suspends between them.
The stateIn trap
A flow exposed with stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) runs its upstream only while someone collects it. Reading .value in a test with nobody collecting returns the initial empty list forever. Collect it in backgroundScope, which runTest cancels for you at the end:
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.notes.collect()
}
Process death, at the ViewModel level
SavedStateHandle has a public constructor that takes a map, so you can simulate the system recreating the ViewModel: write a value through the first instance, build a second one with a handle holding the same values, and assert the state comes back.
val restored = EditorViewModel(repository, SavedStateHandle(mapOf(
"noteId" to null,
"draftTitle" to handle.get<String>("draftTitle"),
)))
advanceUntilIdle()
assertThat(restored.state.value.title).isEqualTo("Half-typed")
Checklist
- The ViewModel depends on interfaces, and each has a reusable fake.
- A
MainDispatcherRulereplacesDispatchers.Mainin every ViewModel test. - Tests advance (
advanceUntilIdle(),runCurrent()) before asserting. - Turbine tests have a real suspension between the states they expect.
stateInflows are collected inbackgroundScopebefore reading.value.- Saved state is tested by rebuilding the ViewModel from a
SavedStateHandle.
