Test a ViewModel whose uiState: StateFlow is built by combine-ing several repository Flows
ProfileViewModel exposes uiState: StateFlow<ProfileUiState>, built inside viewModelScope by combine-ing two repository flows — the user and the settings.
Write the local JVM test (src/test) that asserts the state sequence the UI really sees, not just the last value. Constraints: no device, no mocking library — the two repositories are fakes you drive by hand; the production ViewModel must not change.
class ProfileViewModelTest {
private val users = FakeUserRepository() // exposes a MutableSharedFlow<User>
private val settings = FakeSettingsRepository() // exposes a MutableStateFlow<Settings>
@Test
fun `uiState combines user and settings`() = runTest {
// your code here
}
}
Write the implementation.
Swap Dispatchers.Main for StandardTestDispatcher() via setMain/resetMain, drive it with runTest, and feed the combine from fake repositories. Collect uiState with Turbine before emitting, then assert each awaitItem().
- ✗Reading
uiState.valueafter the fakes emit and seeing only the last state, because aStateFlowconflates - ✗Leaving the real
Dispatchers.Mainin place, soviewModelScopecannot even start on a local JVM - ✗Expecting
combineto emit before every input flow has produced its first value
- →Why does a conflating
StateFlowhide states from a test that only readsuiState.valueat the end? - →How would the assertions change if one of the combined repository flows never emits at all?
The solution
Three things have to line up at once: viewModelScope needs a test dispatcher instead of Dispatchers.Main, the inputs must be under the test's control, and collection of uiState must start before the first emission.
class ProfileViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val users = FakeUserRepository()
private val settings = FakeSettingsRepository()
@Before fun setUp() = Dispatchers.setMain(dispatcher) // ✅ Main → the test scheduler
@After fun tearDown() = Dispatchers.resetMain()
@Test
fun `uiState combines user and settings`() = runTest {
val viewModel = ProfileViewModel(users, settings)
viewModel.uiState.test { // ✅ collect BEFORE emitting
assertEquals(ProfileUiState.Empty, awaitItem()) // the StateFlow's initial value
users.emit(User("Ann"))
settings.emit(Settings(dark = true))
runCurrent()
val state = awaitItem() // combine produced its first combined item
assertEquals("Ann", state.name)
assertTrue(state.dark)
cancelAndIgnoreRemainingEvents() // a StateFlow never completes
}
}
}
Why each piece is there:
Dispatchers.setMain.viewModelScopedispatches onDispatchers.Main, which is backed by the Android main looper — absent on a local JVM.resetMain()in@Afteris not optional: without it the substitution leaks into the next test class.- Fakes, not mocks. The repositories expose a
MutableSharedFlow/MutableStateFlowand the test decides what is emitted and when. A mock would have to be pre-programmed with the sequence, so the test would be asserting on the stub instead of on thecombine. combinewaits for everyone. It emits nothing until every input has produced at least one value, so emitting fromusersalone yields no state at all.- A
StateFlowconflates. Emit first and readuiState.valueafterwards and the intermediate states are already gone. Collecting withTurbinestarts before the emissions, andawaitItem()pulls the sequence out in order.