Techbase Academy
Android · Lesson 25

UI State, StateFlow and Unit-Testing the ViewModel

11 phasesTested Score KeeperAndroid Studio
Phase 1 of 11
Lesson Introduction

In lesson 24 you moved Dessert Clicker's state into a ViewModel. That was the first half of the job. The second half is the payoff: because the logic now lives in a plain Kotlin class, you can test it automatically in milliseconds, with no emulator, no phone and no tapping.

In this lesson you will look more closely at UI state and StateFlow, learn what SavedStateHandle adds, and write real unit tests for a ViewModel using JUnit and the coroutines test library. You will finish the Unit 4 ViewModel work with the practice exercise of adding a ViewModel to an app that does not have one.

This lesson covers two items from Unit 4 of the official Android Basics with Compose course: Write unit tests to test the ViewModel and Practice: Add a ViewModel to Dessert Clicker.

By the end you will be able to:

  • Design a UI state class and update it with MutableStateFlow.update.
  • Explain what a StateFlow is: always has a value, replays the latest to new collectors, ignores equal values.
  • Keep small essential state across process death with SavedStateHandle.
  • Write local unit tests (src/test) with JUnit 4 and assertions.
  • Test code that launches coroutines with runTest and a test dispatcher.
  • Explain why a Dispatchers.Main replacement is needed in ViewModel tests, and install one with a JUnit rule.

Big idea: a test is an executable specification. Writing "after 5 taps the dessert is a Donut" as a test means you never have to re-tap by hand again.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 11
Prerequisite Concepts
  • ViewModel and MutableStateFlow (lesson 24).
  • Data classes and copy (lesson 11).
  • Why test? In lesson 20 you wrote tests for a function and a composable. Today's tests target logic that has no UI at all.
  • Local versus instrumented tests. Android Studio gives you two test folders:
Folder Runs on Speed Use for
app/src/test Your computer's JVM Very fast (milliseconds) Logic: ViewModels, repositories, utilities
app/src/androidTest An emulator or device Slow (seconds to minutes) UI tests, database tests that need Android

ViewModel logic with no Android classes belongs in src/test.

✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 11
Conceptual Understanding: StateFlow in Depth

StateFlow is a hot, state-holding flow:

  • It always has a current value (.value). You must give it an initial value when you create it.
  • A new collector immediately receives the latest value.
  • It conflates: if you set many values quickly, a slow collector may only see the latest.
  • It ignores a new value that is equal (==) to the current one. This is why immutable data classes work so well: set an equal state and nothing recomposes.
text
MutableStateFlow(initial)           StateFlow (read-only view)
  .value = new      ───────────▶     .value   (read the latest)
  .update { old -> new }             .collect { }  (observe changes)

Three ways to change a MutableStateFlow

kotlin
_uiState.value = UiState() // 1: replace outright
_uiState.value = _uiState.value.copy(count = 1) // 2: read then write (NOT thread-safe)
_uiState.update { it.copy(count = it.count + 1) } // 3: atomic read-modify-write

Option 3 is safest. If two coroutines both run option 2 at the same moment they can read the same old value and one change gets lost. update retries until the swap succeeds. Use it whenever the new value depends on the old one.

Designing a UI state class

Ask: "what does the screen need to draw?" Put exactly that in the class. Good UI state is:

  • Immutable (val properties).
  • Complete: the screen can render purely from it.
  • Free of impossible combinations where possible. For a screen that loads data, a sealed interface often beats several booleans:
kotlin
sealed interface NewsUiState {
 data object Loading : NewsUiState
 data class Success(val headlines: List<String>) : NewsUiState
 data class Error(val message: String) : NewsUiState
}

With three booleans (isLoading, hasError, hasData) there are eight combinations and most are nonsense. With the sealed type there are exactly three, and Kotlin's when forces you to handle each one. You will use this pattern in lesson 29.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: StateFlow in Depth. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 11
Surviving Process Death with SavedStateHandle

