Тестирование сопрограммы с задержкой

У меня есть метод под названием 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 {})

Что я пробовал до сих пор:

  1. Замените advanceTimeBy(500) на advanceUntilIdle().

  2. Замените advanceTimeBy(500) на delay(500).

  3. Удаление advanceTimeBy(500), потому что я был уверен, что runTest автоматически пропустит все задержки.

  4. Измените runTest { на runTest(UnconfinedTestDispatcher()) {

  5. Удаление advanceTimeBy(500) и вызов метода соединения для задания, возвращаемого fetchHabits(), вот так: viewModel.fetchHabits().join(), но это привело к тому, что мой тест не прошел, ожидая более 60000 мс.


Я знаю, что я все еще новичок в Coroutines, так что это может не быть большой проблемой, но я не могу понять, в чем проблема :\

0
0
117
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Вы ожидаете, что по прошествии времени код в вашей сопрограмме будет выполняться мгновенно и параллельно. Это неразумное ожидание, так как API advanceByTime не указывает, что он немедленно и полностью уступит всем ожидающим сопрограммам, прежде чем вернуть управление.

Кроме того, даже несмотря на то, что ваш поток обновления запускается в диспетчере ввода-вывода, вам также не гарантируется идеальный параллелизм, и ваш проверочный вызов происходит буквально через наносекунды после опережения времени.

Если вы вызываете delay(0) из своей тестовой процедуры после продвижения времени, этого может быть достаточно, чтобы передать управление достаточно долго, чтобы ваше обновление имело место (о семантике задержки 0 читайте в другом месте). Или даже delay(5) чтобы дать себе дополнительное место для диспетчеров.

Но как будет выглядеть код с этой задержкой? Куда мне его положить?

Leonardo Sibela 25.04.2023 17:20

@LeonardodeOliveiraSibela advanceUntilIdle работает отлично, как и вы: в контракте точно указано, что вы хотите: немедленно выполнить все ожидающие задачи и перевести виртуальное время до последней задержки, что фактически является выходом и сбросом. Это лучше, чем delay (после увеличения времени).

Nino Walker 26.04.2023 18:42
Ответ принят как подходящий

Я решил проблему, передав тот же самый 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)
    }
}

Другие вопросы по теме