A ViewModel is a plain Kotlin class, so a test builds one, sends events and reads its state. The only Android dependency is viewModelScope, which launches on Dispatchers.Main, and the JVM has no main thread. Testing with Fake Dependencies swapped the dispatcher in @Before and @After; BookNest now packs those two calls into a JUnit 48,319 rule, MainDispatcherRule, a TestWatcher whose starting() calls Dispatchers.setMain(UnconfinedTestDispatcher()) and whose finished() calls Dispatchers.resetMain(), so each test class needs one line.
The cart's ViewModel (Unidirectional Data Flow and One-Time Events) has two outputs worth checking: the cart state and the one-time messages that feed the snackbar. The test collects messages in backgroundScope, which runTest cancels when the test ends:
@OptIn(ExperimentalCoroutinesApi::class)
class CartViewModelTest {
@get:Rule val main = MainDispatcherRule()
private val harbor = testBooks[0]
@Test fun toggleAddsThenRemovesAndSaysSo() = runTest {
val vm = CartViewModel(SavedStateHandle())
val messages = mutableListOf<String>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.messages.toList(messages) // collect every one-time message
}
vm.onEvent(CartEvent.Toggle(harbor))
assertEquals(listOf(1), vm.cart.value)
vm.onEvent(CartEvent.Toggle(harbor))
assertEquals(emptyList<Int>(), vm.cart.value)
assertEquals(listOf("Added The Quiet Harbor", "Removed The Quiet Harbor"), messages)
}A second test builds a new ViewModel from a SavedStateHandle holding the first one's "cart" list, as Android does after killing the process. testBooks is three books built in code, shared by every JVM test. Both passed, in 0.075 and 0.173 seconds. For longer flows, Cash App's Turbine 2,862 (https://github.com/cashapp/turbine 2,862 ) (app.cash.turbine:turbine:1.2.1) replaces the manual collector with vm.messages.test { ... awaitItem() ... }.