Search code examples
androidkotlinjunit4kotlin-coroutinesandroid-viewmodel

How to unit test a kotlin coroutine that throws?


(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?


Solution

    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
    }