2

(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
  • 11,485
  • 17
  • 93
  • 141
  • 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
  • 2
    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

2
  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
  • 6,881
  • 16
  • 54
  • 96
  • that makes sense, thanks... though I don't like the idea of giving a custom exception handler as an argument (the function is actually private and called inside the init{} block), since I would have to inject it, adding yet another constructor argument (in addition to the Scope, because there is no lifycycleScope in test env). Wouldn't that be a use case for the `TestCoroutineExceptionHandler`? Tried that, but it didn't work: `private val vmScope: CoroutineScope = TestCoroutineScope(coroutinesExceptionHandler)` – Droidman Jun 08 '22 at 08:45
  • I don't see in the question what are you trying to test, so I can't give an exact answer. Anyway, if we follow the general idea "divide and conquer": attempt to isolate the piece that is subject for testing, add possibility to inject it (ctor or private-package field available from test), mock that piece in test and assert the state of the mock. – diziaq Jun 08 '22 at 09:15
  • `mayThrow` is called in the init{ block} of the ViewModel, so the test setup prepares mocked dependencies for the following test: GIVEN `mocked.bar()` returns null WHEN creating a new instance of `MyViewModel ` THEN an IllegalStateException is thrown. Basically I need to make sure that the app crashes with the specified Exception because the real code uses a `CoroutineExceptionHandler` that throws. – Droidman Jun 08 '22 at 10:27
  • I ended up injecting the `CoroutineExceptionHandler ` and replacing it with a TestCoroutineExceptionHandler in unit tests: `val result = exceptionHandler.uncaughtExceptions[0]` and then `assertThat(result).isInstanceOf(IllegalStateException::class.java)`. Accepting since this answer made me realize that I won't be able to test what I wanted following the approach I had in mind initially. Thanks! – Droidman Jun 08 '22 at 14:33
1

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() }
Sergio
  • 27,326
  • 8
  • 128
  • 149