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
StateFlowis: 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
runTestand a test dispatcher. - Explain why a
Dispatchers.Mainreplacement 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.
- 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.
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.
MutableStateFlow(initial) StateFlow (read-only view)
.value = new ───────────▶ .value (read the latest)
.update { old -> new } .collect { } (observe changes)
Three ways to change a MutableStateFlow
_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 (
valproperties). - Complete: the screen can render purely from it.
- Free of impossible combinations where possible. For a screen that loads data, a
sealed interfaceoften beats several booleans:
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.
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.
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 aStateFlowthat is always in sync with the saved value.savedStateHandle[key] = valuewrites 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.
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.
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.
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):
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:
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
@Testmarks a function JUnit should run. Each test gets a fresh instance of the test class.@Beforeruns 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 generateequalsfor 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:
./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.
Soon your ViewModels will launch coroutines with viewModelScope.launch (lesson 28). Consider a version that delays before applying a "bonus":
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:
viewModelScoperuns onDispatchers.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.- A real two-second
delaywould make the test slow.
The solution is the coroutines test library:
Dispatchers.setMain(testDispatcher)replaces the Main dispatcher for the test, andDispatchers.resetMain()restores it afterwards.runTest { }runs a coroutine with a virtual clock. Adelay(2_000)inside it completes instantly, because the test scheduler skips ahead.
A reusable JUnit rule installs and removes the dispatcher for every test:
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:
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:Ruleapplies the rule to each test. (Theget:is Kotlin syntax so JUnit sees the annotation on the getter.)StandardTestDispatcherdoes 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 therunTestscheduler and the rule's dispatcher must share a scheduler. When you create the ViewModel insiderunTestaftersetMain,runTestpicks up the Main test dispatcher's scheduler automatically, so they cooperate.
For the more complete treatment of flows and testing them, see lesson 33.
StateFlow exposes .value, so most tests simply read it. To check the sequence of emissions you can collect into a list:
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.)
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:
- Create
DessertUiState. - Create
DessertViewModelwithonDessertClicked. - Make
DessertClickerScreenstateless. - Connect through
viewModel()andcollectAsStateWithLifecycle(). - Delete the
rememberstate. - 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:
@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.
@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)
}
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.
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) }
}
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.
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.
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.
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
- Android Basics with Compose, Unit 4, Pathway 1: Write unit tests to test the ViewModel and the Dessert Clicker practice.
- UI layer: state production
- UI events
- Kotlin coroutines on Android
Project goal: Build a two-team scoreboard (for a football match or an ayo tournament) with full test coverage of its ViewModel.
Requirements:
ScoreUiState(teamA: Int, teamB: Int, history: List<String>)plus a computedleader: StringreturningTeam A,Team BorDraw.ScoreViewModelwithonScore(team, points),onUndo()andonReset(). Points must be positive. Undo removes the last entry from the history and reverses its effect.- A stateless screen and a connected top-level composable.
- At least six unit tests, including undo and the draw case.
- Persist the current scores across process death with
SavedStateHandle.
Stretch goal: show the history in a LazyColumn.
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)
}
}
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.
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.
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”.
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.
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.
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