Kotlin 协程:在测试 Android Presenter 时切换上下文
Kotlin coroutines: Switching context when testing an Android Presenter
我最近开始在我的 Android 项目中使用 kotlin 协程,但我对它有点问题。许多人会称之为代码味道。
我使用的是 MVP 架构,其中协程在我的演示器中启动如下:
// WorklistPresenter.kt
...
override fun loadWorklist() {
...
launchAsync { mViewModel.getWorklist() }
...
launchAsync
函数是这样实现的(在我的 BasePresenter class 中,我的 WorklistPresenter class 扩展):
@Synchronized
protected fun launchAsync(block: suspend CoroutineScope.() -> Unit): Job {
return launch(UI) { block() }
}
这个问题是我使用的 UI 协程上下文依赖于 Android 框架。如果没有 运行 到 ViewRootImpl$CalledFromWrongThreadException
,我无法将其更改为另一个协程上下文。为了能够对此进行单元测试,我创建了一个 BasePresenter 的副本,其中包含 launchAsync
:
的不同实现
protected fun launchAsync(block: suspend CoroutineScope.() -> Unit): Job {
runBlocking { block() }
return mock<Job>()
}
对我来说这是个问题,因为现在我的 BasePresenter 必须在两个地方维护。所以我的问题是。如何更改我的实现以支持轻松测试?
我建议将 launchAsync
逻辑提取到单独的 class 中,您可以在测试中简单地对其进行模拟。
class AsyncLauncher{
@Synchronized
protected fun execute(block: suspend CoroutineScope.() -> Unit): Job {
return launch(UI) { block() }
}
}
它应该是您的 activity 构造函数的一部分,以使其可替换。
您也可以让您的演示者不知道 UI
上下文。
相反,演示者应该是无上下文的。
演示者应该只公开 suspend
函数并让调用者指定上下文。
然后当你从 View 调用这个 presenter 协程函数时,你用 UI
上下文 launch(UI) { presenter.somethingAsync() }
调用它。
这样,在测试演示者时,您可以 运行 使用 runBlocking { presenter.somethingAsync() }
进行测试
供其他人使用,这是我最终得到的实现。
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() }
}
}
我在我的构造函数中注入 Executor
并使用 kotlins 委托来避免样板代码:
class SomeInteractor @Inject constructor(private val executor: Executor)
: Interactor, Executor by executor {
...
}
现在可以交替使用 Executor
方法:
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()
}
}
}
}
在我的测试中,我用这个切换了 Executor
实现。
对于单元测试:
/**
* 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>()
}
}
对于仪器测试:
/**
* 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()
}
}
}
我最近了解了 Kotlin 协同程序,教我的人向我展示了解决这个问题的好方法。
您创建一个提供上下文的接口,具有默认实现:
interface CoroutineContextProvider {
val main: CoroutineContext
get() = Dispatchers.Main
val io: CoroutineContext
get() = Dispatchers.IO
class Default : CoroutineContextProvider
}
然后您将此 (CoroutineContextProvider.Default()
) 手动或使用注入框架注入到您的演示者构造函数中。然后在您的代码中使用它提供的上下文:provider.main
; provider.io
;或者你想定义的任何东西。现在您可以愉快地使用 launch
和 withContext
使用来自您的提供者对象的这些上下文,知道它会在您的应用程序中正常工作,但您可以在测试期间提供不同的上下文。
从您的测试中注入此提供程序的不同实现,其中所有上下文都是 Dispatchers.Unconfined
class TestingCoroutineContextProvider : CoroutineContextProvider {
@ExperimentalCoroutinesApi
override val main: CoroutineContext
get() = Dispatchers.Unconfined
@ExperimentalCoroutinesApi
override val io: CoroutineContext
get() = Dispatchers.Unconfined
}
当您模拟挂起函数时,用 runBlocking
包装调用它,这将确保所有操作都发生在调用线程(您的测试)中。已解释 here(请参阅有关 "Unconfined vs confined Dispatcher" 的部分)。
我最近开始在我的 Android 项目中使用 kotlin 协程,但我对它有点问题。许多人会称之为代码味道。
我使用的是 MVP 架构,其中协程在我的演示器中启动如下:
// WorklistPresenter.kt
...
override fun loadWorklist() {
...
launchAsync { mViewModel.getWorklist() }
...
launchAsync
函数是这样实现的(在我的 BasePresenter class 中,我的 WorklistPresenter class 扩展):
@Synchronized
protected fun launchAsync(block: suspend CoroutineScope.() -> Unit): Job {
return launch(UI) { block() }
}
这个问题是我使用的 UI 协程上下文依赖于 Android 框架。如果没有 运行 到 ViewRootImpl$CalledFromWrongThreadException
,我无法将其更改为另一个协程上下文。为了能够对此进行单元测试,我创建了一个 BasePresenter 的副本,其中包含 launchAsync
:
protected fun launchAsync(block: suspend CoroutineScope.() -> Unit): Job {
runBlocking { block() }
return mock<Job>()
}
对我来说这是个问题,因为现在我的 BasePresenter 必须在两个地方维护。所以我的问题是。如何更改我的实现以支持轻松测试?
我建议将 launchAsync
逻辑提取到单独的 class 中,您可以在测试中简单地对其进行模拟。
class AsyncLauncher{
@Synchronized
protected fun execute(block: suspend CoroutineScope.() -> Unit): Job {
return launch(UI) { block() }
}
}
它应该是您的 activity 构造函数的一部分,以使其可替换。
您也可以让您的演示者不知道 UI
上下文。
相反,演示者应该是无上下文的。
演示者应该只公开 suspend
函数并让调用者指定上下文。
然后当你从 View 调用这个 presenter 协程函数时,你用 UI
上下文 launch(UI) { presenter.somethingAsync() }
调用它。
这样,在测试演示者时,您可以 运行 使用 runBlocking { presenter.somethingAsync() }
供其他人使用,这是我最终得到的实现。
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() }
}
}
我在我的构造函数中注入 Executor
并使用 kotlins 委托来避免样板代码:
class SomeInteractor @Inject constructor(private val executor: Executor)
: Interactor, Executor by executor {
...
}
现在可以交替使用 Executor
方法:
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()
}
}
}
}
在我的测试中,我用这个切换了 Executor
实现。
对于单元测试:
/**
* 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>()
}
}
对于仪器测试:
/**
* 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()
}
}
}
我最近了解了 Kotlin 协同程序,教我的人向我展示了解决这个问题的好方法。
您创建一个提供上下文的接口,具有默认实现:
interface CoroutineContextProvider {
val main: CoroutineContext
get() = Dispatchers.Main
val io: CoroutineContext
get() = Dispatchers.IO
class Default : CoroutineContextProvider
}
然后您将此 (CoroutineContextProvider.Default()
) 手动或使用注入框架注入到您的演示者构造函数中。然后在您的代码中使用它提供的上下文:provider.main
; provider.io
;或者你想定义的任何东西。现在您可以愉快地使用 launch
和 withContext
使用来自您的提供者对象的这些上下文,知道它会在您的应用程序中正常工作,但您可以在测试期间提供不同的上下文。
从您的测试中注入此提供程序的不同实现,其中所有上下文都是 Dispatchers.Unconfined
class TestingCoroutineContextProvider : CoroutineContextProvider {
@ExperimentalCoroutinesApi
override val main: CoroutineContext
get() = Dispatchers.Unconfined
@ExperimentalCoroutinesApi
override val io: CoroutineContext
get() = Dispatchers.Unconfined
}
当您模拟挂起函数时,用 runBlocking
包装调用它,这将确保所有操作都发生在调用线程(您的测试)中。已解释 here(请参阅有关 "Unconfined vs confined Dispatcher" 的部分)。