Reputation: 11608
(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?
Upvotes: 5
Views: 6373
Reputation: 7795
code in
launch{ ... }
is beeing executed asynchronously with the test method. To recognize it try to modifymayThrow
method (see code snippet below), so it returns a result disregarding of what is going on insidelaunch {...}
To make the test red replacelaunch
withrunBlocking
(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
}
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)
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
}
Upvotes: 2
Reputation: 30655
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() }
Upvotes: 2