如何对Kotlin挂起函数进行单元测试

33

我遵循MVP模式+ UseCases与模型层进行交互。这是我想要测试的Presenter中的一种方法:

fun loadPreviews() {
    launch(UI) {
        val items = previewsUseCase.getPreviews() // a suspending function
        println("[method] UseCase items: $items")

        println("[method] View call")
        view.showPreviews(items)
    }
}

我简单的BDD测试:

fun <T> givenSuspended(block: suspend () -> T) = BDDMockito.given(runBlocking { block() })

infix fun <T> BDDMockito.BDDMyOngoingStubbing<T>.willReturn(block: () -> T) = willReturn(block())

@Test
fun `load previews`() {
    // UseCase and View are mocked in a `setUp` method

    val items = listOf<PreviewItem>()
    givenSuspended { previewsUseCase.getPreviews() } willReturn { items }

    println("[test] before Presenter call")
    runBlocking { presenter.loadPreviews() }
    println("[test] after Presenter call")

    println("[test] verify the View")
    verify(view).showPreviews(items)
}

测试已经成功通过,但是日志中出现了一些奇怪的东西。我期望它应该是这样的:
  • "[test] before Presenter call"
  • "[method] UseCase items: []"
  • "[method] View call"
  • "[test] after Presenter call"
  • "[test] verify the View"
但实际上它是这样的:
  • [test] before Presenter call
  • [test] after Presenter call
  • [test] verify the View
  • [method] UseCase items: []
  • [method] View call
这种行为的原因是什么,我该如何修复它?
2个回答

19

我发现这是由于一个 CoroutineDispatcher。我曾经用 EmptyCoroutineContext 来模拟 UI 上下文,但改为使用 Unconfined 就解决了问题。

更新于 2020 年 02 月 04 日

问题的名称暗示了将会有详尽的解释如何对挂起函数进行单元测试。让我再解释一下。

测试挂起函数的主要问题是线程。假设我们想要测试这个简单的函数,在不同的线程中更新属性值:

class ItemUpdater(val item: Item) {
  fun updateItemValue() {
    launch(Dispatchers.Default) { item.value = 42 }
  }
}

我们需要在测试中将Dispatchers.Default替换为另一个调度程序。有两种方法可以实现这一点,每种方法都有其优缺点,选择哪种取决于您的项目和编码风格:

1. 注入调度程序。

class ItemUpdater(
    val item: Item,
    val dispatcher: CoroutineDispatcher  // can be a wrapper that provides multiple dispatchers but let's keep it simple
) {
  fun updateItemValue() {
    launch(dispatcher) { item.value = 42 }
  }
}

// later in a test class

@Test
fun `item value is updated`() = runBlocking {
  val item = Item()
  val testDispatcher = Dispatchers.Unconfined   // can be a TestCoroutineDispatcher but we still keep it simple
  val updater = ItemUpdater(item, testDispatcher)

  updater.updateItemValue()

  assertEquals(42, item.value)
}

2. 替换一个分发程序。

class ItemUpdater(val item: Item) {
  fun updateItemValue() {
    launch(DispatchersProvider.Default) { item.value = 42 }  // DispatchersProvider is our own global wrapper
  }
}

// later in a test class

// -----------------------------------------------------------------------------------
// --- This block can be extracted into a JUnit Rule and replaced by a single line ---
// -----------------------------------------------------------------------------------
@Before
fun setUp() {
  DispatchersProvider.Default = Dispatchers.Unconfined
}

@After
fun cleanUp() {
  DispatchersProvider.Default = Dispatchers.Default
}
// -----------------------------------------------------------------------------------

@Test
fun `item value is updated`() = runBlocking {
  val item = Item()
  val updater = ItemUpdater(item)

  updater.updateItemValue()

  assertEquals(42, item.value)
}

两种方法都是做同样的事情 - 它们替换测试类中原始的Dispatchers.Default。唯一的区别在于它们如何做到这一点。选择哪种方法完全取决于您,所以不要受到我下面个人想法的影响。
个人认为:第一种方法有点繁琐。到处注入调度程序将导致大多数类的构造函数中都需要加入额外的DispatchersWrapper,仅用于测试目的。然而,至少目前Google 推荐使用此方法。第二种方法保持简单,不会使生产类变得复杂。这就像RxJava的测试方式,您必须通过RxJavaPlugins替换调度程序。顺便说一下,kotlinx-coroutines-test 将在未来的某个时候带来完全相同的功能

建议使用协程作用域和调度程序进行注入:https://craigrussell.io/2019/11/unit-testing-coroutine-suspend-functions-using-testcoroutinedispatcher/ - mochadwi
1
我不喜欢在每个构造函数中添加“DispatchersProvider”的想法,这有点繁琐。我更喜欢使用RxJava的测试方式-通过RxJavaPlugins替换全局调度程序。这样更方便,也不会使代码变得复杂。 顺便说一下,这种方法也适用于协程:https://github.com/Kotlin/kotlinx.coroutines/issues/1365 - Nikolay Kulachenko
你好,有什么想法可以解决这个问题吗?https://stackoverflow.com/questions/68634896/test-for-suspend-function-making-http-get-call-fails - Parth Doshi

12

我看你已经自己解决了问题,但为了那些可能遇到相同问题的人,我想再解释一下。

当你执行 launch(UI){} 时,会创建一个新的协程并将其调度到 "UI" 调度程序上,这意味着你的协程现在在不同的线程上运行。

你的 runBlocking{} 调用也会创建一个新的协程,但是 runBlocking{} 将等待该协程结束后才继续执行;而你的 loadPreviews() 函数会创建一个协程,立即启动它并立即返回,因此 runBlocking() 只需等待它并返回。

因此,虽然 runBlocking{} 已经返回,但是使用 launch(UI){} 创建的协程仍在不同的线程上运行,这就是日志顺序混乱的原因。

Unconfined 上下文是一个特殊的 CoroutineContext ,它只会创建一个分派程序,在当前线程上执行该协程。因此,现在当你执行runBlocking{}时,它必须等待由 launch{} 创建的协程结束,因为它在相同的线程上执行,从而阻塞了该线程。

我希望我的解释很清楚,祝你有愉快的一天。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接