In lesson 24 you saw that a ViewModel does not survive process death. SavedStateHandle is a small key-value map that does, because it is backed by the same saved instance state as rememberSaveable. The ViewModel receives it through its constructor.

kotlin
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
class CounterViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
 val count: StateFlow<Int> = savedStateHandle.getStateFlow("count", 0)
 fun increment() {
 savedStateHandle["count"] = count.value + 1
 }
}
  • getStateFlow(key, initialValue) gives a StateFlow that is always in sync with the saved value.
  • savedStateHandle[key] = value writes the value, and it is stored for process death.

With the plain viewModel() function in Compose, the default factory supplies the SavedStateHandle automatically for ViewModels whose constructor takes only a SavedStateHandle (or has no parameters). Anything more complex needs a factory, which lesson 30 covers.

Keep it to small things: the selected tab, a search query, an item ID. Big data belongs in storage. The same 1 MB Bundle limit from lesson 23 applies.

✏️ Your Task
Practise what you just learned about Surviving Process Death with SavedStateHandle. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 11
Step-by-Step Build 1: A Testable ViewModel

To make testing straightforward, we will use a slightly extended Dessert Clicker ViewModel. Start from lesson 24's code. For clarity, here is the full ViewModel once more, plus one new rule: the reset function.

DessertViewModel.kt
package com.example.dessertclicker.ui
import androidx.lifecycle.ViewModel
import com.example.dessertclicker.desserts
import com.example.dessertclicker.determineDessertIndex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class DessertViewModel : ViewModel() {
 private val _uiState = MutableStateFlow(DessertUiState())
 val uiState: StateFlow<DessertUiState> = _uiState.asStateFlow()
 fun onDessertClicked() {
 _uiState.update { current ->
 val sold = current.dessertsSold + 1
 val index = determineDessertIndex(sold)
 current.copy(
 revenue = current.revenue + desserts[current.currentDessertIndex].price,
 dessertsSold = sold,
 currentDessertIndex = index,
 currentDessertName = desserts[index].name
 )
 }
 }
 fun onReset() {
 _uiState.value = DessertUiState()
 }
}

Nothing in it knows about Android's UI. That is exactly why we can test it on the JVM.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: A Testable ViewModel. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 11
Step-by-Step Build 2: Your First ViewModel Test

Check the test dependencies

A new project already has JUnit 4 (testImplementation(libs.junit)) in app/build.gradle.kts. For coroutine tests you also need the coroutines test library. Add this line and sync (look up the latest stable version on the kotlinx.coroutines releases page, and use the same version as your coroutines library; the Compose and lifecycle libraries pull in coroutines for you):

build.gradle.kts
dependencies {
 testImplementation(libs.junit)
 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<same version as coroutines>")
}

(A version-catalog entry is tidier; lesson 28 shows both styles.)

The test class

Right-click app/src/test/java/com/example/dessertclicker, choose New > Kotlin Class/File, and name it DessertViewModelTest:

DessertViewModelTest.kt
package com.example.dessertclicker
import com.example.dessertclicker.ui.DessertUiState
import com.example.dessertclicker.ui.DessertViewModel
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class DessertViewModelTest {
 private lateinit var viewModel: DessertViewModel
 @Before
 fun setUp() {
 viewModel = DessertViewModel()
 }
 @Test
 fun initialState_isCupcakeWithZeroSales() {
 val state = viewModel.uiState.value
 assertEquals(0, state.dessertsSold)
 assertEquals(0, state.revenue)
 assertEquals("Cupcake", state.currentDessertName)
 }
 @Test
 fun oneClick_sellsOneCupcake() {
 viewModel.onDessertClicked()
 val state = viewModel.uiState.value
 assertEquals(1, state.dessertsSold)
 assertEquals(5, state.revenue) // a cupcake costs 5
 assertEquals("Cupcake", state.currentDessertName)
 }
 @Test
 fun fiveClicks_unlockDonut() {
 repeat(5) { viewModel.onDessertClicked() }
 val state = viewModel.uiState.value
 assertEquals(5, state.dessertsSold)
 assertEquals(25, state.revenue) // five cupcakes at 5 each
 assertEquals("Donut", state.currentDessertName)
 }
 @Test
 fun reset_returnsToInitialState() {
 repeat(8) { viewModel.onDessertClicked() }
 viewModel.onReset()
 assertEquals(DessertUiState(), viewModel.uiState.value)
 }
}

