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.