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

How to unit test Kotlin suspending functions

我遵循 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)
}

测试成功通过,但日志中有一些奇怪的东西。我希望它是:

但结果是:

出现此行为的原因是什么?我应该如何解决?

我发现这是因为 CoroutineDispatcher。我曾经用 EmptyCoroutineContext 模拟 UI 上下文。切换到 Unconfined 已解决问题

更新 02.04.20

问题的名称表明将对如何对挂起函数进行单元测试进行详尽的解释。那么让我再解释一下。

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

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

我们需要以某种方式将 Dispatchers.Default 替换为另一个调度程序,仅用于测试目的。我们有两种方法可以做到这一点。各有利弊,选择哪一个取决于您的项目和编码风格:

1.注入 Dispatcher.

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 recommends this way at least for now. The second style keeps things simple and it doesn't complicate the production classes. It's like an RxJava's way of testing where you have to substitute schedulers via RxJavaPlugins. By the way, kotlinx-coroutines-test will bring the exact same functionality 将来的某一天。

我看到你是自己发现的,但我想为可能 运行 遇到同样问题的人解释更多

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

你的 runBlocking{} 调用创建了一个新的协程,但是 runBlocking{} 会等待这个协程结束再继续,你的 loadPreviews() 函数创建一个协程,启动它然后 return 立即,所以 runBlocking() 等待它 return.

因此,虽然 runBlocking{} 已 returned,但您使用 launch(UI){} 创建的协程仍在 运行 不同的线程中,这就是为什么您的顺序日志乱七八糟

Unconfined 上下文是一个特殊的 CoroutineContext,它只是创建一个在当前线程上执行协程的调度程序,所以现在当您执行 runBlocking{} 时,它必须等待 launch{} 创建的协程结束,因为它 运行 在同一线程上运行,因此阻塞了该线程。

我希望我的解释很清楚,祝你有美好的一天