Understanding the test

  • @Test marks a function JUnit should run. Each test gets a fresh instance of the test class.
  • @Before runs before every test, so each starts from a new ViewModel. Tests must never depend on each other or on order.
  • assertEquals(expected, actual) fails the test if the values differ. Note the argument order: expected first.
  • The test names describe behaviour (fiveClicks_unlockDonut). A good name tells you what broke without opening the file.
  • assertEquals(DessertUiState(), viewModel.uiState.value) compares whole data classes. This works because data classes generate equals for you.

Check the arithmetic for fiveClicks_unlockDonut. Click 1 to 4 sell cupcakes (index 0, price 5) each, 20 in revenue. At click 5 the sold count reaches 5 after the click, but the price comes from the dessert shown before that click, which is still the cupcake, so revenue is 25. Then the index becomes 1 (Donut). The test asserts 25, matching that reasoning.

Running the tests

Click the green arrow beside the class name, and choose Run 'DessertViewModelTest'. A green tick means pass. Or use the terminal:

bash
./gradlew test

Break something on purpose (change the cupcake price in desserts) and watch the test turn red with a message saying expected:<25> but was:<...>. A test you have never seen fail is a test you cannot fully trust.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: Your First ViewModel Test. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 11
Step-by-Step Build 3: Testing Coroutines and Dispatchers

Soon your ViewModels will launch coroutines with viewModelScope.launch (lesson 28). Consider a version that delays before applying a "bonus":

kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BonusViewModel : ViewModel() {
 private val _bonus = MutableStateFlow(0)
 val bonus: StateFlow<Int> = _bonus.asStateFlow()
 fun claimBonus() {
 viewModelScope.launch {
 delay(2_000) // pretend a server call takes two seconds
 _bonus.update { it + 100 }
 }
 }
}

Testing this naively has two problems:

  1. viewModelScope runs on Dispatchers.Main, which does not exist in a plain JVM unit test, because there is no Android main thread. The test crashes with Module with the Main dispatcher had failed to initialize.
  2. A real two-second delay would make the test slow.

The solution is the coroutines test library:

  • Dispatchers.setMain(testDispatcher) replaces the Main dispatcher for the test, and Dispatchers.resetMain() restores it afterwards.
  • runTest { } runs a coroutine with a virtual clock. A delay(2_000) inside it completes instantly, because the test scheduler skips ahead.

A reusable JUnit rule installs and removes the dispatcher for every test:

MainDispatcherRule.kt
package com.example.dessertclicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
 val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
 override fun starting(description: Description) {
 Dispatchers.setMain(testDispatcher)
 }
 override fun finished(description: Description) {
 Dispatchers.resetMain()
 }
}

setMain and resetMain are marked experimental in the library, hence the @OptIn. Now the test:

BonusViewModelTest.kt
package com.example.dessertclicker
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class BonusViewModelTest {
 @get:Rule
 val mainDispatcherRule = MainDispatcherRule()
 @Test
 fun claimBonus_addsOneHundredAfterDelay() = runTest {
 val viewModel = BonusViewModel()
 viewModel.claimBonus()
 assertEquals(0, viewModel.bonus.value) // the two seconds have not passed yet
 advanceUntilIdle() // run pending coroutines, skipping the delay
 assertEquals(100, viewModel.bonus.value)
 }
}
  • @get:Rule applies the rule to each test. (The get: is Kotlin syntax so JUnit sees the annotation on the getter.)
  • StandardTestDispatcher does not run launched coroutines immediately. They are queued until you advance the scheduler, which lets you assert on the "before" state.
  • advanceUntilIdle() runs all queued work. In Kotlin coroutines tests the runTest scheduler and the rule's dispatcher must share a scheduler. When you create the ViewModel inside runTest after setMain, runTest picks up the Main test dispatcher's scheduler automatically, so they cooperate.

