RizTech Academy logo
RizTech Academy
Testing, Profiling and DebuggingLesson 2 of 535 min

Unit testing ViewModels and repositories

Mandi Bhav arrived at this module with 187 unit tests, written one module at a time as each feature went in. They run on your computer's JVM, need no emulator, and the whole set takes about ten seconds. This lesson reads them as a teaching set: why they use fakes and not mocks, how runTest makes three seconds of retry delays take sixteen milliseconds, how to test a Flow and a SavedStateHandle, and which dispatcher to give a test. Then it finds the gap they had. Every one of them used a fake refresh that finishes instantly, so nothing ever tested what the screen does while a refresh runs, and the first test Arjun wrote for it could never have caught the bug it was for.

What runs where

Folder Runs on Command Mandi Bhav uses it for
app/src/test Your computer's JVM, with Robolectric standing in for Android where needed ./gradlew testDebugUnitTest ViewModels, use cases, the repository over a real Room database, the network against MockWebServer, the worker, and (next lesson) Compose screens
app/src/androidTest An emulator or phone ./gradlew connectedDebugAndroidTest The whole app under Hilt, next lesson

A plain JVM test runs a Kotlin function and checks what comes back: no Android at all. Robolectric tests start a simulated Android inside the JVM, which costs a few seconds the first time in a run and lets Room, DataStore and files work for real. Measured on the course laptop, with Gradle already running:

./gradlew testDebugUnitTest --rerun
tests=204 failures=0
BUILD SUCCESSFUL, 10.5 s wall clock

  3.16s    5 tests  FileSaleSlipRepositoryTest       (Robolectric starting up)
  2.56s   16 tests  RetrofitPriceNetworkDataSourceTest (real sockets; one test waits for a 1.6 s timeout)
  0.05s   26 tests  PricesViewModelTest

Twenty-six ViewModel tests in five hundredths of a second. That speed is the point of a unit test: fast enough that you run them before every commit without thinking, which is the only way tests catch anything. An instrumented test of the same thing would need an emulator and take seconds per test.

Fakes, not mocks

Every ViewModel test builds the ViewModel by hand, with its constructor, which is all an @Inject constructor is (module 7):

private fun newViewModel(
    handle: SavedStateHandle = SavedStateHandle(),
    repository: FakePriceRepository = FakePriceRepository(),
    preferences: FakeUserPreferencesRepository = FakeUserPreferencesRepository(),
    networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
) = PricesViewModel(
    handle,
    repository,
    FindBestMarketUseCase(repository),
    RefreshPricesUseCase(repository, FakeAlertRepository(), FakeAlertNotifier()),
    preferences,
    networkMonitor,
)

FakePriceRepository is a real, small implementation of PriceRepository: prices in a MutableStateFlow, every flow derived from it, a refresh that swaps in pricesAfterRefresh and counts itself. A test sets it up in plain Kotlin (FakePriceRepository(prices = emptyList()), repository.offline = true) and asserts on what the ViewModel shows.

The other common approach is a mock: a library such as MockK generates an object that answers only what you tell it to. In a scratch copy, with io.mockk:mockk:1.14.12 added, the refresh test looks like this:

val repository = mockk<PriceRepository>()
every { repository.todaysPrices() } returns flowOf(SamplePrices.all)
every { repository.lastUpdated() } returns flowOf(null)
coEvery { repository.refresh() } returns 3

// ... build the ViewModel with it, collect, refresh ...

coVerify(exactly = 1) { repository.refresh() }
assertEquals("3 prices updated.", viewModel.uiState.value.userMessage)

It passed. Then, to see what happens the day the ViewModel starts reading one more thing from the repository, I deleted the lastUpdated() line:

