Verdict
Yes
Yes, unit test view models: with a fake repository, assert the screen state after a successful, an empty and a failed load and after each user action, in local tests that need no device.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitycostly
- Test costmoderate
Test view models: they decide what a screen shows, and a test for them runs without a device. For a Jetpack ViewModel or SwiftUI observable object that maps repository data into loading, content, empty and error states, Blast radius is users and Change frequency is regularly, because screens change with most features. Detectability is eventually, because an empty list shown after a failed load crashes nothing. Reversibility is costly, because every fix waits for store review and for users to update. Test cost is moderate: each test needs a fake repository and changes when the screen's states change.
| When | Decision | Why |
|---|---|---|
| The view model computes the total the user pays, such as a basket with a discount code | Test mandatory: assert the total for each discount case and at the boundary amounts | Blast radius rises to money, and Reversibility stays costly, because wrong charges need refunds |
| The view model decides when analytics start, from the user's answer on a consent screen | Test mandatory: assert that no tracking call happens before consent or after a refusal | Blast radius rises to safety-or-legal and Detectability to never, because tracking without consent raises no error |
| The view model exposes the repository's only flow of its type unchanged, with no mapping, branches or actions, and its author opens the screen after each change | Do not test the view model; check the screen on a device before merging | Detectability moves to immediately and Reversibility to trivial, because a broken screen shows on the author's device before any build ships |
| An instrumented UI test on every CI run drives the screen with a fake repository that returns loaded, empty and error results | Do not add separate view model unit tests for the loaded, empty and error states; the UI test covers them | Detectability moves to immediately and Reversibility to trivial, because a wrong state fails CI before merge |
| The view model belongs to a staff app for warehouse pickers, installed through device management | Test minimally: one test of the loaded state per screen | Blast radius falls to internal and Reversibility to with-effort, because a failure slows staff down and device management installs a fixed build the same day |
What breaks if you don't test
A view model breaks on paths its author never clicks: a timeout, an account with no data, a second tap on Save. The screen shows a plausible wrong state, such as "No orders yet" after a failed request or a spinner that never stops. No crash means no alert, and users leave or write a one-star review.
What you lose if you over-test
A test that verifies with Mockito that the view model called repository.load() once breaks when you add a cache, while the screen still works. A test that asserts every emission, such as Loading, Loading, Content, breaks when a refactor drops a duplicate no user saw. In .NET, a PropertyChanged test per property repeats code that the MVVM Toolkit's [ObservableProperty] generates.
How to test
Write local unit tests with a fake repository, as the Android testing guide recommends, and replace the main dispatcher with MainDispatcherRule from the coroutines testing guide. For .NET MAUI or WPF, the MAUI unit testing chapter shows the same pattern.
- Assert the state after a load that returns data, one that returns nothing and one that fails.
- Assert the state after each user action, including a second tap during a request.
- Assert final states, not every intermediate emission.
When the answer changes
- The view model computes an amount the user pays or decides when tracking starts.
- A UI test on every CI run covers each state of the screen with fake data.
- The app is a staff tool that device management updates the same day.
Real incident + Code example
The parcels that vanished on mobile data
On an Android parcel tracking app I worked on, a refactor wrapped the repository call in runCatching and returned an empty list on failure, to stop a crash on timeouts. Failed loads now showed "No parcels yet" instead of an error with a retry button, and the crash rate fell. Nine days later, support linked "my parcels disappeared" tickets to users on mobile data. The fix was one line, but two weeks after it shipped a third of active users still ran the broken version. We added tests like these:
class ParcelsViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test
fun failedLoadShowsErrorWithRetry() = runTest {
val repository = FakeParcelRepository(failWith = IOException("timeout"))
val viewModel = ParcelsViewModel(repository)
viewModel.load()
assertEquals(ParcelsUiState.Error(canRetry = true), viewModel.uiState.value)
}
@Test
fun emptyResultShowsEmptyState() = runTest {
val viewModel = ParcelsViewModel(FakeParcelRepository(parcels = emptyList()))
viewModel.load()
assertEquals(ParcelsUiState.Empty, viewModel.uiState.value)
}
}
The two tests tell a failed load from an empty account, so the runCatching change fails CI.
Related questions
FAQ
- Should I unit test the methods or commands on a view model?
Test the command that the view binds to, not the method behind it. A test that checks
CanExecuteand then callsExecutecovers the condition that enables the button and the work the command does.- Should I mock the repository in view model tests?
Yes, give view model tests a hand-written fake repository that returns the data, empty result or error each test sets. Unlike call verification with a mocking library, a fake does not break when the view model adds a cache.
- Do view model tests need an emulator or a device?
No, a view model test runs as a local JVM unit test, because a Jetpack ViewModel needs no Android framework classes. The only Android dependency to replace is the main dispatcher, which
MainDispatcherRuleswaps for a test dispatcher.- How do I test a view model that uses coroutines or Flow?
Set a test dispatcher as the main dispatcher, run each test inside
runTest, call the action and readuiState.value. Collect the flow with Turbine only when the test must see an intermediate state.