Kotlinx.coroutines: How can you use runBlockingTest with a coroutine you do not cancel?

Created on 12 Sep 2019  路  7Comments  路  Source: Kotlin/kotlinx.coroutines

In my test code, I have an ongoing actor coroutine that I do not need to directly cancel - I rely on the coroutine scope to cancel it. The below is a simplified example of my production code, which does not directly expose the actor coroutine to test code, so I cannot directly cancel it (obviously, calling actor.close() allows the test to pass).

@Test
fun runBlocking_() = runBlocking<Unit> {
    val emittedNumbers = mutableListOf<Int>()
    val actor = actor<Int> {
        for (i in channel) {
            emittedNumbers.add(i)
        }
    }
    actor.send(1)
    actor.send(2)
    // fails here with:
    // org.junit.ComparisonFailure: 
    // Expected :[1, 2]
    // Actual   :[1]
    assertThat(emittedNumbers)
        .isEqualTo(listOf(1, 2))
}
@Test
fun runBlockingTest_() = runBlockingTest {
    val emittedNumbers = mutableListOf<Int>()
    val actor = actor<Int> {
        for (i in channel) {
            emittedNumbers.add(i)
        }
    }
    actor.send(1)
    actor.send(2)
    assertThat(emittedNumbers)
        .isEqualTo(listOf(1, 2))

    // fails here with:
    // kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs:
}

Looking at cleanupTestCoroutines() - it says

@throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended coroutines.

The actor is suspended - but why does the exception still get thrown? Note - I also tried calling cleanupTestCoroutines inside the runBlockingTest lambda, but still no luck. Am I doing something wrong?

question test

Most helpful comment

scope.cancel() would also work

This actually doesn't work as you can't cancel a TestCoroutineScope, it throws an exception

All 7 comments

You need to explicitly cancel the actor before the end of the test. actor.close() should work (you're telling the actor you're no longer going to send any messages to it)

@elizarov but the actor coroutine is currently suspended.

@throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended coroutines

The actor { } is also a private val of the class under test - it鈥檚 an internal implementation detail, and now it has to be exposed to get it to work with runBlockingTest { }, which makes the test code more brittle.

I also don鈥檛 see what problem throwing the exception solves - why does the actor coroutine need to be cancelled manually? Why can鈥檛 scope.cancel() be used to cancel the actor?

scope.cancel() would also work. But you need to cancel actor somehow either way, regardless of your using runBlocking or runBlockingTest. The only difference is this. If you don't cancel an actor then runBlocking just hangs (waiting forever for an actor to complete) while runBlockingTest throws this exception supposedly to make it easier for you to figure out that you have a problem in your code.

I guess I'm still confused. I do the following to work around the issue - is it invalid?

fun runBlockingTestAllowUncompletedCoroutines(testBody: suspend TestCoroutineScope.() -> Unit) {
    var testsPassed = false
    try {
        runBlockingTest {
            testBody(this)
            testsPassed = true
            this.cancel()
        }
    } catch (exception: UncompletedCoroutinesError) {
        if (testsPassed) {
            // we are okay - this is a workaround for https://github.com/Kotlin/kotlinx.coroutines/issues/1531
        } else {
            throw exception
        }
    }
}

I don't understand why I need to explicitly cancel the actor. Specifically:

val scope: CoroutineScope = ..
val actor = scope.actor {
  // ..
}

The point of structured concurrency here is that when I cancel the scope the actor { .. } is launched with, the actor itself shuts down. When I am under test, this scope is the one from either runBlocking { .. } or runBlockingTest { .. }

What I am testing might be a ViewModel(scope) that internally launches an actor as long as the scope for ViewModel is active. So my test might be:

runBlockingTest {
  val scope = this
  val viewModel = ViewModel(scope)
}

The above test will fail with UncompletedCoroutinesError because I did not explicitly cancel the actor coroutine internal to ViewModel. But the test lambda injected into runBlockingTest { } fully completed, so I would consider this test being a success. Is this wrong?

scope.cancel() would also work

This actually doesn't work as you can't cancel a TestCoroutineScope, it throws an exception

@ZakTaccardi that may not work, I'm on my phone and I was searching / reflecting as I also struggled hard today with testing Channel's.

I think that this might be working

val scope = this + TestCoroutineDispatcher()
ViewModel (scope)
...
scope.cancel()

@elizarov does that sound like a proper form?

I'd also expect the scope to cancel when runBlockingTest lambda completes

Was this page helpful?
0 / 5 - 0 ratings