I've started using kotlin coroutines in my Android project recently, but I have somewhat of a problem with it. Many would call it a code smell.
I'm using an MVP architecture where the coroutines are started in my presenter like this:
// WorklistPresenter.kt
...
override fun loadWorklist() {
...
launchAsync { mViewModel.getWorklist() }
...
The launchAsync
function is implemented this way (in my BasePresenter class that my WorklistPresenter class extends):
@Synchronized
protected fun launchAsync(block: suspend CoroutineScope.() -> Unit): Job {
return launch(UI) { block() }
}
The problem with this is that I'm using a UI coroutine context that depends on the Android Framework. I can't change this to another coroutine context without running into ViewRootImpl$CalledFromWrongThreadException
. To be able to unit test this I've created a copy of my BasePresenter with a different implementation of launchAsync
:
protected fun launchAsync(block: suspend CoroutineScope.() -> Unit): Job {
runBlocking { block() }
return mock<Job>()
}
To me this is a problem because now my BasePresenter has to be maintained in two places. So my question is. How can I change my implementation to support easy testing?
I’d recommend to extract the launchAsync
logic into a separate class, which you can simply mock in your tests.
class AsyncLauncher{
@Synchronized
protected fun execute(block: suspend CoroutineScope.() -> Unit): Job {
return launch(UI) { block() }
}
}
It should be part of your activity constructor in order to make it replaceable.
I recently learned about Kotlin coroutines and the guy who taught me showed me a good way to solve this problem.
You create an interface that provides contexts, with a default implementation:
interface CoroutineContextProvider {
val main: CoroutineContext
get() = Dispatchers.Main
val io: CoroutineContext
get() = Dispatchers.IO
class Default : CoroutineContextProvider
}
And you inject this (CoroutineContextProvider.Default()
) into your presenter constructor, either manually or with an injection framework. Then in your code you use the contexts it provides: provider.main
; provider.io
; or whatever you want to define. Now you can happily use launch
and withContext
using these contexts from your provider object, knowing that it will work correctly in your app but you can provide different contexts during testing.
From your tests inject a different implementation of this provider, where all of the contexts are Dispatchers.Unconfined
class TestingCoroutineContextProvider : CoroutineContextProvider {
@ExperimentalCoroutinesApi
override val main: CoroutineContext
get() = Dispatchers.Unconfined
@ExperimentalCoroutinesApi
override val io: CoroutineContext
get() = Dispatchers.Unconfined
}
When you mock the suspend function, call it wrapped with runBlocking
, which will ensure that all the action all takes place in the calling thread (your test). It's explained here (see the section about "Unconfined vs confined Dispatcher").
For others to use, here's the implementation I ended up with.
interface Executor {
fun onMainThread(function: () -> Unit)
fun onWorkerThread(function: suspend () -> Unit) : Job
}
object ExecutorImpl : Executor {
override fun onMainThread(function: () -> Unit) {
launch(UI) { function.invoke() }
}
override fun onWorkerThread(function: suspend () -> Unit): Job {
return async(CommonPool) { function.invoke() }
}
}
I inject the Executor
in my constructor and use kotlins delegation to avoid boilerplate code:
class SomeInteractor @Inject constructor(private val executor: Executor)
: Interactor, Executor by executor {
...
}
It's now possible to use the Executor
-methods interchangeably:
override fun getSomethingAsync(listener: ResultListener?) {
job = onWorkerThread {
val result = repository.getResult().awaitResult()
onMainThread {
when (result) {
is Result.Ok -> listener?.onResult(result.getOrDefault(emptyList())) :? job.cancel()
// Any HTTP error
is Result.Error -> listener?.onHttpError(result.exception) :? job.cancel()
// Exception while request invocation
is Result.Exception -> listener?.onException(result.exception) :? job.cancel()
}
}
}
}
In my test I switch the Executor
implementation with this.
For unit tests:
/**
* Testdouble of [Executor] for use in unit tests. Runs the code sequentially without invoking other threads
* and wraps the code in a [runBlocking] coroutine.
*/
object TestExecutor : Executor {
override fun onMainThread(function: () -> Unit) {
Timber.d("Invoking function on main thread")
function()
}
override fun onWorkerThread(function: suspend () -> Unit): Job {
runBlocking {
Timber.d("Invoking function on worker thread")
function()
}
return mock<Job>()
}
}
For instrumentation tests:
/**
* Testdouble of [Executor] for use in instrumentations tests. Runs the code on the UI thread.
*/
object AndroidTestExecutor : Executor {
override fun onMainThread(function: () -> Unit) {
Timber.d("Invoking function on worker thread")
function()
}
override fun onWorkerThread(function: suspend () -> Unit): Job {
return launch(UI) {
Timber.d("Invoking function on worker thread")
function()
}
}
}
You can also make your presenter not know about the UI
context.
Instead, the presenter should be context-less.
The presenter should just expose the suspend
function and let the callers specify the context.
Then when you call this presenter coroutine function from the View, you call it with UI
context launch(UI) { presenter.somethingAsync() }
.
That way when testing the presenter you can run the test with runBlocking { presenter.somethingAsync() }
来源:https://stackoverflow.com/questions/48205095/kotlin-coroutines-switching-context-when-testing-an-android-presenter