25

I have a suspend function that makes a rest call to an external API that I want to timeout after 1 minute.

suspend fun makeApiCallWithTimeout(): List<ApiResponseData> =
   withTimeout(1.minutes) {
      apiCall()
   }
        

I'm trying to test it with Junit5 and kotlinx.coroutines.test 1.6.0 like so:

@Test
fun `Test api call`() = runTest {
   val responseData = "[]"
   mockWebServer.enqueue(mockResponse(body = responseData)
   val result = sut.makeApiCallWithTimeout()
   advanceUntilIdle()
   assertEquals(0, result.size)
}

Unfortunately, I'm getting errors that look like this:

Timed out waiting for 60000 ms
kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 60000 ms
    at app//kotlinx.coroutines.TimeoutKt.TimeoutCancellationException(Timeout.kt:184)
    at app//kotlinx.coroutines.TimeoutCoroutine.run(Timeout.kt:154)
    at app//kotlinx.coroutines.test.TestDispatcher.processEvent$kotlinx_coroutines_test(TestDispatcher.kt:23)
    at app//kotlinx.coroutines.test.TestCoroutineScheduler.tryRunNextTask(TestCoroutineScheduler.kt:95)
    at app//kotlinx.coroutines.test.TestCoroutineScheduler.advanceUntilIdle(TestCoroutineScheduler.kt:110)
    at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTestCoroutine(TestBuilders.kt:212)
    at app//kotlinx.coroutines.test.TestBuildersKt.runTestCoroutine(Unknown Source)
    at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$1$1.invokeSuspend(TestBuilders.kt:167)
    at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$1$1.invoke(TestBuilders.kt)
    at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$1$1.invoke(TestBuilders.kt)
    at app//kotlinx.coroutines.test.TestBuildersJvmKt$createTestResult$1.invokeSuspend(TestBuildersJvm.kt:13)
    (Coroutine boundary)

It seems that kotlinx.coroutines.test.runTest is advancing virtual time on the withTimeout without giving it any time to execute its body. See (https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/README.md#using-withtimeout-inside-runtest)

Unfortunately, the documentation doesn't provide a way to get around this.

Please advise on how to test this function using runTest.

2
  • 1
    A useful post on a similar issue github.com/Kotlin/kotlinx.coroutines/issues/1266 Commented Jan 10, 2022 at 21:37
  • Why do you use runTest() in the first place? I think its main functionality is to skip time and you don't want that to happen. Also, in most cases it is better to avoid running real network requests and instead mock such services. Although, it makes sense for example in integration tests. Commented Jan 10, 2022 at 22:00

3 Answers 3

16

This is because of delay-skipping.

Here you're using runTest, which brings time-control capabilities to your test. To do so, this coroutine builder provides a dispatcher with a fake time that automatically skips delays (from the real time perspective) but keeps track of the fake time internally.

From the point of view of this dispatcher, everything that doesn't have delay()s runs instantly, while things that do delay make the fake time progress.

However, this cannot be used with things that really take actual time outside of the test dispatcher, because the test will not really wait. So in essence here, withTimeout times out immediately because the actual apiCall() probably runs outside of the dispatcher (and takes real time).

You can easily reproduce this behaviour like this:

@Test
fun test() = runTest {
    withTimeout(1000) { // immediately times out
        apiCall()
    }
}

suspend fun apiCall() = withContext(Dispatchers.IO) {
    Thread.sleep(100) // not even 1s
}

There are usually 2 solutions:

  • if you want to keep using controlled time, you have to make sure you're using the test dispatcher in all the relevant code. This means that the places in your code where you use custom coroutine scopes or explicit dispatchers should allow to inject a dispatcher

  • if you don't really need controlled time, you can use runBlocking instead of runTest (on JVM) or keep using runTest but run the test on another dispatcher like Dispatchers.Default:

fun test() = runTest {
    withContext(Dispatchers.Default) {
        // test code
    }
}
Sign up to request clarification or add additional context in comments.

Comments

2

An addition to Joffrey's answer

Example of injecting the scheduler if you want to keep using controlled time:

@Test
fun test() = runTest {
    val gw = MyGateway(testScheduler)
    withTimeout(1000) {
        gw.apiCall()
    }
}

class MyGateway(private val context: CoroutineContext = Dispatchers.IO) {
    companion object : Logging
    suspend fun apiCall() = withContext(context) {
        Thread.sleep(100) // not even 1s
    }
}

Comments

0

You can also pass the timeout Duration directly to runTest, for example:

@Test
fun `name of your test`() = runTest(timeout = 2.minutes) {
    // test things
}

You can verify this works by letting the test timeout:

kotlinx.coroutines.test.UncompletedCoroutinesError: After waiting for 2m, the test coroutine is not completing, there were active child jobs: ["coroutine#11":DeferredCoroutine{Active}@4ea557c3, "coroutine#12":DeferredCoroutine{Active}@239dc745, "coroutine#13":DeferredCoroutine{Active}@575e6372, "coroutine#14":DeferredCoroutine{Active}@423eb8a4]
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$2$1.invoke(TestBuilders.kt:351)
at app//kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$2$1.invoke(TestBuilders.kt:335)
at app//kotlinx.coroutines.InternalCompletionHandler$UserSupplied.invoke(CompletionHandler.common.kt:67)
at app//kotlinx.coroutines.InvokeOnCancelling.invoke(JobSupport.kt:1438)
at app//kotlinx.coroutines.JobSupport.notifyCancelling(JobSupport.kt:1483)
at app//kotlinx.coroutines.JobSupport.tryMakeCancelling(JobSupport.kt:806)
at app//kotlinx.coroutines.JobSupport.makeCancelling(JobSupport.kt:766)
at app//kotlinx.coroutines.JobSupport.cancelImpl$kotlinx_coroutines_core(JobSupport.kt:682)
at app//kotlinx.coroutines.JobSupport.cancelCoroutine(JobSupport.kt:669)
at app//kotlinx.coroutines.TimeoutCoroutine.run(Timeout.kt:156)
at app//kotlinx.coroutines.EventLoopImplBase$DelayedRunnableTask.run(EventLoop.common.kt:498)
at app//kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:277)
at app//kotlinx.coroutines.DefaultExecutor.run(DefaultExecutor.kt:105)
at [email protected]/java.lang.Thread.run(Unknown Source)

Obviously, its better to use TestCoroutineSchedulers advanceTimeBy() or advanceUntilIdle(), but this is available if needed. I mainly use it to speed up the timeout when writing unit tests.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.