Kotlin coroutines: Switching context when testing an Android Presenter

半腔热情 提交于 2019-12-10 10:25:09

问题


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 launchAsyncfunction 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?


回答1:


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.




回答2:


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").




回答3:


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



回答4:


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

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!