Home > OS >  Unexpected behaviour with Kotlin Coroutine Error Handling using Retrofit2
Unexpected behaviour with Kotlin Coroutine Error Handling using Retrofit2

Time:09-22

I'm currently writing unit tests for a small Android app and I'm running into a rather strange error.

I'm using Kotlin coroutines in conjunction with Retrofit 2 to make a simple HTTP GET request to an API. Overall the app is working as expected and I've written tests using a MockWebServer which is also working fine except for when trying to test an error response from the API (rather ironic in a way).

Basically, the order in which things gets called is entirely out of whack when I intentionally create an error response.

Below is the test code in question:

    @Test
    fun viewModel_loadData_correctErrorHandling() {
        mockServer.enqueue(MockResponse().apply {
            setResponseCode(500)
        })

        viewModel.loadModel()

        assert(!viewModel.loading)
        assert(viewModel.loadingVisibility.value != View.VISIBLE)
        assertNotNull(viewModel.currentError)
        assert(viewModel.errorVisibility.value == View.VISIBLE)
        assertNull(viewModel.model.value)
        assert(viewModel.contentVisibility.value != View.VISIBLE)
    }

The viewModel.loadModel() function is as follows:

    fun loadModel() {
        currentError = null
        loading = true
        model.value = null
        interactor.load(viewModelScope, @MainThread {
            loading = false
            model.value = it
        }, @MainThread {
            loading = false
            currentError = it
            Timber.e(it)
        })
    }

And finally the interactor.load function is as follows.

    fun load(
        scope: CoroutineScope,
        onSuccess: (List<ConsumableCategory>) -> Unit,
        one rror: (Throwable) -> Unit
    ) {
        scope.launch {
            try {
                onSuccess(dataManager.getConsumableCategories())
            } catch (t: Throwable) {
                one rror(t)
            }
        }
    }

dataManager.getConsumableCategories() just references a call to suspended function created by my Retrofit instance.

When running the test in question my output is as follows:

2021-09-16T20:05:27.648 0200 [DEBUG] [TestEventLogger]     loadModel start
2021-09-16T20:05:27.648 0200 [DEBUG] [TestEventLogger]     Pre Scope
2021-09-16T20:05:27.672 0200 [DEBUG] [TestEventLogger]     Post Scope
2021-09-16T20:05:27.672 0200 [DEBUG] [TestEventLogger]     loadModel end
2021-09-16T20:05:27.681 0200 [DEBUG] [TestEventLogger]     one rror start
2021-09-16T20:05:27.740 0200 [DEBUG] [TestEventLogger] 
2021-09-16T20:05:27.740 0200 [DEBUG] [TestEventLogger] com.kenthawkings.mobiquityassessment.ConsumableViewModelTest > viewModel_loadData_correctErrorHandling FAILED
2021-09-16T20:05:27.741 0200 [DEBUG] [TestEventLogger]     java.lang.AssertionError: Assertion failed
2021-09-16T20:05:27.741 0200 [DEBUG] [TestEventLogger]         at com.kenthawkings.mobiquityassessment.ConsumableViewModelTest.viewModel_loadData_correctErrorHandling(ConsumableViewModelTest.kt:104)
...

Somehow my onError block is getting called AFTER the loadModel function is complete. As such, the line assert(!viewModel.loading) fails since it's getting called before the loading variable is set to false in the onError callback. I'm using custom rules to make sure everything is running synchronously.

    @get:Rule
    val testInstantTaskExecutorRule = InstantTaskExecutorRule()

    @ExperimentalCoroutinesApi
    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

I've tried using runBlocking and runBlockingTest (both wrapped around either the entire test or just the viewModel.loadModel() line) and it's made no difference. I've tried switching from using a try-catch to using a CoroutineExceptionHandler and using kotlin.runCatching but I always get the same result.

The really weird part is that success response test works as expected and all the statements print "in order".

    @Test
    fun viewModel_loadData_correctSuccessHandling() {
        val reader = MockResponseFileReader("success_response.json")
        assertNotNull(reader.content)

        mockServer.enqueue(MockResponse().apply {
            setResponseCode(200)
            setBody(reader.content)
            setHeader("content-type", "application/json")
        })

        viewModel.loadModel()

        assert(!viewModel.loading)
        assert(viewModel.loadingVisibility.value != View.VISIBLE)
        assertNull(viewModel.currentError)
        assert(viewModel.errorVisibility.value != View.VISIBLE)
        assertNotNull(viewModel.model.value)
        assert(viewModel.contentVisibility.value == View.VISIBLE)
    }
2021-09-16T20:05:27.542 0200 [DEBUG] [TestEventLogger]     loadModel start
2021-09-16T20:05:27.550 0200 [DEBUG] [TestEventLogger]     Pre Scope
2021-09-16T20:05:27.629 0200 [DEBUG] [TestEventLogger]     onSuccess start
2021-09-16T20:05:27.630 0200 [DEBUG] [TestEventLogger]     onSuccess end
2021-09-16T20:05:27.630 0200 [DEBUG] [TestEventLogger]     Post Scope
2021-09-16T20:05:27.630 0200 [DEBUG] [TestEventLogger]     loadModel end

I'm fairly new to Kotlin coroutines and but I've done a bunch of Googling on the matter and no one else seems to have this issue so I can only assume I'm doing something very silly here...

CodePudding user response:

I'm using custom rules to make sure everything is running synchronously.

The rules that you've used change the execution behavior of the AndroidDispatchers.MAIN dispatcher and the LiveData-related executors. Retrofit implements suspend functions through the Call.enqueue method which uses an executor provided by OkHttp and is not guaranteed to be synchronous.

The way to solve this is by taking the Job object returned by scope.launch and calling .join() in your tests, this ensures the coroutine finishes before you try to assert on its behavior.

The really weird part When running my success response test everything works as expected and all the statements print "in order".

This is actually caused by an implementation quirk of Retrofit. The library author actually has written a blog post about it: Jake Wharton - Exceptions and proxies and coroutines, oh my!.

Basically Retrofit's suspend support cannot return synchronously if it has to throw an exception, it always has to go through the dispatcher.

  • Related