Why doesn't the `withTimeout` throw `TimeoutCancel...
# coroutines
m
Why doesn't the
withTimeout
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`.
Copy code
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()
}
j
I'm not sure what exactly you expect here. When wrapping with
async
, 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
.
m
Well I expect ``test async with withTimeout`` test to pass. Since in production it behaves as I would expect,
.await
rethrows. In test environment it does not and I am wondering what am I doing wrong.
To update, following your suggestion about structured concurrency, if I pass the scope from
runTest
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?