For the more complete treatment of flows and testing them, see lesson 33.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: Testing Coroutines and Dispatchers. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 11
Testing a Flow Value Over Time

StateFlow exposes .value, so most tests simply read it. To check the sequence of emissions you can collect into a list:

kotlin
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun uiState_emitsEveryChange() = runTest {
 val viewModel = DessertViewModelFake()
 val seen = mutableListOf<Int>()
 val job = launch(UnconfinedTestDispatcher(testScheduler)) {
 viewModel.uiState.toList(seen.let { mutableListOf() }.also { }) // placeholder
 }
 job.cancel()
}

That example is deliberately not what you should write, and it is a good reminder: do not make tests clever. A StateFlow conflates updates, so collecting a list can miss intermediate values and produce flaky tests. Prefer asserting on viewModel.uiState.value after each action. For genuine sequences, use first() or the library approach shown in lesson 33.

(That dead-end snippet is here so you learn to recognise over-engineering. Delete it from your own file.)

✏️ Your Task
Practise what you just learned about Testing a Flow Value Over Time. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 11
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: The practice, Add a ViewModel

Take the lesson 23 Dessert Clicker before it had a ViewModel (state in remember), and apply the full migration yourself with only this checklist:

  1. Create DessertUiState.
  2. Create DessertViewModel with onDessertClicked.
  3. Make DessertClickerScreen stateless.
  4. Connect through viewModel() and collectAsStateWithLifecycle().
  5. Delete the remember state.
  6. Verify the rotation test passes.

Then write three new tests: ten clicks give 60 revenue, twenty clicks unlock Eclair, and reset at 2 clicks gives zero.

The migration is exactly the code in lesson 24, Builds 2 to 4. For the tests:

kotlin
@Test
fun tenClicks_giveSixtyRevenue() {
 repeat(10) { viewModel.onDessertClicked() }
 // clicks 1-5 sell cupcakes at 5 = 25; clicks 6-10 sell donuts at 10 = 50. Click 5 sells the cupcake that was on screen.
 assertEquals(75, viewModel.uiState.value.revenue)
}

Careful: this exercise statement said 60, but the arithmetic shows 75. Clicks 1 to 5 each sell the dessert shown before the click, which is a cupcake for clicks 1 to 5 (the donut appears after the fifth sale), so that is 5 x 5 = 25. Clicks 6 to 10 sell donuts: 5 x 10 = 50. Total 75. This is a deliberate lesson: when a test disagrees with your expectation, work through the arithmetic before touching either. The test is right and the number in the exercise was wrong. Use 75.

kotlin
@Test
fun twentyClicks_unlockEclair() {
 repeat(20) { viewModel.onDessertClicked() }
 assertEquals("Eclair", viewModel.uiState.value.currentDessertName)
}
@Test
fun resetAfterTwoClicks_givesZero() {
 repeat(2) { viewModel.onDessertClicked() }
 viewModel.onReset()
 assertEquals(0, viewModel.uiState.value.revenue)
 assertEquals(0, viewModel.uiState.value.dessertsSold)
}
🎯 Your Challenge

Exercise 2: Test a validation rule

Write a PasswordViewModel with onPasswordChange(text) and a UI state exposing isValid (true only when the password is at least 8 characters and contains a digit). Then write tests covering: empty, seven characters with a digit, eight letters without a digit, and a valid password.

