runBlockingTest throws IllegalStateException: This job has not completed yet

Sep 26, 2020

Update 2021-12-27

runBlockingTest was deprected in kotlinx.coroutines version 1.6.0, and will be removed in 1.8.0. The replacement runTest doesn’t suffer from the issue described in this post.

runBlockingTest has quite a few pitfalls. One of them is this obscure exception:

java.lang.IllegalStateException: This job has not completed yet

	at kotlinx.coroutines.JobSupport.getCompletionExceptionOrNull(JobSupport.kt:1189)
	at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:53)
	at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest$default(TestBuilders.kt:45)

tl;dr: runBlockingTest doesn’t execute the test body until it’s completed, but only until the test-dispatcher is idle. The exception is thrown when the test body in runBlockingTest suspends and the dispatcher doesn’t know when it will resume. That can happen if suspend funs are called in the test that have to wait for something from outside the test body. Try to avoid such suspend funs within runBlockingTest. Maybe you can work around that with Fake implementations. If nothing else works there is still the option to use runBlocking.

Cause of the exception

We can cause the exception like this:

suspend fun suspendAndNeverResume() = suspendCoroutine<Unit> { continuation ->
    // We never call `continuation.resume(Unit)` here.
}

@Test
fun `suspend but never resume in runBlockingTest`() = runBlockingTest {
    suspendAndNeverResume()
}

or also like this:

@Test
fun `CompletableDeferred that is never completed`() {
    val neverCompleted = CompletableDeferred<Unit>()
    runBlockingTest {
        neverCompleted.await()
    }
}

All these example tests will not suspend forever, but instead will fail immediately with IllegalStateException: This job has not completed yet.

To understand why it’s happening, we can peek into the runBlockingTest implementation:

public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
    
    val deferred = scope.async {
        scope.testBody()
    }
    dispatcher.advanceUntilIdle()
    deferred.getCompletionExceptionOrNull()?.let {
        throw it
    }
    
}

(source on GitHub)

We can see that testBody gets executed in an async block. The returned Deferred (=Promise) is stored in deferred.

Instead of waiting for the testBody to finish with deferred.await(), the implementation only advances the dispatcher until there is nothing left to do immediately by calling dispatcher.advanceUntilIdle(). Functions like delay tell the dispatcher immediately when they need to resume. When there are multiple coroutines active in a test they usually resume each other. All resumes add a new task for the dispatcher, so it’s usually never idle before the test is done. That’s why we don’t see this exception very often.

The next statement is not deferred.await() to wait for the testBody to finish, but deferred.getCompletionExceptionOrNull(). It re-throws the exception that was thrown within the testBody if there was any. But in our case, there was no exception and the testBody isn’t completed yet. If we peek into the implementation of getCompletionExceptionOrNull as well, we see that the exception that we’re getting is thrown there:

public fun getCompletionExceptionOrNull(): Throwable? {
    val state = this.state
    check(state !is Incomplete) { "This job has not completed yet" }
    return state.exceptionOrNull
}

(source on GitHub)

Conclusion

runBlockingTest doesn’t wait for the testBody to finish but waits for an idle dispatcher. That’s why we’re getting the IllegalStateException: This job has not completed yet when the test body suspends but there is no resume scheduled in the dispatcher yet. It avoids that tests are taking too long or hang forever when the resume never comes.

That can be annoying especially within Instrumentation Tests. There, we sometimes really have to wait for some Android stuff to happen until we can resume the test. In that case, you can fall back on runBlocking. But be aware that runBlocking might hang forever if the function really doesn’t resume and it also doesn’t come with eager execution by default.

You might also like