Testing Flows and ViewModels

Main Dispatcher Extension

viewModelScope uses Dispatchers.Main, which does not exist in local JVM tests. Replace it before each test with a small JUnit 5 extension:

class MainDispatcherExtension(
    val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : BeforeEachCallback, AfterEachCallback {
    override fun beforeEach(context: ExtensionContext) = Dispatchers.setMain(dispatcher)
    override fun afterEach(context: ExtensionContext) = Dispatchers.resetMain()
}

ViewModel Test with a Fake

class FakeProductRepository : ProductRepository {
    var failNext = false
    override suspend fun getProducts(): List<Product> {
        delay(100)
        if (failNext) throw IOException("offline")
        return listOf(Product(1, "Atta 5kg", 289))
    }
    override suspend fun addToCart(id: Long) {}
}

@ExtendWith(MainDispatcherExtension::class)
class ProductsViewModelTest {
    private val repo = FakeProductRepository()

    @Test
    fun `shows products after loading`() = runTest {
        val vm = ProductsViewModel(repo)
        assertEquals(ProductsUiState.Loading, vm.uiState.value)
        advanceUntilIdle()
        assertEquals(ProductsUiState.Success(listOf(Product(1, "Atta 5kg", 289))), vm.uiState.value)
    }

    @Test
    fun `shows error when offline`() = runTest {
        repo.failNext = true
        val vm = ProductsViewModel(repo)
        advanceUntilIdle()
        assertTrue(vm.uiState.value is ProductsUiState.Error)
    }
}

Flows with Turbine

Turbine makes flow assertions readable: awaitItem() suspends until the next emission and fails the test after a timeout.

@Test
fun `cart total updates when items are added`() = runTest {
    val store = CartStore(catalog)
    store.state.test {
        assertEquals(0, awaitItem().total)
        store.add("milk")
        assertEquals(30, awaitItem().total)
        store.add("bread")
        assertEquals(75, awaitItem().total)
        cancelAndIgnoreRemainingEvents()
    }
}

// Without Turbine: collect into a list in backgroundScope
@Test
fun `collects all values`() = runTest {
    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { counter.toList(values) }
    counter.value = 1
    counter.value = 2
    assertEquals(listOf(0, 1, 2), values)
}