io.mockk.MockKException: no answer found for PriceRepository(#1).lastUpdated() [lastUpdated(): Flow]
among the configured answers: (PriceRepository(#1).todaysPrices() [todaysPrices(): Flow]
PriceRepository(#1).refresh(any()) [refresh(Continuation): Int])

A mock has to be told about every call the code under test makes, so it knows how the code works inside; when the code changes, every test that mocks it breaks, even though nothing a user sees has changed. A fake behaves like the real thing, so a test written against it describes behaviour, and survives refactoring.

Fake Mock
What it is A small working implementation you write once A generated object that answers what each test scripts
A test says "Given these prices, the screen shows…" "This method is called, and returns…"
Breaks when The behaviour changes The implementation changes
Costs Writing and maintaining the fake (Mandi Bhav has seven) A library, and a script in every test
Use it for Your own interfaces: repositories, data sources Something you cannot fake: a final class from a library, a call you must prove happened exactly once

Mandi Bhav has no mocking library at all. That is a choice, and Android's own testing guidance recommends the same: prefer fakes.

runTest and virtual time

The repository retries a server error after one second and then two (module 9). A test that really waited would take three seconds; this one takes sixteen milliseconds:

@Test
fun aServerErrorIsRetriedAndThenSucceeds() = runTest {
    network.failures += listOf(RefreshFailure.Server(503), RefreshFailure.Server(503))

    assertEquals(3, repository.refresh())
    assertEquals(3, network.calls)
    // Waited 1 s and then 2 s, in virtual time.
    assertEquals(3_000L, testScheduler.currentTime)
}

runTest runs the body on a test dispatcher whose clock is virtual: a delay(1_000) moves the clock forward by a second, immediately, instead of waiting. testScheduler.currentTime reads that clock, so the test proves the delays were there, which a real-time test could not do without being slow and flaky. It only works for delay; Thread.sleep and real network timeouts wait for real, which is why the network tests are the slow ones.

Which dispatcher

viewModelScope runs on Dispatchers.Main, which does not exist on the JVM. MainDispatcherRule replaces it for each test, and runTest shares its clock:

class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

The two test dispatchers differ in one thing, and it decides how you write the test:

UnconfinedTestDispatcher StandardTestDispatcher
A new coroutine starts At once, until it first suspends Only when the test lets the scheduler run: runCurrent(), advanceTimeBy(), advanceUntilIdle(), or the end of runTest
Reads like Ordinary sequential code Code where you decide when things happen
Mandi Bhav uses it for ViewModels: call refresh(), assert on the state on the next line The repository's application scope, where a test needs to stop half way through a download

RoomPriceRepositoryTest gives its application scope a StandardTestDispatcher on the test's scheduler, so aDownloadFinishesEvenIfTheScreenThatAskedIsGone can start a two-second download, advanceTimeBy(1_000), cancel the screen's coroutine, and check the prices still arrive.

Testing a Flow

uiState is a StateFlow built with stateIn(WhileSubscribed(5_000)): it does nothing until someone collects it. So each test collects it the way the screen does, for as long as the test runs:

private fun TestScope.collectUiState(viewModel: PricesViewModel) {
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.uiState.collect {}
    }
}

backgroundScope is cancelled when the test ends, so the endless collection does not hang it. Then viewModel.uiState.value is the current state. For a one-off read of any flow, flow.first() suspends until the first value. For "what sequence of values did it go through", collect into a list:

val seen = mutableListOf<Boolean>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
    viewModel.uiState.map { it.isRefreshing }.distinctUntilChanged().toList(seen)
}

The widely used library Turbine wraps this pattern (flow.test { awaitItem() }) and fails a test that leaves values unchecked. It is good; Mandi Bhav does not need it yet, and a library you do not need is a dependency to update for nothing.

Be careful with sequences from a StateFlow: it keeps only the latest value, so a collector that is not fast enough misses the ones in between. Assert on sequences only where, as above, an unconfined collector is guaranteed to see every change.

SavedStateHandle: process death in one line

Module 5 kept the search and the chip in SavedStateHandle so they survive Android killing the process. Testing that needs no emulator, because what Android does after process death is construct a new ViewModel and hand it the saved map:

@Test
fun newViewModelRestoresTheFilterFromSavedState() = runTest {
    val handle = SavedStateHandle(mapOf("query" to "lasalgaon", "commodity" to "Onion"))
    val restored = newViewModel(handle)
    collectUiState(restored)

    assertEquals("lasalgaon", restored.uiState.value.query)
    assertEquals(listOf(SamplePrices.lasalgaonOnion), restored.uiState.value.prices)
}

and the other direction, filterIsWrittenToSavedState, checks handle.get<String>("query") after onQueryChange. AlertsViewModelTest.aHalfTypedAlertSurvivesProcessDeath does the same for the half-filled alert form.

The trap: a test that cannot fail

refresh() begins with a guard, so a second tap while a download runs does nothing:

fun refresh() {
    if (refreshStatus.value.isRefreshing) return
    refreshStatus.update { it.copy(isRefreshing = true) }
    viewModelScope.launch { /* ... */ }
}

No test covered it. Arjun's first attempt:

@Test
fun naiveSecondTap() = runTest {
    val repository = FakePriceRepository()
    val viewModel = newViewModel(repository = repository)
    collectUiState(viewModel)

    viewModel.refresh()
    viewModel.refresh()

    assertEquals(1, repository.refreshCount)
}
naiveSecondTap -> java.lang.AssertionError: expected:<1> but was:<2>

It failed with the guard in place. The guard is right; the test is wrong. The fake's refresh never suspends and the dispatcher is unconfined, so the first refresh had completely finished, isRefreshing back to false, before the second call. The test was not testing "a tap while refreshing" at all, because the fake made "while refreshing" last zero time. The tempting fix, changing the 1 to a 2, would have produced a passing test that proves nothing.

The real fix is to let the test hold the refresh open. Mandi Bhav's fake now has:

private var gate: CompletableDeferred<Unit>? = null

/** From now on a refresh starts, counts itself, and waits for [releaseRefreshes]. */
fun holdRefreshes() {
    gate = CompletableDeferred()
}

/** Lets every waiting refresh finish, and the next ones run straight through. */
fun releaseRefreshes() {
    gate?.complete(Unit)
    gate = null
}

with gate?.await() at the top of refresh(), after the count. The test:

@Test
fun aSecondTapWhileRefreshingIsIgnored() = runTest {
    val repository = FakePriceRepository().apply { holdRefreshes() }
    val viewModel = newViewModel(repository = repository)
    collectUiState(viewModel)

    viewModel.refresh()
    assertTrue(viewModel.uiState.value.isRefreshing)
    viewModel.refresh()
    repository.releaseRefreshes()

    assertEquals(1, repository.refreshCount)
    assertFalse(viewModel.uiState.value.isRefreshing)
    assertEquals("Prices are up to date.", viewModel.uiState.value.userMessage)
}

It passes. Then break the code on purpose: delete the guard line from refresh() and run it again:

aSecondTapWhileRefreshingIsIgnored -> java.lang.AssertionError: expected:<1> but was:<2>

Now it fails for the right reason, and that is the only proof a test is worth having. Every new test should be seen to fail once, by breaking the code it protects. A test you have only ever seen pass may be testing nothing.

The same gate gave a second missing test, aRefreshIsShownStartingAndFinishing, which collects isRefreshing into a list and expects [false, true, false]: the spinner appears, and goes away.

One more: the five seconds after the screen stops

WhileSubscribed(5_000) promises that a rotation, which stops collecting for well under a second, does not restart the work, and that leaving the screen stops it after five seconds. With virtual time, a test can check both edges exactly. The fake reports how many collectors its prices flow has:

val todaysPricesSubscribers: Int get() = current.subscriptionCount.value
@Test
fun theStateKeepsWorkingForFiveSecondsAfterTheScreenStops() = runTest {
    val repository = FakePriceRepository()
    val viewModel = newViewModel(repository = repository)
    val screen = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.uiState.collect {}
    }
    assertEquals(1, repository.todaysPricesSubscribers)

    screen.cancel()
    advanceTimeBy(4_999)
    assertEquals(1, repository.todaysPricesSubscribers)

    advanceTimeBy(2)
    assertEquals(0, repository.todaysPricesSubscribers)
}

The suite is now 204 tests: these three, five for lesson 5's exit reasons, and nine Compose tests from the next lesson that run on the JVM.

What a unit test does not tell you

It does not tell you the screen draws the state correctly, that Hilt can build the real objects, or that the app is fast. Those are the next two lessons. And it tells you nothing about code it does not run: none of these tests would have caught lesson 1's percentChange bugs, because nobody had written a test for percentChange. That is Practice 5.

Check your work

Why did naiveSecondTap fail even though the guard was correct? The fake refresh never suspended and the dispatcher was unconfined, so the first refresh finished before the second call; the second call was not "while refreshing" at all.

How do you know aSecondTapWhileRefreshingIsIgnored tests the guard? Deleting the guard makes it fail with expected:<1> but was:<2>.

How long did the retry test's two delays take, and how does the test know they happened? About sixteen milliseconds of real time; testScheduler.currentTime is 3,000 because the virtual clock moved by 1 s and 2 s.

When do you choose StandardTestDispatcher over UnconfinedTestDispatcher? When the test needs to control when a coroutine runs, such as stopping half way through a download with advanceTimeBy.

What does MockK say when the code calls something the test did not script? MockKException: no answer found for PriceRepository(#1).lastUpdated(), listing the calls that were scripted.

Practice 3. theStateKeepsWorkingForFiveSecondsAfterTheScreenStops fails with expected:<1> but was:<0> at the check after advanceTimeBy(4_999): the collection stopped after one second.

Practice 5. assertEquals(2.178, percentChange(55, 2_525), 0.001) for the rise; percentChange(-30, 2_450) is about -1.22; and with yesterday = 0 the Double version returns Infinity (or NaN for 0 on 0) instead of crashing, which is still wrong on screen, so the function should return null and the screen show no percentage.

Practice

  1. Run ./gradlew testDebugUnitTest --rerun and read the slowest classes in app/build/test-results/testDebugUnitTest. Which are Robolectric, and which wait for real time?
  2. Add holdRefreshes, releaseRefreshes and todaysPricesSubscribers to FakePriceRepository, and the three new PricesViewModelTest tests. Then delete the guard in refresh(), watch the right test fail, and put it back.
  3. Change WhileSubscribed(5_000) to WhileSubscribed(1_000) and run the tests. Which fails, and what does its message say?
  4. Write naiveSecondTap yourself, read its failure, and explain it out loud before you read this lesson's explanation again.
  5. Write the tests for a percentChange(change: Long, yesterday: Long): Double? that returns null when yesterday's price is 0, then write the function.
  6. Harder: RefreshPricesUseCase checks the alerts under a Mutex, so two refreshes that finish together notify a crossed alert once. Using holdRefreshes(), start two refreshes through the use case with an alert that the new prices cross, release them, and assert the notifier was called once. Then remove the Mutex and find out whether your test fails. If it still passes, work out why from what this lesson said about unconfined dispatchers and fakes that never suspend, and decide what the fake would need for the test to be able to fail.

Official documentation

Next: testing Compose UI, on the JVM and on the emulator.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship