How to unit test a kotlin coroutine that throws? - android

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

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

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

Related

JUnit - test fail when looping and observing livedata

example 1]
#Test
fun `validate live data`() = runBlocking {
`when`(repository.GetList()).thenReturn(list)
val vm = TestViewModel(testUseCase, userManager)
idHashMap.keys.forEach {
vm.getList(it)
vm.listLivedata.observeForeEver {
assertEquals(
(vm.listLivedata.getOrAwaitValue() as Result.Success<List>)?.data?.listData!!::class.java,
idHashMap[it]
)
assertTrue(
vm.listLiveData.getOrAwaitValue() is Result.Success,
"error"
)
}
}
}
example 2]
#Test
fun `validate live data`() = runBlocking {
`when`(repository.GetList()).thenReturn(list)
val vm = TestViewModel(testUseCase, userManager)
idHashMap.keys.forEach {
vm.getList(it)
vm.listLivedata.observeForeEver {
assertTrue(
vm.listLiveData.getOrAwaitValue() is Result.Success,
"error"
)
}
}
}
1st example always fails the test due to asserEquals() but 2nd always passes a test(when I remove assertEqual()); I wonder what is the reason? Is calling operation that update livedata inside loop, somehow causing livedata issue?

Kotlin coroutine Job's join method

I have found very strange documentation for join method:
In particular, it means that a parent coroutine invoking join on a
child coroutine that was started using launch(coroutineContext) { ...
} builder throws CancellationException if the child had crashed,
unless a non-standard CoroutineExceptionHandler is installed in the
context.
I'm not sure that CoroutineExceptionHandler will have effect for CancellationException.
Example:
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) {
val inner = launch { // all this stack of coroutines will get cancelled
throw IOException() // the original exception
}
try {
inner.join()
} catch (e: CancellationException) {
println("handle join")
}
}
job.join()
}
Output:
handle
join CoroutineExceptionHandler got java.io.IOException
So basically CancellationException will still be thrown regardless any installed handlers.
Am I right?
Yes. Your innerJob will still throw a CancellationException, and in your outer job you'll get a crash, since you don't handle the exception.
See: https://pl.kotl.in/1Uqw8nmNS

Handling exception thrown within a withContext() in Android coroutine

