如何对Kotlin挂起功能进行单元测试

Nik*_*nko 5 mvp unit-testing coroutine mockito kotlin

我遵循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)
    }
}
Run Code Online (Sandbox Code Playgroud)

我简单的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)
}
Run Code Online (Sandbox Code Playgroud)

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

  • “主持人呼叫之前的[测试]”
  • “ [方法] UseCase项目:[]”
  • “ [方法]查看通话”
  • “主持人呼叫后的[测试]”
  • “ [测试]验证视图”

但事实证明是:

  • 主持人呼叫前的[测试]
  • 主持人呼叫后的[测试]
  • [测试]验证视图
  • [方法] UseCase项:[]
  • [方法]查看通话

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

Nik*_*nko 10

我发现这是因为一个CoroutineDispatcher. 我曾经UIEmptyCoroutineContext. 切换到Unconfined已解决问题

20 年 4 月 20 日更新

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

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

class ItemUpdater(val item: Item) {
  fun updateItemValue() {
    launch(Dispatchers.Default) { item.value = 42 }
  }
}
Run Code Online (Sandbox Code Playgroud)

我们需要以某种方式替换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)
}
Run Code Online (Sandbox Code Playgroud)

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)
}
Run Code Online (Sandbox Code Playgroud)

他们都在做同样的事情——他们Dispatchers.Default在测试类中替换了原来的。唯一的区别是他们如何做到这一点。选择哪一个真的取决于你,所以不要被我下面的想法带入偏见。

恕我直言:第一种方法有点麻烦。到处注入调度程序将导致污染大多数类的构造函数,DispatchersWrapper仅用于测试目的。然而,谷歌至少目前推荐这种方式。第二种风格使事情变得简单,并且不会使生产类复杂化。这就像 RxJava 的测试方式,您必须通过 RxJavaPlugins 替换调度程序。顺便说一句,将来有一天kotlinx-coroutines-test 会带来完全相同的功能


See*_*Sky 7

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

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

您的runBlocking{}调用会创建一个新的协程,但runBlocking{}会等待该协程结束后再继续,您的loadPreviews()函数会创建一个协程,启动它然后立即返回,所以runBlocking()只需等待它并返回。

因此,虽然runBlocking{}已返回,但您创建的协程launch(UI){}仍在不同的线程中运行,这就是为什么您的日志顺序混乱的原因

Unconfined上下文是一种特殊的CoroutineContext,简单地创建当前线程上执行协同程序右边有一个调度程序,所以现在当你执行runBlocking{},它必须等待被创建的协程launch{},因为它是在同一线程上运行从而阻断该线程结束.

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