kotlin
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class PasswordUiState(val password: String = "") {
 val isValid: Boolean
 get() = password.length >= 8 && password.any { it.isDigit() }
}
class PasswordViewModel : ViewModel() {
 private val _uiState = MutableStateFlow(PasswordUiState())
 val uiState: StateFlow<PasswordUiState> = _uiState.asStateFlow()
 fun onPasswordChange(text: String) = _uiState.update { it.copy(password = text) }
}
kotlin
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class PasswordViewModelTest {
 private lateinit var vm: PasswordViewModel
 @Before fun setUp() { vm = PasswordViewModel() }
 @Test fun empty_isInvalid() = assertFalse(vm.uiState.value.isValid)
 @Test fun sevenCharsWithDigit_isInvalid() {
 vm.onPasswordChange("abcdef1")
 assertFalse(vm.uiState.value.isValid)
 }
 @Test fun eightLettersNoDigit_isInvalid() {
 vm.onPasswordChange("abcdefgh")
 assertFalse(vm.uiState.value.isValid)
 }
 @Test fun eightCharsWithDigit_isValid() {
 vm.onPasswordChange("abcdefg1")
 assertTrue(vm.uiState.value.isValid)
 }
}

Why these four: they test the boundaries of each rule, which is where bugs hide.

✏️ Your Task

Add a failing test on purpose, read the failure message, then fix the test. Then deliberately break the ViewModel and see which tests catch it. Count how many of your tests fail when you change one price.

Phase 10 of 11
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Tests that depend on each other. What happens: test B only passes after test A ran. The fix: create a fresh ViewModel in @Before, and never share mutable state between tests.

Mistake #2: Forgetting to replace Dispatchers.Main. Symptom: Module with the Main dispatcher had failed to initialize. The fix: use a MainDispatcherRule as shown.

Mistake #3: Thread.sleep in tests. What happens: slow and flaky tests. The fix: runTest with the virtual clock and advanceUntilIdle().

Mistake #4: Swapping expected and actual in assertEquals. What happens: the failure message is misleading. The fix: expected first, always.

Mistake #5: Reading .value before the coroutine ran. What happens: the assertion sees the old state. The fix: advance the test scheduler before asserting.

Mistake #6: Testing implementation, not behaviour. The wrong test: checking that a private function was called. The fix: assert on the state the user would see.

Mistake #7: Putting Android classes in a local test. Symptom: Method ... not mocked. The fix: keep the ViewModel free of Android classes, or move that test to androidTest.

Mistake #8: Trusting an exercise instead of the arithmetic. What happens: you "fix" correct code to match a wrong expectation. The fix: calculate by hand, then decide which side is wrong.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 11
Knowledge Check

Question 1. Why does MutableStateFlow require an initial value?

StateFlow always holds a current value, so new collectors can receive the latest state immediately. There is no "empty" state.

Question 2. Why use update { } instead of value = value.copy(...)?

update performs an atomic read-modify-write. Concurrent updates cannot overwrite each other with a stale value, which can happen with separate read and write steps.

Question 3. Where do local unit tests live and what do they run on?

In app/src/test, running on the JVM of your development machine. They are fast and cannot use Android framework classes without special libraries.

Question 4. Why do ViewModel tests that use viewModelScope need Dispatchers.setMain?

viewModelScope uses Dispatchers.Main, which does not exist in a plain JVM test. A test dispatcher must replace it, and be reset afterwards.

Question 5. What does SavedStateHandle add to a ViewModel?

It stores small pieces of state in a map backed by saved instance state, so those values survive process death as well as configuration changes.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Tested Score Keeper

Project goal: Build a two-team scoreboard (for a football match or an ayo tournament) with full test coverage of its ViewModel.

Requirements:

  1. ScoreUiState(teamA: Int, teamB: Int, history: List<String>) plus a computed leader: String returning Team A, Team B or Draw.
  2. ScoreViewModel with onScore(team, points), onUndo() and onReset(). Points must be positive. Undo removes the last entry from the history and reverses its effect.
  3. A stateless screen and a connected top-level composable.
  4. At least six unit tests, including undo and the draw case.
  5. Persist the current scores across process death with SavedStateHandle.

Stretch goal: show the history in a LazyColumn.

