0

(Kotlin 1.5.21, kotlinx-coroutines-test 1.5.0)

Please consider the following code inside a 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 corresponds to viewModelScope, in tests it is replaced by a TestCoroutineScope. The dispatchers.IO is a proxy to Dispatchers.IO, in tests it is a TestCoroutineDispatcher. In this case, the app's behavior is undefined if bar() returns null, so I want it to crash if that's the case. Now I'm trying to (JUnit4) test this code:

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

The test fails because of the very same exception it is supposed to test for:

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

I have the feeling I'm missing something quite obvious here... Question: is the code in my ViewModel the right way to throw an exception from a coroutine and if yes, how can I unit test it?

Droidman
  • 10,983
  • 15
  • 91
  • 137
  • 1
    https://stackoverflow.com/questions/5912240/android-junit-testing-how-to-expect-an-exception – ADM May 31 '22 at 10:59
  • Does this answer your question? [JUnit4 : testing for expected exception](https://stackoverflow.com/questions/8353173/junit4-testing-for-expected-exception) – possum May 31 '22 at 11:15
  • 1
    both of the linked questions have very little in common with the one I'm asking. I know how to use JUnit4 and I also have a couple hundred of tests checking for expected exceptions. My problem is that the code under test is launching a **coroutine** and **something under the hood** seems to fail the test before it has a chance to complete. The question is how to figure out this "something". This question is not tagged "Java" for a good reason. – Droidman May 31 '22 at 11:27

2 Answers2

0

If nothing else works I can suggest to move the code, which throws an exception, to another method and test this method:

// 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()
}

Or using JUnit Jupiter allows to test throwing Exceptions by using assertThrows method. Example:

assertThrows<IllegalStateException> { tested.doWorkThatThrows() }
BigSt
  • 19,607
  • 4
  • 63
  • 81
0
  1. Why the test is green:

code in launch{ ... } is beeing executed asynchronously with the test method. To recognize it try to modify mayThrow method (see code snippet below), so it returns a result disregarding of what is going on inside launch {...} To make the test red replace launch with runBlocking (more details in docs, just read the first chapter and run the examples)


@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. Why it looks like "test fails because of the very same exception ..."

the test does not fail, but we see the exception stacktrace in console, because the default exception handler works so and it is applied, because in this case the custom exception handler CoroutineExceptionHandler throws (detailed explanation)

  1. How to test

Function mayThrow has too many responsibilities, that is why it is hard to test. It is a standard problem and there are standard treatments (first, second): long story short is apply Single responsibility principle. For instance, pass exception handler to the function

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
}
diziaq
  • 5,216
  • 12
  • 46
  • 65