Протестируйте ViewModel, чей uiState: StateFlow собран через combine из нескольких Flow repository
ProfileViewModel отдаёт наружу uiState: StateFlow<ProfileUiState>, который собирается внутри viewModelScope через combine из двух Flow repository — пользователя и настроек.
Напишите локальный JVM-тест (src/test), проверяющий именно ту последовательность состояний, которую видит UI, а не только последнее значение. Ограничения: без устройства и без библиотеки моков — оба repository являются fake, которыми вы управляете руками; боевой ViewModel менять нельзя.
class ProfileViewModelTest {
private val users = FakeUserRepository() // отдаёт MutableSharedFlow<User>
private val settings = FakeSettingsRepository() // отдаёт MutableStateFlow<Settings>
@Test
fun `uiState combines user and settings`() = runTest {
// ваш код здесь
}
}
Допишите реализацию.
Подмените Dispatchers.Main на StandardTestDispatcher() через setMain/resetMain, запускайте тест в runTest и питайте combine из fake-repository. Собирайте uiState через Turbine до первых эмиссий, а затем проверяйте каждый awaitItem().
- ✗Читать
uiState.valueпосле эмиссий fake и видеть лишь последнее состояние, ведьStateFlowсхлопывает значения - ✗Оставлять настоящий
Dispatchers.Main, из-за чегоviewModelScopeвообще не стартует на локальной JVM - ✗Ждать от
combineэмиссии раньше, чем каждый входной поток выдал своё первое значение
- →Почему схлопывающий
StateFlowскрывает состояния от теста, который лишь читаетuiState.valueв конце? - →Как изменятся проверки, если один из объединяемых
Flowrepository вообще ничего не выдаст?
Решение
Три вещи должны сойтись разом: viewModelScope обязан получить тестовый диспетчер вместо Dispatchers.Main, входы должны быть под контролем теста, а сбор uiState — начаться до первых эмиссий.
class ProfileViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val users = FakeUserRepository()
private val settings = FakeSettingsRepository()
@Before fun setUp() = Dispatchers.setMain(dispatcher) // ✅ Main → тестовый планировщик
@After fun tearDown() = Dispatchers.resetMain()
@Test
fun `uiState combines user and settings`() = runTest {
val viewModel = ProfileViewModel(users, settings)
viewModel.uiState.test { // ✅ собираем ДО эмиссий
assertEquals(ProfileUiState.Empty, awaitItem()) // начальное значение StateFlow
users.emit(User("Ann"))
settings.emit(Settings(dark = true))
runCurrent()
val state = awaitItem() // combine выдал первый объединённый элемент
assertEquals("Ann", state.name)
assertTrue(state.dark)
cancelAndIgnoreRemainingEvents() // StateFlow никогда не завершается
}
}
}
Почему именно так:
Dispatchers.setMain.viewModelScopeработает наDispatchers.Main, а за ним стоит главный looper Android, которого на локальной JVM нет.resetMain()в@Afterобязателен — иначе подмена утечёт в следующий тестовый класс.- Fake, а не mock. Repository отдают
MutableSharedFlow/MutableStateFlow, и тест сам решает, когда и что выдать. Мок пришлось бы настраивать на заранее известную последовательность — тогда тест проверял бы заглушку, а неcombine. combineждёт всех. Он не выдаёт ничего, пока каждый вход не произвёл хотя бы одно значение, поэтому эмиссия только изusersсостояния не породит.StateFlowсхлопывает. Если сначала выдать значения, а потом читатьuiState.value, промежуточные состояния уже потеряны. Сбор черезTurbineначинается раньше эмиссий, иawaitItem()вытягивает последовательность по порядку.