https://kotlinlang.org logo
#coroutines
Title
# coroutines
k

Konrad Biernacki

11/22/2023, 7:31 AM
Hi folks, I've seen some strange behaviour our team is unable to explain and raised an issue with a reproducing project. The TL;DR is: testing the behaviour of a shared flow with
SharingStarted.WhileSubscribed
, and using
StandardTestDispatcher
to advance the clock without triggering unsubscription is doing something unexpected. https://github.com/Kotlin/kotlinx.coroutines/issues/3949
d

Dmitry Khalanskiy [JB]

11/22/2023, 11:35 AM
Hi! I haven't looked into it very closely, but the example that you shared contains at least one problem:
testUnconfinedScope
and
scheduledScope
use different test coroutine schedulers. So,
advanceTimeBy
and
runCurrent
don't affect what happens in
testUnconfinedScope
at all.
In order for them to have a common scheduler, you can: • Create it explicitly and pass to several dispatchers, like
val scheduler = TestCoroutineScheduler(); val testUnconfinedScope = CoroutineScope(UnconfinedTestDispatcher(scheduler))
• Make sure
Dispatchers.setMain
executes before any other dispatchers are created. According to https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-standard-test-dispatcher.html, if
Dispatchers.Main
uses a test dispatcher, all newly-created dispatchers share a scheduler with it.
Does this explain the behavior you've observed?
Ok, I looked into it more closely. Here's roughly what happens: • After 5 seconds pass, the shared flow wants to cancel. • At the same moment of virtual time, a new subscriber arrives. There is a data race: whoever succeeds first decides whether or not the flow gets completed. This depends on the internal ordering of tasks and shouldn't be relied on: we give no guarantees how many times a given function will suspend during its execution. Even the tiny things, like
Dispatchers.Main
vs explicitly using a scheduler, become important, as in one case, the system recognizes "hey, that's the same dispatcher" and performs one suspension fewer, and in the other case it doesn't.
To properly test this, just do something like
Copy code
delay(5.seconds - 1.milliseconds)
instead of
advanceUntilBy
, and instead of
runCurrent
, do
delay(1.millisecond)
after that.
The point about dispatchers not having the same scheduler still stands, though.
k

Konrad Biernacki

11/22/2023, 11:00 PM
The point about the separate schedulers is interesting, and something I missed, thanks for pointing it out. The point of using the unconfined test scope with a separate scheduler is what I believe is the correct technique to collect eagerly from a flow in a test scenario. In my example, I am expecting
scheduledScope
to operate "slower" at the pace of the test scheduler, and I am expecting the collectors in the
testUnconfinedScope
to operate "faster" to collect any emissions, without caring in particular about what any other schedulers are doing.
Thanks for the response, I'll revisit my example and play with the test schedulers a little more.
I believe the theory of the test is sound, and I'm a little disappointed the
- 1.milliseconds
may end up the recommended approach, but happy to fall back to it as it is familiar. I'd like to use the following approach:
Copy code
advanceUntilBy()
// verify an event has not yet occurred
runCurrent()
// verify an event has occurred
over the other one in almost all circumstances when testing a
delay()
I've managed to tweak my example to have the expected behaviour, the key is indeed providing the same test coroutine scheduler instance to
runTest
.
I'm not seeing any adverse behaviour to running simultaneously
UnconfinedTestDispatcher()
with its own test scheduler
d

Dmitry Khalanskiy [JB]

11/23/2023, 8:33 AM
I'd like to use the following approach
It's error-prone, and we're planning to deprecate it: https://github.com/Kotlin/kotlinx.coroutines/issues/3919 Why do you dislike using
delay
?
the key is indeed providing the same test coroutine scheduler instance to
runTest
.
No, this just introduces the same extra suspension that using
Dispatchers.Main
in
scheduledScope
does. This is not at all a solution, as it doesn't change the fundamental fact that you have a data race in your test.
If we publish a new version of coroutines that adds or removes a suspension as part of an internal implementation of some method, your tests will break.