Testing a ViewModel
A ViewModel is a plain class: a test builds it with whatever real or fake dependencies it needs.
runVMTest, from the vie-test artifact, then drives it the way a view would, on virtual time.
Running a test
class FakeCounterStore : CounterStore {
val saved = mutableListOf<Int>()
override suspend fun save(count: Int): Result<Unit> {
delay(1.seconds) (1)
saved += count
return Result.success(Unit)
}
override fun savedCounts() = emptyFlow<Int>()
}
@Test
fun saveSignalsSaved(): TestResult { (2)
val store = FakeCounterStore()
return runVMTest(
newVM = { CounterVM(store) },
) {
post(Intent.Save)
advanceUntilIdle()
assertEquals(listOf(0), store.saved)
assertEquals(
listOf(
Model(count = 0, saving = false),
Model(count = 0, saving = true),
Model(count = 0, saving = false),
),
pendingModels(),
)
assertEquals(listOf(Signal.Saved), pendingSignals())
}
}
| 1 | Runs on virtual time: the test does not wait a second. |
| 2 | Return what runVMTest returns: on JS and Wasm it is a promise that the test runner waits for, and a test that drops it ends before the block has run. |
newVM builds the ViewModel, and the block drives it.
Like a view, the block never sees the ViewModel itself: it posts intents, reads the model, and drains what a view would have received.
The ViewModel is cleared once the block returns.
newVM is a suspend lambda on the TestScope, so it can prepare data first, for instance by filling a fake.
It must not advance the scheduler once the ViewModel is built: the models and signals are collected from the moment it returns, and anything emitted before would be lost.
The block starts after the tasks due at time zero have run.
The test scope
The block runs in a VMTestScope:
| Member | Purpose |
|---|---|
|
Sends an intent to the ViewModel. |
|
The current model, as a view would render it. |
|
The models since the previous call, in order. |
|
The signals since the previous call, in order. |
|
Runs everything due, now or later. |
|
Runs what is due now, without moving time. |
|
Moves time forward, running the tasks due before the new time.
|
|
The scheduler every coroutine of the test runs on. |
|
A scope for work that must not keep the test waiting. |
|
The underlying |
The block is a CoroutineScope, so launch works in it.
Advance the scheduler inside the block, since the ViewModel is cleared when the block returns.
pendingModels and pendingSignals drain, like pendingIntents on MockVIE.
The first call to pendingModels starts with the model current when newVM returned.
Like a view, it does not see a model equal to the previous one, and it sees the models after every middleware.
The models and signals are collected from the moment newVM returns, before any coroutine of the ViewModel runs, so no signal is lost.
Do not collect the ViewModel’s signals elsewhere in the test: an Effect would reach only one of the collectors.
Virtual time
runVMTest sets Dispatchers.Main to a test dispatcher before calling newVM.
viewModelScope runs on it, so every delay of the ViewModel, and of the dependencies that do not switch dispatcher, runs on virtual time, which the block moves forward:
@Test
fun saveShowsProgress() = runVMTest(
newVM = { CounterVM(FakeCounterStore()) },
) {
post(Intent.Save)
runCurrent() (1)
assertTrue(model.saving)
advanceTimeBy(1.seconds)
runCurrent() (2)
assertFalse(model.saving)
}
| 1 | post only queues the intent: runCurrent lets process run, up to the delay. |
| 2 | advanceTimeBy stops right before the time it moves to: the task due exactly then runs now. |
Build the ViewModel in newVM, not before calling runVMTest: viewModelScope picks its dispatcher when the ViewModel is built.
Only Dispatchers.Main is replaced.
A dependency that switches to Dispatchers.Default or IO runs on real time, and the block does not wait for it.
Let newVM pass such a dependency a StandardTestDispatcher(testScheduler):
|
newVM = { CounterVM(DiskCounterStore(dispatcher = StandardTestDispatcher(testScheduler))) },