Milan Vidic
04/04/2023, 1:04 PMwithTimeout
throw TimeoutCancellationException
when it is wrapped inside .async
which is executed with TestScope(UnconfinedTestDispatcher())
?
You can reproduce this by running ``test async with withTimeout`` test.
If it is not wrapped with .async
then it work as expected, .await
rethrows. This is covered with ``test just withTimeout`` test.
If we manually throw CancellationException
inside .async
then .await
rethrows as expected, covered with ``test async throws exception`` test.
If we try to catch the CancellationException
inside .async and rethrow general Exception
, than that exception is not rethrown from .await
also. This is covered with ``test async with withTimeout and manual exception throwing` test`.
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.tinylog.kotlin.Logger
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
internal class TestSubjectWithTimeoutAsyncTest {
private val mockSomeInterface: SomeInterface = mockk(relaxed = true)
private val testSubject = spyk(
WithTimeoutAsyncTestScope(
mockSomeInterface,
TestScope(UnconfinedTestDispatcher())
)
)
@Test
fun `test async throws exception`() = runTest {
val result = testSubject.asyncThrowsException()
coVerify {
mockSomeInterface.onError()
}
assertEquals(Result.Failure, result)
}
@Test
fun `test async with withTimeout`() = runTest {
val result = testSubject.asyncWithTimeout()
coVerify {
mockSomeInterface.onError()
}
assertEquals(Result.Failure, result)
}
@Test
fun `test just withTimeout`() = runTest {
val result = testSubject.justWithTimeout()
coVerify {
mockSomeInterface.onError()
}
assertEquals(Result.Failure, result)
}
@Test
fun `test async with withTimeout and manual exception throwing`() = runTest {
val result = testSubject.asyncWithTimeoutAndException()
coVerify {
mockSomeInterface.onError()
}
assertEquals(Result.Failure, result)
}
}
class WithTimeoutAsyncTestScope(
private val someInterface: SomeInterface,
private val scope: CoroutineScope
) {
suspend fun asyncWithTimeout(): Result {
return handleError {
val job = scope.async {
withTimeout(3000) {
delay(3500)
return@withTimeout Result.Success
}
}
job.await()
}
}
suspend fun asyncThrowsException(): Result {
return handleError {
val job = scope.async {
throw CancellationException("My exception")
}
job.await()
}
}
suspend fun justWithTimeout(): Result {
return handleError {
return@handleError withTimeout(3000) {
delay(3500)
return@withTimeout Result.Success
}
}
}
suspend fun asyncWithTimeoutAndException(): Result {
return handleError {
val job = scope.async {
try {
withTimeout(3000) {
delay(3500)
return@withTimeout Result.Success
}
} catch (e: CancellationException) {
Logger.error("First catch block", e)
throw Exception("My exception 1")
}
}
job.await()
}
}
private suspend fun handleError(executionBlock: suspend () -> Result): Result {
return try {
executionBlock()
} catch (e: CancellationException) {
Logger.error("Timeout while executing : ${e.message}", e)
someInterface.onError()
Result.Failure
} catch (e: Exception) {
Logger.error("${e.message}", e)
Result.Failure
}
}
}
sealed class Result {
object Success : Result()
object Failure : Result()
}
interface SomeInterface {
fun onError()
}
Joffrey
04/04/2023, 1:07 PMasync
, you're running a coroutine in a custom TestScope
as you mentioned, effectively escaping structured concurrency. When not wrapping in async, you are respecting structured concurrency by staying in the scope and context provided by runTest
.Milan Vidic
04/04/2023, 1:23 PM.await
rethrows. In test environment it does not and I am wondering what am I doing wrong.Milan Vidic
04/04/2023, 1:26 PMrunTest
to the asyncWithTimeout
as a parameter and call async
on it, than it passes.
So is there a nicer way of testing this, passing this scope?