Testing ViewModels and coroutines
A ViewModel is the best place to test an Android app’s behavior: it’s plain Kotlin, it decides what the screen shows, and with fakes it runs without network or database. Two things need setting up because of coroutines:
viewModelScopeuses the Main dispatcher, which doesn’t exist on your computer’s JVM. A small rule replaces it with a test dispatcher.- Tests run inside
runTest, which runs coroutines on a virtual clock:delay(5_000)takes no real time.
kotlinx-coroutines-test provides runTest (virtual time, fails on uncaught exceptions and leaked coroutines after a timeout), StandardTestDispatcher (queues coroutines until the scheduler advances: explicit control) and UnconfinedTestDispatcher (runs eagerly: simpler, less control). Dispatchers.setMain swaps the Main dispatcher used by viewModelScope; share the scheduler between Main and any injected dispatchers so advanceUntilIdle() drives everything. Inject dispatchers (ioDispatcher) rather than hard-coding Dispatchers.IO, or virtual time won’t apply to that work.
// A JUnit rule that swaps Dispatchers.Main for a test dispatcher
class MainDispatcherRule(
val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
class FakeCartRepository : CartRepository {
var items = listOf<CartItem>()
var couponResult: Result<Discount> = Result.success(Discount.None)
override suspend fun items() = items
override suspend fun applyCoupon(code: String) = couponResult.getOrThrow()
}
class CartViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
private val repository = FakeCartRepository()
@Test
fun `shows the total with the coupon applied`() = runTest {
repository.items = listOf(CartItem("lamp", priceCents = 5_000, quantity = 2))
repository.couponResult = Result.success(Discount.Percent(10))
val viewModel = CartViewModel(repository)
viewModel.applyCoupon("SAVE10")
advanceUntilIdle() // let the launched coroutines finish
assertEquals(9_000, (viewModel.state.value as CartState.Loaded).totalCents)
}
@Test
fun `keeps the cart and shows an error when the coupon fails`() = runTest {
repository.items = listOf(CartItem("lamp", priceCents = 5_000, quantity = 1))
repository.couponResult = Result.failure(IOException())
val viewModel = CartViewModel(repository)
viewModel.applyCoupon("SAVE10")
advanceUntilIdle()
val state = viewModel.state.value as CartState.Loaded
assertEquals(5_000, state.totalCents)
assertEquals("Couldn't apply the coupon. Check your connection.", state.message)
}
}MainDispatcherRuleis written once and used in every ViewModel test.- The fake repository returns exactly what each test sets, so the failing-coupon case is one line, not airplane mode at the right moment.
advanceUntilIdle()runs everything the ViewModel launched, then the test checks the final state.- Backtick names describe the behavior in plain words; when a test fails, the name says what broke.
Tap the line with the bug.
class ProfileViewModelTest {
@get:Rule val mainRule = MainDispatcherRule() // StandardTestDispatcher
@Test
fun loadsTheProfile() = runTest {
val viewModel = ProfileViewModel(FakeProfileRepository(name = "Ana"))
viewModel.load()
assertEquals(ProfileState.Loaded("Ana"), viewModel.state.value)
}
}Hint: load() calls viewModelScope.launch. With a StandardTestDispatcher, when does that coroutine actually run?
load() launches a coroutine and returns immediately. A StandardTestDispatcher only queues it; nothing runs until the test scheduler advances. So the assertion sees the initial Loading state and the test fails, even though the ViewModel is correct. (With UnconfinedTestDispatcher it would pass here, but break again as soon as the repository uses delay or another dispatcher.)
Let the queued work finish before asserting:
viewModel.load()
advanceUntilIdle()
assertEquals(ProfileState.Loaded("Ana"), viewModel.state.value)Never fix this with Thread.sleep: virtual time doesn’t advance with real time, so sleeping makes the test slower and still fails.
Predict the output
A ViewModel retries a failed request after delay(10_000), up to three times. A test with a fake that always fails calls load() and then advanceUntilIdle() inside runTest. How long does the test take to run?
runTest uses virtual time: advanceUntilIdle() jumps the clock forward past each delay(10_000) instantly, so the three retries complete in milliseconds, and the test can even assert that currentTime is 30,000. That only works for delays on the test scheduler; work hard-coded to Dispatchers.IO or using real Thread.sleep runs in real time, which is why dispatchers should be injected.
- JVM tests with fakes;
Dispatchers.setMainvia a rule;runTestvirtual time;advanceUntilIdle/runCurrentwithStandardTestDispatcher. - Inject dispatchers and share the test scheduler; hard-coded
Dispatchers.IOescapes virtual time. - Never
Thread.sleep; assert final exposed state.
Check this part
Why do ViewModel tests need a MainDispatcherRule?
What does advanceUntilIdle() do?
Why inject an ioDispatcher instead of using Dispatchers.IO directly?