I have an android app that I have built up an architecture similar to the Google IO App. I use the CoroutineUseCase from that app (but wrap results in a kotlin.Result<T> instead).
The main code looks like this:
suspend operator fun invoke(parameters: P): Result<R> {
return try {
withContext(Dispatchers.Default) {
work(parameters).let {
Result.success(it)
}
}
} catch (e: Throwable) {
Timber.e(e, "CoroutineUseCase Exception on ${Thread.currentThread().name}")
Result.failure<R>(e)
}
}
#Throws(RuntimeException::class)
protected abstract suspend fun work(parameters: P): R
Then in my view model I am invoking the use case like this:
viewModelScope.launch {
try {
createAccountsUseCase(CreateAccountParams(newUser, Constants.DEFAULT_SERVICE_DIRECTORY))
.onSuccess {
// Update UI for success
}
.onFailure {
_errorMessage.value = Event(it.message ?: "Error")
}
} catch (t: Throwable) {
Timber.e("Caught exception (${t.javaClass.simpleName}) in ViewModel: ${t.message}")
}
My problem is even though the withContext call in the use case is wrapped with a try/catch and returned as a Result, the exception is still thrown (hence why I have the catch in my view model code - which i don't want). I want to propagate the error as a Result.failure.
I have done a bit of reading. And my (obviously flawed) understanding is the withContext should create a new scope so any thrown exceptions inside that scope shouldn't cancel the parent scope (read here). And the parent scope doesn't appear to be cancelled as the exception caught in my view model is the same exception type thrown in work, not a CancellationException or is something unwrapping that?. Is that a correct understanding? If it isn't what would be the correct way to wrap the call to work so I can safely catch any exceptions and return them as a Result.failure to the view model.
Update:
The implementation of the use case that is failing. In my testing it is the UserPasswordInvalidException exception that is throwing.
override suspend fun work(parameters: CreateAccountParams): Account {
val tokenClient = with(parameters.serviceDirectory) {
TokenClient(tokenAuthorityUrl, clientId, clientSecret, moshi)
}
val response = tokenClient.requestResourceOwnerPassword(
parameters.newUser.emailAddress!!,
parameters.newUser.password!!,
"some scopes offline_access"
)
if (!response.isSuccess || response.token == null) {
response.statusCode?.let {
if (it == 400) {
throw UserPasswordInvalidException("Login failed. Username/password incorrect")
}
}
response.exception?.let {
throw it
}
throw ResourceOwnerPasswordException("requestResourceOwnerPassword() failed: (${response.message} (${response.statusCode})")
}
// logic to create account
return acc
}
}
class UserPasswordInvalidException(message: String) : Throwable(message)
class ResourceOwnerPasswordException(message: String) : Throwable(message)
data class CreateAccountParams(
val newUser: User,
val serviceDirectory: ServiceDirectory
)
Update #2:
I have logging in the full version here is the relevant details:
2020-09-24 18:12:28.596 25842-25842/com.ipfx.identity E/CoroutineUseCase: CoroutineUseCase Exception on main
com.ipfx.identity.domain.accounts.UserPasswordInvalidException: Login failed. Username/password incorrect
at com.ipfx.identity.domain.accounts.CreateAccountsUseCase.work(CreateAccountsUseCase.kt:34)
at com.ipfx.identity.domain.accounts.CreateAccountsUseCase.work(CreateAccountsUseCase.kt:14)
at com.ipfx.identity.domain.CoroutineUseCase$invoke$2.invokeSuspend(CoroutineUseCase.kt:21)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:56)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:738)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
2020-09-24 18:12:28.598 25842-25842/com.ipfx.identity E/LoginViewModel$createAccount: Caught exception (UserPasswordInvalidException) in ViewModel: Login failed. Username/password incorrect
The full exception is logged inside the catching in CoroutineUseCase.invoke. And then again the details logged inside the catch in the view model.
Update #3
#RKS was correct. His comment caused me to look deeper. My understanding was correct on the exception handling. The problem was in using the kotlin.Result<T> return type. I am not sure why yet but I was somehow in my usage of the result trigger the throw. I switched the to the Result type from the Google IO App source and it works now. I guess enabling its use as a return type wasn't the smartest.
try/catch inside viewModelScope.launch {} is not required.
The following code is working fine,
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class TestCoroutines {
private suspend fun work(): String {
delay(1000)
throw Throwable("Exception From Work")
}
suspend fun invoke(): String {
return try {
withContext(Dispatchers.Default) {
work().let { "Success" }
}
} catch (e: Throwable) {
"Catch Inside:: invoke"
}
}
fun action() {
runBlocking {
val result = invoke()
println(result)
}
}
}
fun main() {
TestCoroutines().action()
}
Please check the entire flow if same exception is being thrown from other places.

try-catch in kotlin coroutine with withContext method

