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 {})
Change the advanceTimeBy(500)
to advanceUntilIdle()
.
Change the advanceTimeBy(500)
to delay(500)
.
Removing the advanceTimeBy(500)
because I was sure the runTest would skip all the delays automaticly.
Change the runTest {
to runTest(UnconfinedTestDispatcher()) {
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 :\
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)
}
}