Search code examples
unit-testingmockitokotlin-coroutinesandroid-viewmodel

mock retrofit suspend function infinite response


I would like to test case when server does not return response, and we trigger the next network call ( like for example search query).

So we basically have a method inside ViewModel and Retrofit method

  interface RetrofitApi {
    @GET("Some Url")
    suspend fun getVeryImportantStuff(): String
}

class TestViewModel(private val api: RetrofitApi) : ViewModel() {

    private var askJob: Job? = null
    fun load(query: String) {
        askJob?.cancel()
        askJob = viewModelScope.launch {
            val response = api.getVeryImportantStuff()

            //DO SOMETHING WITH RESPONSE

        }
    }
}

And I would like to test case when new query is asked, and the old one didn't returns. for case when response returns test is easy

@Test
    fun testReturnResponse() {
        runBlockingTest {
            //given
            val mockApi:RetrofitApi = mock()
            val viewModel = TestViewModel(mockApi)
            val response = "response from api"

            val query = "fancy query"
            whenever(mockApi.getVeryImportantStuff()).thenReturn(response)

            //when
            viewModel.load(query)


            //then
            //verify what happens
        }
    }

But I don't know how to mock suspend function that did't come back, and test case when new request is triggered like this

@Test
    fun test2Loads() {
        runBlockingTest {
            //given
            val mockApi:RetrofitApi = mock()
            val viewModel = TestViewModel(mockApi)
            val response = "response from api"
            val secondResponse = "response from api2"

            val query = "fancy query"
            whenever(mockApi.getVeryImportantStuff())
                .thenReturn(/* Here return some fancy stuff that is suspend* or something like onBlocking{} stub but not  blocking but dalayed forever/)
                .thenReturn(secondResponse)

            //when
            viewModel.load(query)
            viewModel.load(query)


            //then
            //verify that first response did not happens , and only second one triggered all the stuff
        }
    }

Any ideas ?

EDIT: I'm not really attached to mockito, any mock library will be good :) regards Wojtek


Solution

  • I came up with kind of solution to the problem, but slightly different than I was thinking at the beginning

            interface CoroutineUtils {
                val io: CoroutineContext
            }
    
            interface RetrofitApi {
                @GET("Some Url")
                suspend fun getVeryImportantStuff(query: String): String
            }
    
            class TestViewModel(private val api: RetrofitApi,
                                private val utils: CoroutineUtils) : ViewModel() {
            private val text = MutableLiveData<String>()
            val testStream: LiveData<String> = text
            private var askJob: Job? = null
            fun load(query: String) {
                askJob?.cancel()
                askJob = viewModelScope.launch {
                    val response = withContext(utils.io) { api.getVeryImportantStuff(query) }
                    text.postValue(response)
                }
            }
        }
    

    And the test scenario would look like this

            class TestViewModelTest {
    
            @get:Rule
            val coroutineScope = MainCoroutineScopeRule()
            @get:Rule
            val instantTaskExecutorRule = InstantTaskExecutorRule()
    
    
            lateinit var retrofit: RetrofitApi
    
            lateinit var utils: CoroutineUtils
    
            val tottalyDifferentDispatcher = TestCoroutineDispatcher()
    
            lateinit var viewModel: TestViewModel
            @Before
            fun setup() {
                retrofit = mock()
                utils = mock()
                viewModel = TestViewModel(retrofit, utils)
            }
    
    
            @UseExperimental(ExperimentalCoroutinesApi::class)
            @Test
            fun test2Loads() {
                runBlockingTest {
                    //given
                    val response = "response from api"
                    val response2 = "response from api2"
                    val query = "fancy query"
                    val query2 = "fancy query2"
    
                    whenever(utils.io)
                        .thenReturn(tottalyDifferentDispatcher)
    
                    val mutableListOfStrings = mutableListOf<String>()
    
                    whenever(retrofit.getVeryImportantStuff(query)).thenReturn(response)
                    whenever(retrofit.getVeryImportantStuff(query2)).thenReturn(response2)
    
                    //when
    
                    viewModel.testStream.observeForever {
                        mutableListOfStrings.add(it)
                    }
                    tottalyDifferentDispatcher.pauseDispatcher()
                    viewModel.load(query)
                    viewModel.load(query2)
    
                    tottalyDifferentDispatcher.resumeDispatcher()
    
                    //then
                    mutableListOfStrings shouldHaveSize 1
                    mutableListOfStrings[0] shouldBe response2
                    verify(retrofit, times(1)).getVeryImportantStuff(query2)
                }
            }
        }
    

    It is not exactly what I wanted, because retrofit call is not triggered when load method is called for the first time, but it is the closest solution.

    What would be a perfect test for me will be assertion that retrofit was called twice , but only the second one returned to ViewModel. Solution for that will be to wrap Retrofit around method that returns suspend function like this

        interface RetrofitWrapper {
         suspend fun getVeryImportantStuff(): suspend (String)->String
        }
        class TestViewModel(private val api: RetrofitWrapper,
                            private val utils: CoroutineUtils) : ViewModel() {
    
            private val text = MutableLiveData<String>()
            val testStream: LiveData<String> = text
            private var askJob: Job? = null
            fun load(query: String) {
                askJob?.cancel()
                askJob = viewModelScope.launch {
                    val veryImportantStuff = api.getVeryImportantStuff()
                    val response = withContext(utils.io) {
                        veryImportantStuff(query)
                    }
                    text.postValue(response)
                }
            }
        }
    

    and test for it

        @Test
        fun test2Loads() {
            runBlockingTest {
                //given
                val response = "response from api"
                val response2 = "response from api2"
                val query = "fancy query"
                val query2 = "fancy query2"
    
                whenever(utils.io)
                    .thenReturn(tottalyDifferentDispatcher)
    
                val mutableListOfStrings = mutableListOf<String>()
    
                whenever(retrofit.getVeryImportantStuff())
                    .thenReturn(suspendCoroutine {
                        it.resume { response }
                    })
                whenever(retrofit.getVeryImportantStuff()).thenReturn(suspendCoroutine {
                    it.resume { response2 }
                })
    
                //when
    
                viewModel.testStream.observeForever {
                    mutableListOfStrings.add(it)
                }
                tottalyDifferentDispatcher.pauseDispatcher()
                viewModel.load(query)
                viewModel.load(query2)
    
                tottalyDifferentDispatcher.resumeDispatcher()
    
                //then
                mutableListOfStrings shouldHaveSize 1
                mutableListOfStrings[0] shouldBe response2
                verify(retrofit, times(2)).getVeryImportantStuff()
            }
        }
    

    But in my opinion it is a little bit too much in interference in code only to be testable. But maybe I'm wrong :P