Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How can you use runBlockingTest with a coroutine you do not cancel? #1531

Closed
ZakTaccardi opened this issue Sep 12, 2019 · 12 comments
Closed
Assignees

Comments

@ZakTaccardi
Copy link

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?

@elizarov
Copy link
Contributor

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)

@ZakTaccardi
Copy link
Author

ZakTaccardi commented Oct 28, 2019

@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’s 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’t see what problem throwing the exception solves - why does the actor coroutine need to be cancelled manually? Why can’t scope.cancel() be used to cancel the actor?

@elizarov
Copy link
Contributor

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.

@ZakTaccardi
Copy link
Author

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?

@ZakTaccardi
Copy link
Author

ZakTaccardi commented May 8, 2020

scope.cancel() would also work

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

@qwwdfsad qwwdfsad self-assigned this May 8, 2020
@qwwdfsad qwwdfsad added the test label May 8, 2020
@fardavide
Copy link

@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?

@ursusursus
Copy link

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

@takieddine12
Copy link

I'm also facing this issue and could not find any workaround , did anyone find a solution for this issue , Thank you ?

@takieddine12
Copy link

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

I guess that is not the case , because even in my code , it does not cancel the job after work is done .. any solution you found ??

@qwwdfsad qwwdfsad assigned dkhalanskyjb and unassigned qwwdfsad Jan 31, 2022
@dkhalanskyjb
Copy link
Collaborator

The idea behind runTest not canceling the scope on the finish is that, in real life, not canceling a scope will lead to coroutines leaking, which could lead to problems.

Consider this contrived code:

        repeat(1000) {
            val deferred = CompletableDeferred<Int>()
            launch {
                deferred.await()
            }
        }

If we never cancel the scope in which this happens, the coroutines that have no chance of ever running will use up the space.

Now, consider some code (that's quite possible in real life) that periodically creates a coroutine that never runs. Now, this would be a full-fledged memory leak.

If we just canceled the scope before ending the test, we would have no chance of learning about these leaks.

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

No longer true, now the scopes properly have Job instances!

@rasmusohrstig
Copy link

I'm not sure if I like this behaviour or not.

What is the purpose of asserting that all coroutines are done at the end of runTest()? I suppose it could be to protect us from inadvertently sharing state between tests. Perhaps it is a necessary complication which we need to deal with when we test code that uses coroutines.

I still think it is a bit annoying to have to run the cleanup code of our class in every test. Is there a way to opt-out of this behaviour of runTest()? Cancelling the scope or the job causes the test to fail with kotlinx.coroutines.JobCancellationException.

@dkhalanskyjb
Copy link
Collaborator

Closing in favor of #3287, which is more up-to-date (much information here is not applicable anymore) and contains a general overview of the problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants