kotlin 单元测试回调流

t1rydlwq  于 2023-03-24  发布在  Kotlin
关注(0)|答案(3)|浏览(192)

我有一个基于回调的API,如下所示:

class CallbackApi {
    fun addListener(callback: Callback) {
      // todo
    }

    fun removeListener(callback: Callback) {
      // todo
    }

    interface Callback {
      fun onResult(result: Int)
    }
  }

以及将API转换成热冷流的扩展函数:

fun CallbackApi.toFlow() = callbackFlow<Int> {
    val callback = object : CallbackApi.Callback {
      override fun onResult(result: Int) {
        trySendBlocking(result)
      }
    }
    addListener(callback)
    awaitClose { removeListener(callback) }
  }

您是否介意建议如何编写单元测试,以确保API正确转换为热流?
这是我的尝试。通过反复试验,我想出了这个解决方案。

@Test
  fun callbackFlowTest() = runBlocking {
    val callbackApi = mockk<CallbackApi>()
    val callbackSlot = slot<CallbackApi.Callback>()
    every { callbackApi.addListener(capture(callbackSlot)) } just Runs
    every { callbackApi.removeListener(any()) } just Runs
    val list = mutableListOf<Int>()
    val flow: Flow<Int> = callbackApi.toFlow().onEach { list.add(it) }
    val coroutineScope = CoroutineScope(this.coroutineContext + SupervisorJob())
    flow.launchIn(coroutineScope)
    yield()
    launch {
      callbackSlot.captured.onResult(10)
      callbackApi.removeListener(mockk()) // this was a misunderstanding
    }.join()
    assert(list.single() == 10)
  }

但我不明白这个解决方案的两个部分。
1-在没有SupervisorJob()的情况下,测试似乎永远不会结束。可能由于某种原因,收集流永远不会结束,我不明白。我在一个单独的协程中提供捕获的回调。
2-如果我删除launch主体,其中callbackSlot.captured.onResult(10)在它里面,测试将失败,错误为UninitializedPropertyAccessException: lateinit property captured has not been initialized。我认为yield应该启动流。

93ze6v8z

93ze6v8z1#