ScoreViewModel.kt
package com.example.scorekeeper
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class ScoreUiState(
 val teamA: Int = 0,
 val teamB: Int = 0,
 val history: List<String> = emptyList()
) {
 val leader: String
 get() = when {
 teamA > teamB -> "Team A"
 teamB > teamA -> "Team B"
 else -> "Draw"
 }
}
class ScoreViewModel(private val handle: SavedStateHandle = SavedStateHandle()) : ViewModel() {
 private val _uiState = MutableStateFlow(
 ScoreUiState(
 teamA = handle["a"] ?: 0,
 teamB = handle["b"] ?: 0,
 history = handle["history"] ?: emptyList()
 )
 )
 val uiState: StateFlow<ScoreUiState> = _uiState.asStateFlow()
 fun onScore(team: Char, points: Int) {
 if (points <= 0 || (team != 'A' && team != 'B')) return
 _uiState.update { s ->
 val entry = "$team+$points"
 if (team == 'A') s.copy(teamA = s.teamA + points, history = s.history + entry)
 else s.copy(teamB = s.teamB + points, history = s.history + entry)
 }
 save()
 }
 fun onUndo() {
 _uiState.update { s ->
 val last = s.history.lastOrNull() ?: return@update s
 val team = last[0]
 val points = last.substring(2).toInt()
 val rest = s.history.dropLast(1)
 if (team == 'A') s.copy(teamA = s.teamA - points, history = rest)
 else s.copy(teamB = s.teamB - points, history = rest)
 }
 save()
 }
 fun onReset() {
 _uiState.value = ScoreUiState()
 save()
 }
 private fun save() {
 val s = _uiState.value
 handle["a"] = s.teamA
 handle["b"] = s.teamB
 handle["history"] = ArrayList(s.history)
 }
}
ScoreViewModelTest.kt
package com.example.scorekeeper
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class ScoreViewModelTest {
 private lateinit var vm: ScoreViewModel
 @Before fun setUp() { vm = ScoreViewModel() }
 @Test fun initial_isDraw() = assertEquals("Draw", vm.uiState.value.leader)
 @Test fun scoring_updatesTeamA() {
 vm.onScore('A', 3)
 assertEquals(3, vm.uiState.value.teamA)
 assertEquals("Team A", vm.uiState.value.leader)
 }
 @Test fun scoring_updatesTeamB() {
 vm.onScore('B', 2)
 assertEquals("Team B", vm.uiState.value.leader)
 }
 @Test fun equalScores_areDraw() {
 vm.onScore('A', 2); vm.onScore('B', 2)
 assertEquals("Draw", vm.uiState.value.leader)
 }
 @Test fun undo_reversesLastScore() {
 vm.onScore('A', 3); vm.onScore('B', 1)
 vm.onUndo()
 assertEquals(0, vm.uiState.value.teamB)
 assertEquals(3, vm.uiState.value.teamA)
 assertEquals(1, vm.uiState.value.history.size)
 }
 @Test fun negativePoints_areIgnored() {
 vm.onScore('A', -5)
 assertEquals(0, vm.uiState.value.teamA)
 }
 @Test fun undoOnEmptyHistory_doesNothing() {
 vm.onUndo()
 assertEquals(ScoreUiState(), vm.uiState.value)
 }
}

Notes: the default argument SavedStateHandle() lets tests build the ViewModel with no framework help, while the real app receives a handle from the system. The history is stored as an ArrayList<String> because a SavedStateHandle accepts only Bundle-compatible types, and a Kotlin List implementation is not guaranteed to be one. In a local unit test SavedStateHandle() works because it is a plain class backed by a map.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 25 project”.

3

On github.com click New repository, name it android-lesson-25-ui-state-stateflow-and-unit-testing-the-vi, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 25 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: StateFlow in Depth
  • Surviving Process Death with SavedStateHandle
  • Step-by-Step Build 1: A Testable ViewModel
  • Step-by-Step Build 2: Your First ViewModel Test
  • Step-by-Step Build 3: Testing Coroutines and Dispatchers
  • Testing a Flow Value Over Time
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close