如何对抛出异常的 Kotlin 协程进行单元测试?

3

(Kotlin 1.5.21,kotlinx-coroutines-test 1.5.0)

请考虑以下代码,它位于 androidx.lifecycle.ViewModel 中:

fun mayThrow(){
    val handler = CoroutineExceptionHandler { _, t -> throw t }
    vmScope.launch(dispatchers.IO + handler) {
        val foo = bar() ?: throw IllegalStateException("oops")
        withContext(dispatchers.Main) {
            _someLiveData.value = foo
        }
    }
}

vmScope 对应于 viewModelScope,在测试中它被替换为 TestCoroutineScopedispatchers.IODispatchers.IO 的代理,在测试中它是一个 TestCoroutineDispatcher。在这种情况下,如果 bar() 返回 null,则应用程序的行为未定义,因此我希望在这种情况下它崩溃。现在我正在尝试(JUnit4)测试这段代码:

@Test(expected = IllegalStateException::class)
fun `should crash if something goes wrong with bar`()  {
    tested.mayThrow()
}

测试失败是因为它本应该测试的异常出现了。
Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: oops
// stack trace

Expected exception: java.lang.IllegalStateException
java.lang.AssertionError: Expected exception: java.lang.IllegalStateException
// stack trace

我有一种感觉,好像我在这里漏掉了什么很明显的东西... 问题:我的ViewModel中的代码是否是从协程抛出异常的正确方式?如果是,如何对其进行单元测试?


1
https://dev59.com/3W025IYBdhLWcg3wl3K- - ADM
这个回答解决了你的问题吗?JUnit4:测试预期异常 - possum
2
这两个链接的问题与我提出的问题几乎没有任何关系。我知道如何使用JUnit4,并且我还有几百个测试来检查预期的异常。我的问题是,被测试的代码正在启动一个协程,并且底层的某些东西似乎在它完成之前就使测试失败了。问题是如何找出这个“某些东西”。这个问题没有标记为“Java”,有很好的理由。 - Droidman
2个回答

2
  1. 为什么测试是绿色的:

launch{ ... } 中的代码是异步执行的,与测试方法不同步。要识别它,请尝试修改 mayThrow 方法(请参见下面的代码片段),使其返回结果,而不管 launch {...} 中发生了什么。要使测试变为红色,请用 runBlocking 替换 launch(有关更多详细信息,请参阅文档,只需阅读第一章并运行示例即可)。


@Test
fun test() {
    assertEquals(1, mayThrow()) // GREEN
}

fun mayThrow(): Int {
    val handler = CoroutineExceptionHandler { _, t -> throw t }

    vmScope.launch(dispatchers.IO + handler) {
        val foo = bar() ?: throw IllegalStateException("oops")
        withContext(dispatchers.Main) {
            _someLiveData.value = foo
        }
    }

    return 1 // this line succesfully reached
}
  1. 为什么看起来像是“测试失败,因为相同的异常…”

测试并没有失败,但我们在控制台中看到了异常堆栈跟踪,这是因为默认的异常处理程序会这样做,并且会应用它,因为在这种情况下自定义异常处理程序 CoroutineExceptionHandler 抛出了异常 (详细解释)

  1. 如何进行测试

函数 mayThrow 承担了太多的责任,因此很难进行测试。这是一个标准问题,有标准的解决方案 (第一篇第二篇):简而言之,应用单一职责原则。例如,将异常处理程序传递给函数。

fun mayThrow(xHandler: CoroutineExceptionHandler){
    vmScope.launch(dispatchers.IO + xHandler) {
        val foo = bar() ?: throw IllegalStateException("oops")
        withContext(dispatchers.Main) {
            _someLiveData.value = foo
        }
    }
}

@Test(expected = IllegalStateException::class)
fun test() {
    val xRef = AtomicReference<Throwable>()
    mayThrow(CoroutineExceptionHandler { _, t -> xRef.set(t) })

    val expectedTimeOfAsyncLaunchMillis = 1234L
    Thread.sleep(expectedTimeOfAsyncLaunchMillis)

    throw xRef.get() // or assert it any other way
}

有道理,谢谢...不过我不喜欢将自定义异常处理程序作为参数传递(该函数实际上是私有的,在init{}块内调用),因为我必须注入它,添加另一个构造函数参数(除了Scope之外,因为测试环境中没有lifecycleScope)。那不是TestCoroutineExceptionHandler的一个使用案例吗?我尝试过了,但它没有起作用:private val vmScope: CoroutineScope = TestCoroutineScope(coroutinesExceptionHandler) - Droidman
我在问题中没有看到您尝试测试什么,因此我无法给出确切的答案。无论如何,如果我们遵循“分而治之”的一般思路:尝试隔离需要测试的部分,添加注入它的可能性(通过测试可用的构造函数或私有包字段),在测试中对该部分进行模拟,并断言模拟状态。 - diziaq
mayThrow 在 ViewModel 的 init{ block} 中被调用,因此测试设置会为以下测试准备模拟依赖项:GIVEN mocked.bar() 返回 null WHEN 创建 MyViewModel 的新实例 THEN 抛出 IllegalStateException。基本上,我需要确保应用程序由于指定的异常而崩溃,因为真实代码使用 CoroutineExceptionHandler 抛出异常。 - Droidman
最终,我注入了“CoroutineExceptionHandler”,并在单元测试中将其替换为“TestCoroutineExceptionHandler”:val result = exceptionHandler.uncaughtExceptions[0] ,然后 assertThat(result).isInstanceOf(IllegalStateException::class.java)。接受此答案的原因是它让我意识到,按照我最初设想的方法,我将无法测试我想要的内容。谢谢! - Droidman

1

如果其他方法都不起作用,我建议将抛出异常的代码移动到另一个方法并测试该方法:

// ViewModel

fun mayThrow(){
    vmScope.launch(dispatchers.IO) {
        val foo = doWorkThatThrows()
        withContext(dispatchers.Main) {
            _someLiveData.value = foo
        }
    }
}

fun doWorkThatThrows(): Foo {
    val foo = bar() ?: throw IllegalStateException("oops")
    return foo
}

// Test

@Test(expected = IllegalStateException::class)
fun `should crash if something goes wrong with bar`()  {
    tested.doWorkThatThrows()
}

或者使用JUnit Jupiter,通过使用assertThrows方法来测试抛出的异常。例如:

assertThrows<IllegalStateException> { tested.doWorkThatThrows() }

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