https://kotlinlang.org logo
#coroutines
Title
# coroutines
j

jean

08/18/2020, 10:54 AM
In article like this one : https://quickbirdstudios.com/blog/android-mvi-kotlin-coroutines-flow/ I see the view model is handling intent by using
viewModelScope
to be able to use internal suspend function, and then use
lifeCycleScope
to observe the state flow variable. That leave us with two different coroutines, doesn’t it? Now I’m trying to test my code that use a similar approach and run in the situation where one of the mocked object used from the
viewModelScope
always return null regardless of the
whenever
mocking configuration. I stoped using the
init
block of my view model to jump start the handling of intent and replace it by a suspend fun, so now my view calls that function which returns the state flow to observe instead of the variable. This solved my null-value-mocked issue. Is that a better way way? Is there anything I’m not understanding?
👀 2
👍 1
g

Gabriel Feo

08/18/2020, 11:34 AM
The advantage of using viewModelScope I think would be to have any operations survive a configuration change. Can you share your code?
j

jean

08/18/2020, 11:47 AM
a bit long, but I can yes.
view model :
Copy code
class SearchViewModel(
    private val flow: Flow<SearchInputs>,
    private val searchRepository: SearchRepository
) : ViewModel() {

    private val initial = SearchFragmentState(
        isLoading = false,
        result = null,
        error = null
    )

    private val _state = MutableStateFlow(initial)

    suspend fun observeStateChange(): StateFlow<SearchFragmentState> {
        flow.transform { input ->
            when (input) {
                is SearchInputs.Search -> {
                    emit(SearchOutputs.Loading)
                    val output = search(input.organizationId, input.query)
                    emit(output)
                }
            }
        }
            .mapToState()

        return _state
    }

    private suspend fun search(organizationId: String, query: String): SearchOutputs {
        return when (val data = searchRepository.search(organizationId, query)) {
            is Either.Right -> SearchOutputs.SearchResult(data.value)
            is Either.Left -> SearchOutputs.SearchFail(data.value)
            null -> SearchOutputs.SearchFail()
        }
    }

    private suspend fun Flow<SearchOutputs>.mapToState() {
        collect { output ->
            when (output) {
                is SearchOutputs.Loading -> {
                    _state.value = SearchFragmentState(
                        isLoading = true,
                        result = null,
                        error = null
                    )
                }
                is SearchOutputs.SearchResult -> {
                    _state.value = SearchFragmentState(
                        isLoading = false,
                        result = output.data,
                        error = null
                    )
                }
                is SearchOutputs.SearchFail -> {
                    _state.value = SearchFragmentState(
                        isLoading = false,
                        result = null,
                        error = output.error ?: UnexpectedException
                    )
                }
            }
        }
    }
}
test :
Copy code
@ExperimentalTime
    @Test
    fun `should search and success`() = coroutineTestRule.runBlockingTest {
        val flow = flowOf(SearchInputs.Search("test-as", "test"))
        val viewModel = SearchViewModel(flow, searchRepository)

        whenever(searchRepository.search("test-as", "test"))
            .thenReturn(Either.Right(data))

        viewModel.observeStateChange().test {
            var state = expectItem()
            assertFalse(state.isLoading)
            assertNull(state.result)
            assertNull(state.error)

            state = expectItem()
            assertTrue(state.isLoading)
            assertNull(state.result)
            assertNull(state.error)

            state = expectItem()
            assertFalse(state.isLoading)
            assertEquals(data, state.result)
            assertNull(state.error)

            expectComplete()
        }
    }
I don’t have the null value problem anymore (
searchRepository.search
was returning null when I was using both scopes)
but now I have another issue, when I call
expectItem()
the first time I’m expecting the state equivalent to the
SearchOutputs.Loading
but it is skipped, and I get the state equivalent to
SearchOutputs.SearchResult
instead
if I comment out :
Copy code
_state.value = SearchFragmentState(
                        isLoading = false,
                        result = output.data,
                        error = null
                    )
inside the branch for
SearchOutputs.SearchResult
inside
mapToState()
then the test receives the state corresponding to
SearchOutputs.Loading
. So it feels like the sub-flow is emitting only it’s last value
So after some refactoring I extracted some of the code. Now the code handling transformation from inputs to outputs is inside its own class and the view model receive an instance of that class as constructor parameter. That allows me to test it independently and it works 🙂 My problem is really on testing the view model due to the use of
viewModelScope
and the mocking of calls within that scope. So back to the first issue. I have to say that if I run the app, the view receives each states as intended
3 Views