Android ViewModel — unit-test skeleton
Every ViewModel that follows the UDF contract ships with a matching unit test in this shape. Same imports, same rule ordering, same factory pattern. If a rule below blocks the task, stop and surface the conflict — don't silently break the contract.
Pairs with [[wnb-viewmodel-udf]].
Non-negotiables
@get:Rule val mainDispatcherRule = MainDispatcherRule()at the top of every test class. ReplacesDispatchers.Mainwith aStandardTestDispatcherfor the duration of each test.private val dispatcherProvider = TestDispatcherProvider(mainDispatcherRule.testDispatcher)— reuses the rule's dispatcher so all IO work runs on the same virtual clock as the main dispatcher. Never construct a secondTestDispatcher.- Factory function
newViewModel(...)at the top of the class. Every dependency has a defaultFakeXxxRepository(). Individual tests override only what they need. Keeps each test independent, no shared mutable state. @OptIn(ExperimentalCoroutinesApi::class)on the class (or per-test) —runTest,advanceUntilIdle,TestDispatcherrequire it.runTest { ... }wraps every coroutine test.advanceUntilIdle()after anylaunch { }in the SUT to drain the queue. UserunCurrent()when you need a single step and want to inspect intermediate state.Truthassertions (assertThat(x).isEqualTo(y)), not JUnitassertEquals. Clearer failure messages, chained matchers.- Fakes, never mocks, for repository interfaces. Fakes live in
app/src/test/java/.../testing/fakes/(or the test source set your project uses). A mocked interface is a lie — a fake exercises the real contract. - No
Thread.sleep. UseadvanceUntilIdle(),advanceTimeBy(ms), or Turbine'sawaitItem(). - Turbine for
Flow/SharedFlowemissions. Snapshot withstate.valueis fine when asserting a single final state, but any sequence-of-emissions assertion (loading → loaded, one-shot events) usesstate.test { ... }/navigationEvent.test { ... }. - Backtick test names describe the behavior, not the method under test:
`unauthenticated user routes to SignUp and marks initialized`(). Given–When–Then is fine but keep it terse.
Canonical example
@OptIn(ExperimentalCoroutinesApi::class)
class FooViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val dispatcherProvider = TestDispatcherProvider(mainDispatcherRule.testDispatcher)
private fun newViewModel(
repo: FakeFooRepository = FakeFooRepository(),
) = FooViewModel(repo, dispatcherProvider)
@Test
fun `initial state is loading, no foos, no error`() = runTest {
val vm = newViewModel()
// Observe the first emission before letting coroutines run
assertThat(vm.state.value.isLoading).isTrue()
assertThat(vm.state.value.foos).isEmpty()
assertThat(vm.state.value.error).isNull()
}
@Test
fun `observeFoos emits foos and clears loading`() = runTest {
val repo = FakeFooRepository(foos = listOf(Foo("1"), Foo("2")))
val vm = newViewModel(repo = repo)
advanceUntilIdle()
assertThat(vm.state.value.foos).hasSize(2)
assertThat(vm.state.value.isLoading).isFalse()
assertThat(vm.state.value.error).isNull()
}
@Test
fun `refresh failure surfaces error and clears loading`() = runTest {
val repo = FakeFooRepository(refreshResult = Result.failure(RuntimeException("nope")))
val vm = newViewModel(repo = repo)
advanceUntilIdle()
vm.onAction(FooAction.Refresh)
advanceUntilIdle()
assertThat(vm.state.value.error).isEqualTo("nope")
assertThat(vm.state.value.isLoading).isFalse()
}
@Test
fun `Select action updates selected state`() = runTest {
val foo = Foo("1")
val vm = newViewModel(repo = FakeFooRepository(foos = listOf(foo)))
advanceUntilIdle()
vm.onAction(FooAction.Select(foo))
assertThat(vm.state.value.selected).isEqualTo(foo)
}
@Test
fun `NavigateToDetail emits a one-shot navigation event`() = runTest {
val vm = newViewModel()
advanceUntilIdle()
vm.navigationEvent.test {
vm.onAction(FooAction.NavigateToDetail("foo-1"))
assertThat(awaitItem()).isEqualTo("foo-1")
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `state emits loading then loaded when observing repo`() = runTest {
val repo = FakeFooRepository(foos = listOf(Foo("1")))
newViewModel(repo = repo).state.test {
assertThat(awaitItem().isLoading).isTrue() // initial
advanceUntilIdle()
val settled = expectMostRecentItem()
assertThat(settled.isLoading).isFalse()
assertThat(settled.foos).hasSize(1)
cancelAndConsumeRemainingEvents()
}
}
}
Common mistakes
- Constructing a new
UnconfinedTestDispatcher()inside the test → the SUT and the test now use different schedulers;advanceUntilIdle()won't drive the SUT. Always reusemainDispatcherRule.testDispatcher. Thread.sleep(500)to wait for a coroutine → flaky on CI, doesn't advance the virtual clock. UseadvanceUntilIdle()oradvanceTimeBy(ms).- Mocking
FooRepositoryinterface → a mock lies about the contract. Write aFakeFooRepositorythat implements the real interface with in-memory state. - Reusing a single ViewModel instance across tests as a class field → cross-test bleed. Always construct via
newViewModel(...)inside the test. - Asserting
SharedFlowwithstate.value→SharedFlowhas no.value. Use Turbine (event.test { assertThat(awaitItem())... }). - Calling
runBlockinginstead ofrunTest→ skips the test-dispatcher machinery, hits real time. AlwaysrunTest. - Forgetting
advanceUntilIdle()afteronAction→ assertion runs before the launched coroutine mutates state; test fails intermittently. - Asserting on
_state(private backing field) via reflection → fragile and tells you nothing the publicstatedoesn't. Assert onstate.valueor via Turbine. - One giant test asserting five unrelated things → when it fails, you don't know which behavior broke. One test = one behavior.
- Silent
advanceUntilIdle()inside astate.test { }block — Turbine already collects; you rarely need to advance again unless the SUT is doing timed work.
When to move a test to :domain
If the assertion is about rules (discount math, coupon eligibility, cart total) and the ViewModel is just plumbing, extract to a use case in the domain module and test the use case with pure JUnit — no dispatcher, no Turbine, no fakes. Faster to write, faster to run, catches the real bug.
Related skills
[[wnb-viewmodel-udf]]— the ViewModel shape this test targets.[[wnb-compose-ui-test]]— the UI-side test that verifies the state renders correctly.