Should I test that?

Should I test view models?

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

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 the decision changes
WhenDecisionWhy
The view model computes the total the user pays, such as a basket with a discount codeTest mandatory: assert the total for each discount case and at the boundary amountsBlast 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 screenTest mandatory: assert that no tracking call happens before consent or after a refusalBlast 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 changeDo not test the view model; check the screen on a device before mergingDetectability 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 resultsDo not add separate view model unit tests for the loaded, empty and error states; the UI test covers themDetectability 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 managementTest minimally: one test of the loaded state per screenBlast 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.

  1. Assert the state after a load that returns data, one that returns nothing and one that fails.
  2. Assert the state after each user action, including a second tap during a request.
  3. 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.

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 CanExecute and then calls Execute covers 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 MainDispatcherRule swaps 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 read uiState.value. Collect the flow with Turbine only when the test must see an intermediate state.