Kotlin coroutine单元测试的更好方法

Posted 冬天的毛毛雨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Kotlin coroutine单元测试的更好方法相关的知识,希望对你有一定的参考价值。

作者:尼耳多

如果你用Kotlin编程,你很有可能在异步工作中使用coroutines。

然而,使用coroutines的代码也应该被单元测试。多亏了kotlinx-coroutines-test 库,乍一看,这似乎是一个简单的任务。

然而,有一件事被许多开发者忽视了,它有可能使你的测试不可靠:coroutines如何处理异常。

在这篇文章中,我们将介绍一个典型的项目情况,即一些生产代码调用coroutines。这段代码是经过单元测试的,但测试配置不完整,在coroutine内调用的代码可能会抛出一个异常。

然后,当在coroutine内抛出的异常没有被传播为测试失败时,我们将找到一个解决方案来缓解这个问题,并为我们所有的测试自动化。

前提条件

要学习本教程,我假设你。

  • 有一个已经使用了Kotlin coroutines的项目
  • 已经使用JUnit 5测试框架kotlinx-coroutines-test coroutine测试库设置了单元测试
  • 使用IntelliJ IDEA或android Studio作为你的IDE(以查看运行单元测试的详细结果)。

本教程中的测试示例使用MockK来模拟测试依赖,但这并不关键–你也可以使用其他模拟框架。

你也可以为运行单元测试设置一个CI服务器,或者从命令行运行它们–只要你能检查测试运行时抛出的异常。

使用轮子线的生产代码示例

作为coroutines通常在生产中运行的基本机制的例子,我将使用一个简单的presenter类,它连接到ApiService ,以获得用户数据,然后在UI中显示它。

由于getUser() 是一个挂起的函数,而我们想利用结构化的并发性,我们必须在一个包裹着CoroutineContextCoroutineScope 中启动它。你的项目中的特定逻辑可能会有所不同,但如果你使用结构化并发,调用coroutine的机制将是相似的。

我们必须将coroutine上下文注入到presenter的构造函数中,所以它可以很容易地被替换,以便进行测试。

class UserPresenter(
  val userId: String,
  val apiService: ApiService,
  val coroutineContext = Dispatchers.Default
) 

  init 
    CoroutineScope(coroutineContext).launch 
      val user = apiService.getUser(userId)
      // Do something with the user data, e.g. display in the UI
    
  
 

冠词逻辑的单元测试

对于这个presenter,我们有一个单元测试的例子,测试当presenter被实例化时,我们是否调用API方法来获取用户数据。

class UserPresenterTest 

  val apiService = mockk<ApiService>()

  @Test
  fun `when presenter is instantiated then get user from API`() 

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = TestCoroutineDispatcher()
    )

    coVerify  apiService.getUser("testUserId") 
  

在这里,我们使用MockK嘲弄框架来嘲弄ApiService 的依赖性;你可能在你的项目中使用了另一个嘲弄框架。

我们还向我们的presenter注入了一个TestCoroutineDispatcher ,作为coroutine上下文。TestCoroutineDispatcherkotlinx-coroutines-test 库的一部分,允许我们在测试中更容易地运行coroutine。

通过使用MockK的coVerify 块,我们可以验证getUser() 方法是否按照预期在被模拟的ApiService 上被调用。

然而,MockK是一个严格的嘲弄框架,这意味着它要求我们使用下面的语法来定义被嘲弄的ApiServicegetUser() 方法的行为。

coEvery  
  apiService.getUser("testUserId") 
 returns User(name = "Test User", email = "testuser@example.com")

正如你在本节开头的测试示例中所看到的,这个对getUser() 行为的定义是缺失的。这是因为我们忘记了定义它。

这种情况在编写测试时有时会发生。当它发生时,MockK会在运行测试时抛出一个异常,提醒我们缺少配置,测试应该失败。

测试通过了,但是抛出了一个异常

然而,当我们在IDE或持续集成服务器上运行该测试时,它却通过了!这是为什么呢?

UserPresenterTest - PASSED 1 of 1 test
  when presenter is instantiated then get user from API() - PASSED

IDE和CI服务器都没有告诉我们,我们忘记配置模拟的ApiService.getUser() 行为。

然而,当你在IntelliJ IDEA中点击看似绿色的测试结果时,你会看到MockK抛出的一个异常已经被记录下来。

Exception in thread "Test worker @coroutine#1" 
io.mockk.MockKException: 
no answer found for: ApiService(#1).getUser(testUserId, continuation )

不幸的是,这个异常并没有作为测试失败传播到JUnit测试框架中,使测试变成了绿色,给了我们一种错误的安全感。我们的报告工具(IDE或CI)都不会让我们一眼就看出出错了。

当然,如果你有成百上千的测试,点击每一个测试以确保你没有忘记模拟什么,是不现实的。

为什么会出现这种情况?

Coroutines在内部处理异常,默认情况下不会将它们作为测试失败传播给JUnit。此外,默认情况下,coroutines也不会向JUnit报告未完成的coroutines。因此,我们的代码中可能有泄漏的coroutines,甚至没有注意到它。

在Kotlin coroutines GitHub上有一个关于这个话题的有趣讨论特别是关于一个调查和解决这个问题的不同方法

runBlockingTest 可以解决这个问题吗?

上面提到的GitHub上的讨论提到,即使我们用runBlockingrunBlockingTest 块来包装我们的测试代码,coroutine的异常也不会被传播为测试失败。下面是修改后的测试,虽然抛出了一个异常,但仍然通过。

  @Test
  fun `when presenter is instantiated then get user from API`() = runBlockingTest 

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = TestCoroutineDispatcher()
    )

    coVerify  apiService.getUser("testUserId") 
  

