How to test ViewModel + Flow with API call from init{}

4.1k views Asked by At

I have ViewModel which exposes flow to fragment. I am calling API from ViewModel's init which emits different states. I am not able to write unit test to check all the emitted states.

My ViewModel

class FooViewModel constructor( fooProvider : FooProvider){

private val _uiState = MutableSharedFlow<UiState>(replay = 1)
// Used in fragment to collect ui states.
val uiState = _uiState.asSharedFlow()

init{
_uiState.emit(FetchingFoo)
viewModelScope.runCatching {
// Fetch shareable link from server [users.sharedInvites.list].
fooProvider.fetchFoo().await()
}.fold(
onSuccess = { 
_uiState.emit(FoundFoo)
},
onFailure = {
_uiState.emit(EmptyFoo)
}
)
}

sealed class UiState {
object FetchingFoo : UiState()
object FoundFoo : UiState()
object EmptyFoo : UiState()
}
}

Now I want to test this ViewModel to check if all the states are being emitted.

My Test: Note I am using turbine library.

class FooViewModelTest{

@Mock
private lateinit var fooProvider : FooProvider

@Test
fun testFooFetch() = runTest {
 whenever(fooProvider.fetchFoo()).thenReturn(// Expected API response)

val fooViewModel = FooViewModel(fooProvider)
// Here lies the problem. as we create fooViewModel object API is called.
// before reaching test block.
fooViewModel.uiState.test{
// This condition fails as fooViewModel.uiState is now at FoundFoo.
assertEquals(FetchingFoo, awaitItem())
assertEquals(FoundFoo, awaitItem())
}
}
}

How to delay init till inside on .test{} block. Tried creating ViewModel object by Lazy{} but not working.

1

There are 1 answers

0
Mark On

It is not very pragmatic to "delay" emissions for sake of testing, this may produce flakey tests.

This is more of a coding issue - the right question should be "Does this logic belong in the class initialisation. The fact that it is more difficult to test should give you hints that it is less than ideal.

A better solution would be to use a StateFlow which is lazily initialised something like (some code assumed for sake of testing) :

class FooViewModel constructor(private val fooProvider: FooProvider) : ViewModel() {

    val uiState: StateFlow<UiState> by lazy {
        flow<UiState> {
            emit(FoundFoo(fooProvider.fetchFoo()))
        }.catch { emit(EmptyFoo) }
            .flowOn(Dispatchers.IO)
            .stateIn(
                scope = viewModelScope,
                started = WhileSubscribed(5_000),
                initialValue = FetchingFoo)
    }

    sealed class UiState {
        object FetchingFoo : UiState()
        data class FoundFoo(val list: List<Any>) : UiState()
        object EmptyFoo : UiState()
    }
}

fun interface FooProvider {

    suspend fun fetchFoo(): List<Any>
} 

Then testing could be something like :

class FooViewModelTest {

    @ExperimentalCoroutinesApi
    @Test fun `whenObservingUiState thenCorrectStatesObserved`() = runTest {
        val states = mutableListOf<UiState>()
        FooViewModel { emptyList() }
            .uiState
            .take(2)
            .toList(states)

        assertEquals(2, states.size)
        assertEquals(listOf(FetchingFoo, FoundFoo(emptyList()), states)
    }

    @ExperimentalCoroutinesApi
    @Test fun `whenObservingUiStateAndErrorOccurs thenCorrectStatesObserved`() = runTest {
        val states = mutableListOf<UiState>()
        FooViewModel { throw IllegalStateException() }
            .uiState
            .take(2)
            .toList(states)

        assertEquals(2, states.size)
        assertEquals(listOf(FetchingFoo, EmptFoo), states)
    }
}

addotional test dependencies :

testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
testImplementation "android.arch.core:core-testing:1.1.1"