6

The following test succeeds with Process finished with exit code 0. Note, this test does print the exception to the logs, but does not fail the test (which is the behavior I want).

@Test
fun why_does_this_test_pass() {
    val job = launch(Unconfined) {
        throw IllegalStateException("why does this exception not fail the test?")
    }

    // because of `Unconfined` dispatcher, exception is thrown before test function completes
}

As expected, this test fails with Process finished with exit code 255

@Test
fun as_expected_this_test_fails() {
    throw IllegalStateException("this exception fails the test")
}

Why do these tests not behave the same way?

Marko Topolnik
  • 195,646
  • 29
  • 319
  • 436
ZakTaccardi
  • 12,212
  • 15
  • 59
  • 107

3 Answers3

10

Compare your test with the following one that does not use any coroutines, but starts a new thread instead:

@Test
fun why_does_this_test_pass() {
    val job = thread { // <-- NOTE: Changed here
        throw IllegalStateException("why does this exception not fail the test?")
    }
    // NOTE: No need for runBlocking any more
    job.join() // ensures exception is thrown before test function completes
}

What happens here? Just like the test with launch, this test passes if you run it, but the exception gets printed on the console.

So, using launch to start a new coroutine is very much like using thread to start a new thread. If it fails, the error gets handled by uncaught exception handler in thread and by CoroutineExceptionHandler (see it in the docs) by launch. Exceptions in launch are not swallowed, but are handled by the coroutine exception handler.

If you want exception to propagate to the test, you shall replace launch with async and replace join with await in your code. See also this question: What is the difference between launch/join and async/await in Kotlin coroutines

UPDATE: Kotlin coroutines had recently introduced the concept of "Structured Concurrency" to avoid this kind of exception loss. The code in this question does not compile anymore. To compile it, you'd have to either explicitly say GlobalScope.launch (as in "I confirm that it Ok to loose my exceptions, here is my signature") or wrap the test into runBlocking { ... }, in which case exception is not lost.

Roman Elizarov
  • 27,053
  • 12
  • 64
  • 60
  • I removed the `runBlocking { job.join() }` as it was causing confusion. This test is just a simple example, but my real implementation uses `launch` because I have no need to `.await()` on the result. I want to ensure exceptions crash my application. And for tests, I want to ensure that exceptions fail the test if they occur before the test method completes – ZakTaccardi Jun 06 '18 at 14:38
  • Just like with threads, if you want exceptions to crash your application, then you should use a custom exception handler (coroutine exception handler for coroutines, uncaught exception handler for threads). – Roman Elizarov Jun 06 '18 at 15:06
  • Makes sense. I came up with a JUnit `TestRule` for this - but is there a better solution though? https://stackoverflow.com/a/52301537/891242 – ZakTaccardi Sep 12 '18 at 18:43
2

I was able to create an exception throwing CoroutineContext for tests.

val coroutineContext = Unconfined + CoroutineExceptionHandler { _, throwable ->
        throw throwable
    }

Though this would probably not be suitable for production. Maybe need to catch cancellation exceptions or something, I'm not sure

ZakTaccardi
  • 12,212
  • 15
  • 59
  • 107
  • Were you able to find a more robust approach? This behavior is very frustrating and the biggest thing I miss from RxJava. – David Whitman Jul 26 '18 at 19:45
  • so far, my thoughts are that the best way to address this issue is a test rule that generates a error catching coroutine context, and then in tearDown you go and check if that coroutine caught any errors and fail the test accordingly I feel like there should be a better way though – ZakTaccardi Sep 12 '18 at 03:37
0

A custom test rule so far seems to be the best solution.

/**
 * Coroutines can throw exceptions that can go unnoticed by the JUnit Test Runner which will pass
 * a test that should have failed. This rule will ensure the test fails, provided that you use the
 * [CoroutineContext] provided by [dispatcher].
 */
class CoroutineExceptionRule : TestWatcher(), TestRule {

    private val exceptions = Collections.synchronizedList(mutableListOf<Throwable>())

    val dispatcher: CoroutineContext
        get() = Unconfined + CoroutineExceptionHandler { _, throwable ->
            // I want to hook into test lifecycle and fail test immediately here
            exceptions.add(throwable)
            // this throw will not always fail the test. this does print the stacktrace at least
            throw throwable 
        }

    override fun starting(description: Description) {
        // exceptions from a previous test execution should not fail this test
        exceptions.clear()
    }

    override fun finished(description: Description) {
        // instead of waiting for test to finish to fail it
        exceptions.forEach { throw AssertionError(it) }
    }
}

I'm hoping to improve it via this post though

UPDATE: just use runBlocking - like Roman suggests.

ZakTaccardi
  • 12,212
  • 15
  • 59
  • 107