У меня есть метод под названием fetchHabits(), который вызывает вариант использования, который извлекает для меня некоторые данные. В моей реализации, если этот вариант использования возвращает ошибку при первом вызове, я должен вызвать его снова после задержки в 500 мс, а при втором вызове он может вернуть успех. Если он возвращает успех, я обновляю данные в своем saveStateHandle данными, полученными из варианта использования. Это именно тот сценарий, который я хочу проверить. Вызовите метод fetchHabits(). смоделируйте мой вариант использования, чтобы сначала вернуть ошибку, а затем успех, и посмотреть, были ли данные установлены на моем savedStateHandle.
Это моя ViewModel, в которой реализован метод fetchHabits():
abstract class HabitListViewModel<T : PeriodicHabit>(
private val savedStateHandle: SavedStateHandle,
private val getCurrentDailyHabitsUseCase: GetCurrentHabitsUseCase<T>,
private val finishDailyHabitUseCase: FinishHabitUseCase<T>,
private val dispatcherHandler: DispatcherHandler,
) : ViewModel() {
@Suppress("PropertyName")
abstract val HABITS_KEY: String
@Suppress("LeakingThis")
val habits: StateFlow<PeriodicHabitResult<T>> = savedStateHandle.getStateFlow(
HABITS_KEY, PeriodicHabitResult.Loading
)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
var isFirstFetch = true
fun fetchHabits(): Job = viewModelScope.launch(dispatcherHandler.IO) {
setPeriodicHabitResult(PeriodicHabitResult.Loading)
val result = getCurrentDailyHabitsUseCase()
if (result is Result.Error) {
if (isFirstFetch) {
isFirstFetch = false
delay(500)
val secondResult = getCurrentDailyHabitsUseCase()
if (secondResult is Result.Error) {
setPeriodicHabitResult(PeriodicHabitResult.Error)
} else {
if (result.value?.isEmpty() != false) {
setPeriodicHabitResult(PeriodicHabitResult.EmptyList)
} else {
setPeriodicHabitResult(PeriodicHabitResult.Success(result.value))
}
}
fetchHabits()
} else {
setPeriodicHabitResult(PeriodicHabitResult.Error)
}
} else {
if (result.value?.isEmpty() != false) {
setPeriodicHabitResult(PeriodicHabitResult.EmptyList)
} else {
setPeriodicHabitResult(PeriodicHabitResult.Success(result.value))
}
}
}
private fun setPeriodicHabitResult(periodicHabitResult: PeriodicHabitResult<T>) {
savedStateHandle[HABITS_KEY] = periodicHabitResult
}
Это класс, который я хочу протестировать, расширяющий класс HabitListViewModel:
class WeeklyHabitListViewModel(
savedStateHandle: SavedStateHandle,
getCurrentHabitsUseCase: GetCurrentHabitsUseCase<WeeklyHabit>,
finishHabitUseCase: FinishHabitUseCase<WeeklyHabit>,
dispatcherHandler: DispatcherHandler,
) : HabitListViewModel<WeeklyHabit>(
savedStateHandle,
getCurrentHabitsUseCase,
finishHabitUseCase,
dispatcherHandler
) {
override val HABITS_KEY: String
get() = "WEEKLY_HABITS_KEY"
}
DispatcherHandler — это интерфейс, который я использую для работы с диспетчерами:
@Suppress("PropertyName")
interface DispatcherHandler {
val Default: CoroutineDispatcher
val IO: CoroutineDispatcher
val Main: CoroutineDispatcher
val Unconfined: CoroutineDispatcher
}
В моем производственном коде это реализация, которую я использую:
object DispatcherHandlerImpl : DispatcherHandler {
override val Default: CoroutineDispatcher
get() = Dispatchers.Default
override val IO: CoroutineDispatcher
get() = Dispatchers.IO
override val Main: CoroutineDispatcher
get() = Dispatchers.Main
override val Unconfined: CoroutineDispatcher
get() = Dispatchers.Unconfined
}
Это реализация моего варианта использования:
class GetCurrentHabitsUseCase<T : PeriodicHabit>(
private val periodicHabitRepository: PeriodicHabitRepository<T>,
) {
suspend operator fun invoke(): Result<List<T>> = resultBy {
periodicHabitRepository.getHabitsForLastPeriod()
}
}
В этом тесте я использую следующее:
@ExperimentalCoroutinesApi
object DispatcherHandlerUnconfined : DispatcherHandler {
override val Default: CoroutineDispatcher
get() = UnconfinedTestDispatcher()
override val IO: CoroutineDispatcher
get() = UnconfinedTestDispatcher()
override val Main: CoroutineDispatcher
get() = UnconfinedTestDispatcher()
override val Unconfined: CoroutineDispatcher
get() = UnconfinedTestDispatcher()
}
Пока что мой тестовый класс выглядит так:
@ExperimentalCoroutinesApi
class WeeklyHabitsViewModelTest {
@get:Rule
@ExperimentalCoroutinesApi
val coroutineTestRule = CoroutineTestRule()
@RelaxedMockK
private lateinit var savedStateHandle: SavedStateHandle
@RelaxedMockK
private lateinit var getCurrentWeeklyHabitsUseCase: GetCurrentHabitsUseCase<WeeklyHabit>
@RelaxedMockK
private lateinit var finishWeeklyHabitUseCase: FinishHabitUseCase<WeeklyHabit>
private lateinit var viewModel: WeeklyHabitListViewModel
companion object {
private const val WEEKLY_HABITS_KEY: String = "WEEKLY_HABITS_KEY"
}
init {
initMockKAnnotations()
mockInitialValueForHabitResult()
initializeViewModel()
}
private fun mockInitialValueForHabitResult() {
every {
savedStateHandle.getStateFlow(WEEKLY_HABITS_KEY, PeriodicHabitResult.Loading)
} returns MutableStateFlow(PeriodicHabitResult.Loading)
}
private fun initializeViewModel() {
viewModel = spyk(
WeeklyHabitListViewModel(
savedStateHandle,
getCurrentWeeklyHabitsUseCase,
finishWeeklyHabitUseCase,
DispatcherHandlerUnconfined
)
)
}
@Test
fun `GIVEN getCurrentWeeklyHabits returns an error once and then a success on the second attempt WHEN fetchHabits called THEN must set PeriodicHabitResult_Success`() =
runTest {
val throwable = Throwable()
val expectedList = listOf(FIRST_WEEKLY_HABIT, SECOND_WEEKLY_HABIT)
coEvery { getCurrentWeeklyHabitsUseCase() } returns throwable.toError() andThen expectedList.toSuccess()
viewModel.isFirstFetch = true
Assert.assertEquals(PeriodicHabitResult.Loading, viewModel.habits.value)
viewModel.fetchHabits()
advanceTimeBy(500)
coVerifyOrder {
savedStateHandle[WEEKLY_HABITS_KEY] = PeriodicHabitResult.Loading
getCurrentWeeklyHabitsUseCase()
getCurrentWeeklyHabitsUseCase()
savedStateHandle[WEEKLY_HABITS_KEY] = PeriodicHabitResult.Success(expectedList)
}
}
}
Однако это не работает, и ошибка говорит, что мой вариант использования вызывается только один раз:
Verification failed: fewer calls happened than demanded by order verification sequence.
Matchers:
+SavedStateHandle(savedStateHandle#3).set(eq(WEEKLY_HABITS_KEY), eq(com.hikarisource.smarthabits.presentation.features.list.viewmodel.PeriodicHabitResult$Loading@2f4ba1ae)))
+GetCurrentHabitsUseCase(getCurrentWeeklyHabitsUseCase#2).invoke(any()))
+GetCurrentHabitsUseCase(getCurrentWeeklyHabitsUseCase#2).invoke(any()))
SavedStateHandle(savedStateHandle#3).set(eq(WEEKLY_HABITS_KEY), eq(Success(data=[WeeklyHabit(id=1, description=Description one, completed=true, period=1), WeeklyHabit(id=2, description=Description two, completed=false, period=2)]))))
Calls:
1) SavedStateHandle(savedStateHandle#3).getStateFlow(WEEKLY_HABITS_KEY, com.hikarisource.smarthabits.presentation.features.list.viewmodel.PeriodicHabitResult$Loading@2f4ba1ae)
2) +SavedStateHandle(savedStateHandle#3).set(WEEKLY_HABITS_KEY, com.hikarisource.smarthabits.presentation.features.list.viewmodel.PeriodicHabitResult$Loading@2f4ba1ae)
3) +GetCurrentHabitsUseCase(getCurrentWeeklyHabitsUseCase#2).invoke(continuation {})
Замените advanceTimeBy(500) на advanceUntilIdle().
Замените advanceTimeBy(500) на delay(500).
Удаление advanceTimeBy(500), потому что я был уверен, что runTest автоматически пропустит все задержки.
Измените runTest { на runTest(UnconfinedTestDispatcher()) {
Удаление advanceTimeBy(500) и вызов метода соединения для задания, возвращаемого fetchHabits(), вот так: viewModel.fetchHabits().join(), но это привело к тому, что мой тест не прошел, ожидая более 60000 мс.
Я знаю, что я все еще новичок в Coroutines, так что это может не быть большой проблемой, но я не могу понять, в чем проблема :\
Вы ожидаете, что по прошествии времени код в вашей сопрограмме будет выполняться мгновенно и параллельно. Это неразумное ожидание, так как API advanceByTime не указывает, что он немедленно и полностью уступит всем ожидающим сопрограммам, прежде чем вернуть управление.
Кроме того, даже несмотря на то, что ваш поток обновления запускается в диспетчере ввода-вывода, вам также не гарантируется идеальный параллелизм, и ваш проверочный вызов происходит буквально через наносекунды после опережения времени.
Если вы вызываете delay(0) из своей тестовой процедуры после продвижения времени, этого может быть достаточно, чтобы передать управление достаточно долго, чтобы ваше обновление имело место (о семантике задержки 0 читайте в другом месте). Или даже delay(5) чтобы дать себе дополнительное место для диспетчеров.
@LeonardodeOliveiraSibela advanceUntilIdle работает отлично, как и вы: в контракте точно указано, что вы хотите: немедленно выполнить все ожидающие задачи и перевести виртуальное время до последней задержки, что фактически является выходом и сбросом. Это лучше, чем delay (после увеличения времени).
Я решил проблему, передав тот же самый UnconfinedTestDispatcher, который я вводил в свою модель представления, в метод runTest и используя метод advanceUntilIdle() в своем тесте. Для этого я сначала создал реализацию DispatcherHandler, в которой я могу передать нужный мне диспетчер, чтобы я мог сохранить ссылку на свой тест:
@ExperimentalCoroutinesApi
class DispatcherHandlerCustom(
override val Default: CoroutineDispatcher,
override val IO: CoroutineDispatcher,
override val Main: CoroutineDispatcher,
override val Unconfined: CoroutineDispatcher,
) : DispatcherHandler {
constructor(coroutineDispatcher: CoroutineDispatcher) : this(
coroutineDispatcher,
coroutineDispatcher,
coroutineDispatcher,
coroutineDispatcher
)
}
Затем я создал поле для UnconfinedTestDispatcher() в своем тестовом классе:
private val unconfinedTestDispatcher = UnconfinedTestDispatcher()
И инициализировал мою модель представления, передав эту ссылку DispatcherHandlerCustom:
private fun initializeViewModel() {
viewModel = spyk(
WeeklyHabitListViewModel(
savedStateHandle,
getCurrentWeeklyHabitsUseCase,
finishWeeklyHabitUseCase,
DispatcherHandlerCustom(unconfinedTestDispatcher)
)
)
}
Затем в своих тестовых примерах я просто передал свою ссылку unconfinedTestDispatcher моему runTest и вызвал advanceUntilIdle(), чтобы пропустить задержку:
@Test
fun `GIVEN getCurrentWeeklyHabits returns an error once and then a success on the second attempt WHEN fetchHabits called THEN must set PeriodicHabitResult_Success`() = runTest(unconfinedTestDispatcher) {
val expectedList = listOf(FIRST_WEEKLY_HABIT, SECOND_WEEKLY_HABIT)
coEvery { getCurrentWeeklyHabitsUseCase() } returns Throwable().toError() andThen expectedList.toSuccess()
viewModel.isFirstFetch = true
Assert.assertEquals(PeriodicHabitResult.Loading, viewModel.habits.value)
viewModel.fetchHabits()
advanceUntilIdle()
coVerifyOrder {
savedStateHandle[WEEKLY_HABITS_KEY] = PeriodicHabitResult.Loading
getCurrentWeeklyHabitsUseCase()
getCurrentWeeklyHabitsUseCase()
savedStateHandle[WEEKLY_HABITS_KEY] = PeriodicHabitResult.Success(expectedList)
}
}
Но как будет выглядеть код с этой задержкой? Куда мне его положить?