【问题标题】:mock retrofit suspend function infinite response模拟改造挂起功能无限响应
【发布时间】:2020-02-25 12:02:45
【问题描述】:

我想测试服务器没有返回响应的情况,我们触发下一个网络调用(例如搜索查询)。

所以我们基本上在 ViewModel 和 Retrofit 方法中都有一个方法

  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

        }
    }
}

并且我想在询问新查询时测试用例,而旧查询没有返回。 对于响应返回测试的情况很容易

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

但我不知道如何模拟没有返回的挂起函数,以及像这样触发新请求时的测试用例

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

有什么想法吗?

编辑:我并不真正喜欢 mockito,任何模拟库都会很好:) 问候 沃伊泰克

【问题讨论】:

    标签: unit-testing mockito kotlin-coroutines android-viewmodel


    【解决方案1】:

    我想出了解决问题的办法,但与我一开始的想法略有不同

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

    测试场景应该是这样的

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

    这不是我想要的,因为第一次调用 load 方法时不会触发改造调用,但它是最接近的解决方案。

    对我来说完美的测试是断言改造被调用了两次,但只有第二次返回给 ViewModel。解决方案是将 Retrofit 包裹在返回挂起函数的方法周围

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

    并测试它

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

    但在我看来,对代码的干扰有点太多了,只能进行测试。但也许我错了:P

    【讨论】:

    • 哦,太好了!我正在考虑类似的事情,但我总是使用val io: CoroutineDispatcher。老实说,这看起来有点矫枉过正,但现在我们知道这是可行的。
    • 是的,但如果你想出更好的东西,请不要犹豫分享:D 我不喜欢这个
    【解决方案2】:

    当您有无法访问的服务器、超时或类似情况时,您似乎想要测试场景。

    在这种情况下,在进行模拟时,您可以说第一次尝试返回对象,然后在第二次执行时抛出异常,例如 java.net.ConnectException: Connection timed out

                    whenever(mockApi.getVeryImportantStuff())
                    .thenReturn(someObjet)
                    .thenThrow(ConnectException("timed out"))
    

    这应该可以,但你必须在 ViewModel 中执行 try/catch 块,这并不理想。我建议您添加额外的抽象。

    您可以RepositoryUseCase 或您喜欢的任何模式/名称将网络呼叫移到那里。然后引入sealed class Result 来封装行为,让你的ViewModel 更具可读性。

    class TestViewModel(val repo: Repo): ViewModel() {
        private var askJob: Job? = null
    
        fun load(query: String) {
            askJob?.cancel()
            askJob = viewModelScope.launch {
                when (repo.getStuff()) {
                    is Result.Success -> TODO()
                    is Result.Failure -> TODO()
                }
            }
        }
    }
    
    class Repo(private val api: Api) {
        suspend fun getStuff() : Result {
            return try {
                Result.Success(api.getVeryImportantStuff())
            } catch (e: java.lang.Exception) {
                Result.Failure(e)
            }
        }
    }
    
    sealed class Result {
        data class Success<out T: Any>(val data: T) : Result()
        data class Failure(val error: Throwable) : Result()
    }
    
    interface Api {
        suspend fun getVeryImportantStuff() : String
    }
    

    使用这种抽象级别,您的 ViewModelTest 只检查两种情况下发生的情况。

    希望对你有帮助!

    【讨论】:

    • 不是真的,因为问题是返回暂停,而不是异常。问题是在第一个响应没有返回时测试用例,并且触发了第二个请求。将改造传递给 ViewModel 只是为了简单起见。但是感谢您的努力
    • 我认为你不了解协程是如何工作的,或者我们无法相互理解问题所在。协程挂起函数确实是异步执行的,所以基本上两次调用查询方法会触发 2 个不同的作业(在第一个作业未被取消的情况下)。目标是测试第一个被取消。在那个简单的场景中,但是还有很多地方可以使用这种模拟
    • 是的,看来我们不了解对方。在您的场景中使用 runBlockingTest 将不起作用,因为每个挂起的值都将立即返回,并且所有协程都必须完成或被取消。我认为您的测试场景不适用于不支持线程的 JUnit 框架。
    • 检查我的答案:)
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2021-05-08
    • 2013-01-02
    • 1970-01-01
    • 2020-02-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多