将异常作为测试失败进行传播的方法TestCoroutineScope

如果你看一下[kotlinx-coroutines-test](github.com/Kotlin/kotl… 库,你会发现TestCoroutineScope ,这似乎正是我们需要的正确处理异常的方法。

cleanupTestCoroutines() 方法重新抛出在我们的测试中可能发生的任何未捕获的异常。如果有任何未完成的循环程序,它也会抛出一个异常。

在测试中使用TestCoroutineScope

为了使用TestCoroutineScope ,我们可以在测试中用TestCoroutineScope.coroutineContext 替换TestCoroutineDispatcher() 。我们还必须在每次测试后调用cleanupTestCoroutines()

class UserPresenterTest 

  val apiService = mockk<ApiService>()
  val testScope = TestCoroutineScope()

  @Test
  fun `when presenter is instantiated then get user from API`() 

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = testScope.coroutineContext
    )

    coVerify  apiService.getUser("testUserId") 
  

  @AfterEach
  fun tearDown() 
    testScope.cleanupTestCoroutines()
  

正如你所看到的,使用TestCoroutineScrope ,最好的事情是我们不需要改变我们的测试逻辑本身。

测试现在准确地失败了

让我们再次运行测试。现在,我们看到,缺失的模拟异常被作为测试失败传播给JUnit。

UserPresenterTest - FAILED 1 of 1 test
  when presenter is instantiated then get user from API() - FAILED

  io.mockk.MockKException: 
  no answer found for: ApiService(#1).getUser(testUserId, continuation )

此外,如果我们在测试中有一个未完成的循环程序,它将报告为测试失败。

自动化测试配置

通过创建一个JUnit5测试扩展,我们可以自动进行适当的测试配置以节省时间。

为此,我们必须创建一个CoroutineTestExtension 类。这个类实现了TestInstancePostProcessor ,它在我们的测试实例创建后将TestCoroutineScope() 注入到我们的测试实例中,这样我们就可以在测试中轻松使用它。

这个类还实现了AfterEachCallback ,所以我们不需要把cleanupTestCoroutines() 方法复制粘贴到每个测试类中。

@ExperimentalCoroutinesApi
class CoroutineTestExtension : TestInstancePostProcessor, AfterEachCallback 

   private val testScope = TestCoroutineScope()

   override fun postProcessTestInstance(
           testInstance: Any?, 
           context: ExtensionContext?
   ) 
       (testInstance as? CoroutineTest)?.let  it.testScope = testScope 
   

   override fun afterEach(context: ExtensionContext?) 
       testScope.cleanupTestCoroutines()
   

我们还可以为我们所有的单元测试类创建一个CoroutineTest 接口来实现。这个接口自动扩展了我们刚刚创建的CoroutineTestExtension 类。

@ExperimentalCoroutinesApi
@ExtendWith(CoroutineTestExtension::class)
interface CoroutineTest 

   var testScope: TestCoroutineScope

在测试中使用CoroutineTextExtension

我们的测试类现在只实现了CoroutineTest

请注意,重载的testScope 是由我们刚刚写的CoroutineTestExtension 来设置的。这就是为什么把它标记为lateinit var (在Kotlin中用lateinit var 来覆盖var 是允许的)是完全可以的。

class UserPresenterTest : CoroutineTest 

  val apiService = mockk<ApiService>()
  override lateinit var testScope: TestCoroutineScope

  @Test
  fun `when presenter is instantiated then get user from API`() 

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = testScope.coroutineContext
    )
    // No changes to test logic :)
  

现在,你的测试将正确报告所有的循环程序异常和未完成的循环程序作为测试失败。

总结

通过在你的测试中使用CoroutineTestExtension ,你将能够依靠你的测试准确地失败,只要有一个异常在你的coroutine中抛出或你的coroutine未完成。不会再有错误的否定,传达错误的安全感。

另外,由于CoroutineTest 接口,正确配置你的测试将像在你的测试中写两行额外的代码一样容易。这使得人们更有可能真正做到这一点。

以上是关于Kotlin coroutine单元测试的更好方法的主要内容,如果未能解决你的问题,请参考以下文章

模拟返回 Kotlin Coroutines Deferred 类型的方法的返回值

使用 Kotlin Coroutines 替换 LocalBroadcastManager 进行 Firebase 消息传递

Kotlin:使内部函数对单元测试可见

Spring WebFlux Reactive 和 Kotlin Coroutines 启动错误

有没有办法在 Kotlin 中使用 coroutines/Flow/Channels 实现这个 rx 流?

kotlin - Coroutine 协程