I found some documentation arguing about exception handling in Kotlin's coroutines with launch and async. But I could not found the solution dealing with the withContext.
suppose I have a coroutine like:
fun bar(path: String) {
viewModelScope.launch {
val foo = withContext(Dispatchers.IO) {
foo(path)
}
}
}
fun foo(path: String) {
// do something...
val media = MediaMetadataRetriever()
media.setDataSource(path) // may throw IllegalArgumentException according to API's doc
return media.frameAtTime
}
viewModelScope is imported from the lifecycle-viewmodel-ktx's implementation using a SupervisorJob.
Where should I put a try-catch block to deal with the IOException here?
try-catch in Kotlin coroutines feels a little clumsy, but I think it's partially because the designers of Kotlin don't think you should be using it in the first place. When something is a checked exception (caused by something outside the programmer's control), their recommendation is to wrap it or return null. For example:
fun foo(path: String): Bitmap? {
// do something...
val media = MediaMetadataRetriever()
try {
media.setDataSource(path)
catch (e: IOException) {
Log.e(TAG, "Failed to set media data source", e)
return null
}
return media.frameAtTime
}
fun bar(path: String) {
viewModelScope.launch {
val foo = withContext(Dispatchers.IO) {
foo(path)
}
if (foo == null) {
// show error to user or something
} else {
// do something with smart-cast non-null foo Bitmap
}
}
}
By the way, for a blocking function like foo that you will never call from the main thread, I suggest making it a suspend function and moving the withContext(Dispatchers.IO) into the function so it is self-contained and you can freely call it from any coroutine.

Kotlin Flow unit testing exceptions and job with FlowOn with different Dispatchers

Using the code below to test exceptions with Flow and MockK
#Test
fun given network error returned from repos, should throw exception() =
testCoroutineScope.runBlockingTest {
// GIVEN
every { postRemoteRepository.getPostFlow() } returns flow<List<PostDTO>> {
emit(throw Exception("Network Exception"))
}
// WHEN
var expected: Exception? = null
useCase.getPostFlow()
.catch { throwable: Throwable ->
expected = throwable as Exception
println("⏰ Expected: $expected")
}
.launchIn(testCoroutineScope)
// THEN
println("⏰ TEST THEN")
Truth.assertThat(expected).isNotNull()
Truth.assertThat(expected).isInstanceOf(Exception::class.java)
Truth.assertThat(expected?.message).isEqualTo("Network Exception")
}
And prints
⏰ TEST THEN
⏰ Expected: java.lang.Exception: Network Exception
And the test fails with
expected not to be: null
Method i test is
fun getPostFlow(): Flow<List<Post>> {
return postRemoteRepository.getPostFlow()
// 🔥 This method is just to show flowOn below changes current thread
.map {
// Runs in IO Thread DefaultDispatcher-worker-2
println("⏰ PostsUseCase map() FIRST thread: ${Thread.currentThread().name}")
it
}
.flowOn(Dispatchers.IO)
.map {
// Runs in Default Thread DefaultDispatcher-worker-1
println("⏰ PostsUseCase map() thread: ${Thread.currentThread().name}")
mapper.map(it)
}
// This is a upstream operator, does not leak downstream
.flowOn(Dispatchers.Default)
}
This is not a completely practical function but just to check how Dispatchers work with Flow and tests.
At the time of writing the question i commented out .flowOn(Dispatchers.IO), the one on top
the test passed. Also changing Dispatchers.IO to Dispatchers.Default and caused test to pass. I assume it's due to using different threads.
1- Is there a function or way to set all flowOn methods to same thread without modifying the code?
I tried testing success scenario this time with
#Test
fun `given data returned from api, should have data`() = testCoroutineScope.runBlockingTest {
// GIVEN
coEvery { postRemoteRepository.getPostFlow() } returns flow { emit(postDTOs) }
every { dtoToPostMapper.map(postDTOs) } returns postList
val actual = postList
// WHEN
val expected = mutableListOf<Post>()
// useCase.getPostFlow().collect {postList->
// println("⏰ Collect: ${postList.size}")
//
// expected.addAll(postList)
// }
val job = useCase.getPostFlow()
.onEach { postList ->
println("⏰ Collect: ${postList.size}")
expected.addAll(postList)
}
.launchIn(testCoroutineScope)
job.cancel()
// THEN
println("⏰ TEST THEN")
Truth.assertThat(expected).containsExactlyElementsIn(actual)
}
When i tried to snippet that is commented out, test fails with
java.lang.IllegalStateException: This job has not completed yet
It seems that test could pass if job has been canceled, the one with launchIn that returns job passes.
2- Why with collect job is not canceled, and it only happens when the first flowOn uses Dispatchers.IO?

Categories

Resources