以及将API转换为热流的扩展函数
这个扩展看起来是正确的,但是流不是热的(也不应该是热的)。它只在实际收集开始时注册回调,并在收集器被取消时取消注册(这包括收集器使用限制项目数量的终端操作符,如.first().take(n))。
这是一个非常重要的注意事项,请记住您的其他问题。
在没有SupervisorJob()的情况下,测试似乎永远不会结束。也许收集流永远不会因为某种原因而结束,我不明白
如上所述,由于流的构造方式(以及CallbackApi的工作方式),流收集不能由生产者(回调API)决定结束。它只能通过取消收集器来停止,这也将取消注册相应的回调(这很好)。
你的自定义作业允许测试结束的原因可能是因为你通过用一个没有当前作业作为父作业的自定义作业覆盖上下文中的作业来逃避结构化并发。但是你可能仍然会从从未取消的作用域中泄漏那个永不结束的协程。
我在一个单独的协程中提供捕获的回调。
这是正确的,尽管我不明白为什么你要从这个单独的协程调用removeListener。你在这里注销的是什么回调?注意,这也不会对流程有任何影响,因为即使你可以注销在callbackFlow构建器中创建的回调,它也不会神奇地关闭callbackFlow的通道,所以流无论如何都不会结束(我假设这就是您在这里尝试做的)。
此外,从外部取消注册回调会阻止您检查它是否实际上已被生产代码取消注册。
2-如果我删除callbackSlot.captured.onResult(10)在其中的启动主体,测试将失败,并出现以下错误UninitializedPropertyAccessException:捕获的lateinit属性尚未初始化。我认为yield应该启动流。
yield()是相当脆弱的。如果你使用它,你必须非常清楚每个并发协程的代码当前是如何编写的。它脆弱的原因是它只会在下一个挂起点之前将线程让给其他协程。你不能预测在屈服时会执行哪个协程,你也不能预测线程在到达暂停点后会恢复哪一个线程。如果有两个暂停,所有的赌注都被取消。如果有其他运行的协程,所有的赌注也被取消。
更好的方法是使用kotlinx-coroutines-test,它提供了advanceUntilIdle这样的实用程序,可以确保其他协程都已完成或正在等待挂起点。
现在如何修复这个测试?我现在不能测试任何东西,但我可能会这样做:

  • 使用来自kotlinx-coroutines-testrunTest而不是runBlocking来更好地控制其他协程何时运行(并等待流集合执行某些操作)
  • 在协程中启动流集合(仅launch/launchIn(this),不带自定义作用域),并保留已启动Job的句柄(返回值为launch/launchIn
  • 使用值advanceUntilIdle()调用捕获的回调,以确保流收集器的协程可以处理它,然后Assert列表获得了元素(注意:因为所有的东西都是单线程的,并且回调没有挂起,如果没有缓冲区,这将死锁,但是callbackFlow使用默认缓冲区,所以应该没问题)
  • 可选:用不同的值重复上述几次,并确认它们被流收集
  • 取消收集作业advanceUntilIdle(),然后测试回调是否未注册(我不是MockkMaven,但应该有一些东西可以检查是否调用了removeListener

注意:也许我是个守旧派,但如果你的CallbackApi是一个接口(在你的例子中它是一个类,但我不确定它在多大程度上反映了现实),我宁愿使用一个通道来模拟事件和Assert期望手动实现一个mock。我发现它更容易推理和调试。

wrrgggsh

wrrgggsh2#

以下是我根据@Joffrey有用指南找到的解决方案:

@Test
  fun callbackFlowTestSolution() = runTest {
    val callbackApi = mockk<CallbackApi>()
    val callbackSlot = slot<CallbackApi.Callback>()
    every { callbackApi.addListener(capture(callbackSlot)) } just Runs
    every { callbackApi.removeListener(any()) } just Runs
    val itemsToSend = Array(9) { index -> index } // [0, 1, 2, 3, 4, 5, 6, 7, 8]
    val collectedItems = mutableListOf<Int>()

    val flow: Flow<Int> = callbackApi.toFlow().onEach { collectedItems.add(it) }
    val collectJob = launch { flow.collect() }
    advanceUntilIdle() // wait for callbackFlow builder to call addListener
    itemsToSend.forEach { callbackSlot.captured.onResult(it) }
    advanceUntilIdle() // wait for flow collection
    collectJob.cancel()
    advanceUntilIdle() // wait for awaitClose

    verify { callbackApi.removeListener(callbackSlot.captured) }
    assertArrayEquals(itemsToSend.toIntArray(), collectedItems.toIntArray())
  }

请考虑升级到kotlinx-coroutines-test:1.6.0以使用runTest。在旧版本中,我们可以使用runBlockingTest,但已弃用。

UPDATE:如果要检查流是否可以逐个收集项目

@Test
  fun callbackFlowTestSolution() = runTest {
    val callbackApi = mockk<CallbackApi>()
    val callbackSlot = slot<CallbackApi.Callback>()
    every { callbackApi.addListener(capture(callbackSlot)) } just Runs
    every { callbackApi.removeListener(any()) } just Runs
    val itemsToSend = Array(9) { index -> index } // [0, 1, 2, 3, 4, 5, 6, 7, 8]
    var lastCollectedItem: Int? = null

    val flow: Flow<Int> = callbackApi.toFlow().onEach { lastCollectedItem = it }
    val collectJob = launch { flow.collect() }
    advanceUntilIdle() // wait for callbackFlow builder to call addListener
    itemsToSend.forEach {
      callbackSlot.captured.onResult(it)
      advanceUntilIdle() // wait for flow collection
      
      assertEquals(it, lastCollectedItem)
    }
    collectJob.cancel()
    advanceUntilIdle() // wait for awaitClose

    verify { callbackApi.removeListener(callbackSlot.captured) }
  }
xtfmy6hx

xtfmy6hx3#

这里是一个基于@Joffrey和@Fartab的答案的解决方案,但使用了Turbine Flow testing library,这进一步减少了所需的代码量。这里的重要调用再次是在调用捕获的插槽之前的advanceUntilIdle()

@Test
fun callbackFlowTestSolutionWithTurbine() = runTest {
    val callbackApi = mockk<CallbackApi>()
    val callbackSlot = slot<CallbackApi.Callback>()
    justRun { callbackApi.addListener(capture(callbackSlot)) }
    justRun { callbackApi.removeListener(any()) }
    val itemsToSend = Array(9) { index -> index } // [0, 1, 2, 3, 4, 5, 6, 7, 8]

    callbackApi.toFlow().test{
        advanceUntilIdle() // wait for callbackFlow builder to call addListener
        itemsToSend.forEach {
            callbackSlot.captured.onResult(it)
            assertEquals(it, awaitItem())
        }
    }
    verify { callbackApi.removeListener(callbackSlot.captured) }
}

相关问题