Search code examples
androidkotlinjunitkotlin-coroutinesdelay

Testing coroutine with delay


I have a method called fetchHabits() that calls an Use Case that fetches some data for me. In my implementation, if this usecase returns an error on a first call, I have to call it again after a 500ms delay and on a second call, it may return a success. If it does return a success, I update the data on my savedStateHandle with the data I got from the Use Case. This is exactly the scenario I want to test. Call the fetchHabits() method. mock my Use Case to return an error at first and then a success and see if the data was set on my savedStateHandle.

This is my ViewModel, where the fetchHabits() method is implemented:

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
    }

This is the class I want to test, which extends the HabitListViewModel class:

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"
}

The DispatcherHandler is an interface I use to handle Dispatchers:

@Suppress("PropertyName")
interface DispatcherHandler {

    val Default: CoroutineDispatcher

    val IO: CoroutineDispatcher

    val Main: CoroutineDispatcher

    val Unconfined: CoroutineDispatcher
}

In my production code, this is the implementation I use:

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
}

This is the implementation of my Use Case:

class GetCurrentHabitsUseCase<T : PeriodicHabit>(
    private val periodicHabitRepository: PeriodicHabitRepository<T>,
) {

    suspend operator fun invoke(): Result<List<T>> = resultBy {
        periodicHabitRepository.getHabitsForLastPeriod()
    }
}

In this test, this is what I'm using:

@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()
}

So far, my test class is like this:

@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)
            }
        }
} 

However, this is not working, and the error says it is only calling my usecase once:

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 {})

What I have tried so far:

  1. Change the advanceTimeBy(500) to advanceUntilIdle().

  2. Change the advanceTimeBy(500) to delay(500).

  3. Removing the advanceTimeBy(500) because I was sure the runTest would skip all the delays automaticly.

  4. Change the runTest { to runTest(UnconfinedTestDispatcher()) {

  5. Removing the advanceTimeBy(500) and calling the join method on the job returned by the fetchHabits() like this: viewModel.fetchHabits().join(), but it made my test fail waiting more than 60000ms.


I know I'm still new to Coroutines, so this might not be a big problem, but I can't figure out what is the problem :\


Solution

  • I solved the problem by passing the same UnconfinedTestDispatcher I'm injecting on my viewmodel to the runTest method and using the advanceUntilIdle() method on my test. To do it, I first created a DispatcherHandler implementation where I can pass the dispatcher I want so I can keep a reference on my test:

    @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
        )
    }
    

    Then I crearted a field for an UnconfinedTestDispatcher() on my test class:

    private val unconfinedTestDispatcher = UnconfinedTestDispatcher()
    

    And initialized my viewmodel passing this reference to the DispatcherHandlerCustom:

    private fun initializeViewModel() {
        viewModel = spyk(
            WeeklyHabitListViewModel(
                savedStateHandle,
                getCurrentWeeklyHabitsUseCase,
                finishWeeklyHabitUseCase,
                DispatcherHandlerCustom(unconfinedTestDispatcher)
            )
        )
    }
    

    Then, on my test cases, I just passed my unconfinedTestDispatcher reference to my runTest and called the advanceUntilIdle() to skip the delay:

    @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)
        }
    }