Testing Android ViewModels with Kotlin Flow, Turbine, and Coroutines
ViewModels are the core of Android architecture: they hold state, handle business logic, and expose data via Kotlin Flow. Testing them thoroughly — including loading states, error handling, and side effects — requires understanding how to control coroutines and observe Flow emissions in tests.
This guide covers ViewModel testing with TestDispatcher, Turbine (Flow testing library), and StateFlow/SharedFlow patterns.
Setup
// app/build.gradle.kts
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
testImplementation("app.cash.turbine:turbine:1.1.0")
testImplementation("io.mockk:mockk:1.13.10")
testImplementation("com.google.truth:truth:1.4.2")
testImplementation("junit:junit:4.13.2")
}Test Dispatcher Setup
Without test dispatcher control, coroutines started in viewModelScope don't complete synchronously:
// MainDispatcherRule.kt
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description?) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description?) {
Dispatchers.resetMain()
}
}Use UnconfinedTestDispatcher (runs coroutines eagerly, good for most ViewModel tests) or StandardTestDispatcher (requires manual advanceUntilIdle(), good for testing timing).
Basic ViewModel Test
@HiltViewModel
class OrderListViewModel @Inject constructor(
private val repository: OrderRepository
) : ViewModel() {
sealed class UiState {
object Loading : UiState()
data class Success(val orders: List<Order>) : UiState()
data class Error(val message: String) : UiState()
}
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
loadOrders()
}
fun loadOrders() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val orders = repository.getOrders()
_uiState.value = UiState.Success(orders)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}class OrderListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val repository = mockk<OrderRepository>()
private lateinit var viewModel: OrderListViewModel
@Test
fun `initial load shows orders`() = runTest {
val orders = listOf(
Order("ORD-1", 50.0, OrderStatus.COMPLETED),
Order("ORD-2", 30.0, OrderStatus.PENDING),
)
coEvery { repository.getOrders() } returns orders
viewModel = OrderListViewModel(repository)
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(OrderListViewModel.UiState.Success::class.java)
assertThat((state as OrderListViewModel.UiState.Success).orders).isEqualTo(orders)
}
@Test
fun `load failure shows error`() = runTest {
coEvery { repository.getOrders() } throws IOException("Network error")
viewModel = OrderListViewModel(repository)
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(OrderListViewModel.UiState.Error::class.java)
assertThat((state as OrderListViewModel.UiState.Error).message).contains("Network error")
}
}Testing with Turbine
Turbine makes testing Flow emissions clean and readable:
class OrderListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
private val repository = mockk<OrderRepository>()
@Test
fun `loadOrders emits loading then success`() = runTest {
val orders = listOf(Order("ORD-1", 50.0, OrderStatus.COMPLETED))
coEvery { repository.getOrders() } returns orders
val viewModel = OrderListViewModel(repository)
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(OrderListViewModel.UiState.Loading)
advanceUntilIdle()
assertThat(awaitItem())
.isEqualTo(OrderListViewModel.UiState.Success(orders))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loadOrders emits loading then error on failure`() = runTest {
coEvery { repository.getOrders() } throws RuntimeException("Server error")
val viewModel = OrderListViewModel(repository)
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(OrderListViewModel.UiState.Loading)
advanceUntilIdle()
val errorState = awaitItem()
assertThat(errorState).isInstanceOf(OrderListViewModel.UiState.Error::class.java)
assertThat((errorState as OrderListViewModel.UiState.Error).message)
.contains("Server error")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `refresh reloads orders`() = runTest {
val initialOrders = listOf(Order("ORD-1", 50.0, OrderStatus.COMPLETED))
val refreshedOrders = listOf(
Order("ORD-1", 50.0, OrderStatus.COMPLETED),
Order("ORD-2", 30.0, OrderStatus.PENDING),
)
coEvery { repository.getOrders() } returnsMany listOf(initialOrders, refreshedOrders)
val viewModel = OrderListViewModel(repository)
viewModel.uiState.test {
skipItems(2) // skip Loading + initial Success
viewModel.loadOrders()
advanceUntilIdle()
assertThat(awaitItem()).isEqualTo(OrderListViewModel.UiState.Loading)
assertThat(awaitItem())
.isEqualTo(OrderListViewModel.UiState.Success(refreshedOrders))
cancelAndIgnoreRemainingEvents()
}
}
}Testing SharedFlow (Side Effects / Events)
Use SharedFlow for one-time events (navigation, snackbar, error dialog):
class CheckoutViewModel @Inject constructor(
private val paymentService: PaymentService,
private val cartRepository: CartRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(CheckoutUiState())
val uiState: StateFlow<CheckoutUiState> = _uiState.asStateFlow()
// One-time events
private val _events = MutableSharedFlow<CheckoutEvent>()
val events: SharedFlow<CheckoutEvent> = _events.asSharedFlow()
fun checkout() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val result = paymentService.processPayment(_uiState.value.cartId)
cartRepository.clear(_uiState.value.cartId)
_events.emit(CheckoutEvent.NavigateToConfirmation(result.orderId))
} catch (e: PaymentException) {
_events.emit(CheckoutEvent.ShowError(e.message ?: "Payment failed"))
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
sealed class CheckoutEvent {
data class NavigateToConfirmation(val orderId: String) : CheckoutEvent()
data class ShowError(val message: String) : CheckoutEvent()
}class CheckoutViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val paymentService = mockk<PaymentService>()
private val cartRepository = mockk<CartRepository>(relaxed = true)
@Test
fun `checkout success emits navigation event`() = runTest {
coEvery { paymentService.processPayment(any()) } returns PaymentResult(orderId = "ORD-999")
val viewModel = CheckoutViewModel(paymentService, cartRepository)
viewModel.events.test {
viewModel.checkout()
val event = awaitItem()
assertThat(event).isInstanceOf(CheckoutEvent.NavigateToConfirmation::class.java)
assertThat((event as CheckoutEvent.NavigateToConfirmation).orderId).isEqualTo("ORD-999")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `checkout failure emits error event`() = runTest {
coEvery { paymentService.processPayment(any()) } throws PaymentException("Card declined")
val viewModel = CheckoutViewModel(paymentService, cartRepository)
viewModel.events.test {
viewModel.checkout()
val event = awaitItem()
assertThat(event).isInstanceOf(CheckoutEvent.ShowError::class.java)
assertThat((event as CheckoutEvent.ShowError).message).contains("Card declined")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `checkout clears cart on success`() = runTest {
coEvery { paymentService.processPayment(any()) } returns PaymentResult(orderId = "ORD-999")
val viewModel = CheckoutViewModel(paymentService, cartRepository)
viewModel.checkout()
coVerify { cartRepository.clear(any()) }
}
@Test
fun `loading state is true during checkout then false after`() = runTest {
coEvery { paymentService.processPayment(any()) } returns PaymentResult(orderId = "ORD-999")
val viewModel = CheckoutViewModel(paymentService, cartRepository)
viewModel.uiState.test {
val initial = awaitItem()
assertThat(initial.isLoading).isFalse()
viewModel.checkout()
// Can't test loading=true in UnconfinedTestDispatcher (runs eagerly)
// Use StandardTestDispatcher for this:
val final = awaitItem()
assertThat(final.isLoading).isFalse() // loading cleared after completion
cancelAndIgnoreRemainingEvents()
}
}
}Testing Loading State Transitions
To test the loading state during execution, use StandardTestDispatcher:
class CheckoutViewModelLoadingTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
@Test
fun `loading is true during checkout`() = runTest {
val paymentService = mockk<PaymentService>()
val cartRepository = mockk<CartRepository>(relaxed = true)
coEvery { paymentService.processPayment(any()) } coAnswers {
delay(1000) // simulate network delay
PaymentResult(orderId = "ORD-999")
}
val viewModel = CheckoutViewModel(paymentService, cartRepository)
viewModel.uiState.test {
assertThat(awaitItem().isLoading).isFalse() // initial state
viewModel.checkout()
advanceTimeBy(100) // start coroutine but don't finish
assertThat(awaitItem().isLoading).isTrue() // loading during payment
advanceUntilIdle() // complete the payment
assertThat(awaitItem().isLoading).isFalse() // done
cancelAndIgnoreRemainingEvents()
}
}
}Testing Flow Transformation in ViewModel
When ViewModel transforms a repository Flow:
class SearchViewModel @Inject constructor(
private val repository: ProductRepository
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
val searchResults: StateFlow<List<Product>> = searchQuery
.debounce(300)
.flatMapLatest { query ->
if (query.isBlank()) flowOf(emptyList())
else repository.search(query)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun onSearch(query: String) {
searchQuery.value = query
}
}class SearchViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
@Test
fun `search query returns results`() = runTest {
val repository = mockk<ProductRepository>()
val results = listOf(Product("Widget Pro", 49.99))
coEvery { repository.search("widget") } returns flowOf(results)
val viewModel = SearchViewModel(repository)
viewModel.searchResults.test {
assertThat(awaitItem()).isEmpty() // initial empty state
viewModel.onSearch("widget")
advanceTimeBy(400) // past the 300ms debounce
assertThat(awaitItem()).isEqualTo(results)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `empty query clears results`() = runTest {
val repository = mockk<ProductRepository>()
coEvery { repository.search(any()) } returns flowOf(
listOf(Product("Widget", 10.0))
)
val viewModel = SearchViewModel(repository)
viewModel.searchResults.test {
skipItems(1) // skip initial empty
viewModel.onSearch("widget")
advanceTimeBy(400)
skipItems(1) // skip results
viewModel.onSearch("")
advanceTimeBy(400)
assertThat(awaitItem()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
}Fake Repository vs MockK
For repositories with complex state, prefer fakes:
class FakeOrderRepository : OrderRepository {
private val orders = MutableStateFlow<List<Order>>(emptyList())
private var shouldThrow: Exception? = null
fun addOrder(order: Order) {
orders.value = orders.value + order
}
fun setError(e: Exception) {
shouldThrow = e
}
override fun observeOrders(): Flow<List<Order>> = orders
override suspend fun getOrders(): List<Order> {
shouldThrow?.let { throw it }
return orders.value
}
override suspend fun updateOrder(order: Order) {
orders.value = orders.value.map { if (it.id == order.id) order else it }
}
}Use fakes when: the repository has state that tests need to control (add orders, trigger updates). Use MockK when: you just need to stub return values and verify calls.
Common Pitfalls
StateFlow doesn't emit the same value twice. If your test sets the same value, Turbine's awaitItem() hangs. Check for distinctUntilChanged behavior.
SharingStarted.WhileSubscribed and tests: stateIn with WhileSubscribed only subscribes when there's a collector. In Turbine's test {} block, the subscription exists. Outside of it, it doesn't. Subscribe inside test {}.
UnconfinedTestDispatcher skips loading states: UnconfinedTestDispatcher runs coroutines synchronously before returning to the caller. Use StandardTestDispatcher when you need to assert intermediate states like isLoading = true.
Not canceling Turbine: Always end a test {} block with cancelAndIgnoreRemainingEvents(), cancelAndConsumeRemainingEvents(), or consuming all items. Turbine fails the test if you leave items unconsumed.
Summary
ViewModel testing with Flow requires three things: a MainDispatcherRule to control coroutines, Turbine for observing Flow emissions in order, and clear fake/mock collaborators. Use StandardTestDispatcher + advanceUntilIdle() when testing state transitions that involve timing. Use UnconfinedTestDispatcher for simpler tests where you only care about the final state. Test every ViewModel state: loading, success, error, and any side effects via SharedFlow events.