Skip to content

Commit 776250b

Browse files
authored
Implement new test dispatchers (#2986)
Defines two test dispatchers: * StandardTestDispatcher, which, combined with runTest, gives an illusion of an event loop; * UnconfinedTestDispatcher, which is like Dispatchers.Unconfined, but skips delays. By default, StandardTestDispatcher is used due to the somewhat chaotic execution order of Dispatchers.Unconfined. TestCoroutineDispatcher is deprecated. Fixes #1626 Fixes #1742 Fixes #2082 Fixes #2102 Fixes #2405 Fixes #2462
1 parent 9fb2182 commit 776250b

23 files changed

+629
-147
lines changed

kotlinx-coroutines-core/api/kotlinx-coroutines-core.api

+9
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,15 @@ public final class kotlinx/coroutines/TimeoutKt {
545545
public static final fun withTimeoutOrNull-KLykuaI (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
546546
}
547547

548+
public final class kotlinx/coroutines/YieldContext : kotlin/coroutines/AbstractCoroutineContextElement {
549+
public static final field Key Lkotlinx/coroutines/YieldContext$Key;
550+
public field dispatcherWasUnconfined Z
551+
public fun <init> ()V
552+
}
553+
554+
public final class kotlinx/coroutines/YieldContext$Key : kotlin/coroutines/CoroutineContext$Key {
555+
}
556+
548557
public final class kotlinx/coroutines/YieldKt {
549558
public static final fun yield (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
550559
}

kotlinx-coroutines-core/common/src/Unconfined.kt

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ internal object Unconfined : CoroutineDispatcher() {
3838
/**
3939
* Used to detect calls to [Unconfined.dispatch] from [yield] function.
4040
*/
41+
@PublishedApi
4142
internal class YieldContext : AbstractCoroutineContextElement(Key) {
4243
companion object Key : CoroutineContext.Key<YieldContext>
4344

kotlinx-coroutines-test/api/kotlinx-coroutines-test.api

+9-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor
4040
public fun toString ()Ljava/lang/String;
4141
}
4242

43+
public final class kotlinx/coroutines/test/TestCoroutineDispatchersKt {
44+
public static final fun StandardTestDispatcher (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)Lkotlinx/coroutines/test/TestDispatcher;
45+
public static synthetic fun StandardTestDispatcher$default (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestDispatcher;
46+
public static final fun UnconfinedTestDispatcher (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)Lkotlinx/coroutines/test/TestDispatcher;
47+
public static synthetic fun UnconfinedTestDispatcher$default (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestDispatcher;
48+
}
49+
4350
public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor {
4451
public fun <init> ()V
4552
public fun cleanupTestCoroutinesCaptor ()V
@@ -69,6 +76,8 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
6976
public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
7077
public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V
7178
public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V
79+
public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope;
80+
public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
7281
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J
7382
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
7483
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -77,7 +86,6 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
7786
}
7887

7988
public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay {
80-
public fun <init> ()V
8189
public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
8290
public abstract fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
8391
public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle;

kotlinx-coroutines-test/common/src/DelayController.kt

+15-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
4+
@file:Suppress("DEPRECATION")
45

56
package kotlinx.coroutines.test
67

@@ -102,7 +103,10 @@ public interface DelayController {
102103
* This is useful when testing functions that start a coroutine. By pausing the dispatcher assertions or
103104
* setup may be done between the time the coroutine is created and started.
104105
*/
105-
@ExperimentalCoroutinesApi
106+
@Deprecated(
107+
"Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.",
108+
level = DeprecationLevel.WARNING
109+
)
106110
public suspend fun pauseDispatcher(block: suspend () -> Unit)
107111

108112
/**
@@ -111,7 +115,10 @@ public interface DelayController {
111115
* When paused, the dispatcher will not execute any coroutines automatically, and you must call [runCurrent] or
112116
* [advanceTimeBy], or [advanceUntilIdle] to execute coroutines.
113117
*/
114-
@ExperimentalCoroutinesApi
118+
@Deprecated(
119+
"Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.",
120+
level = DeprecationLevel.WARNING
121+
)
115122
public fun pauseDispatcher()
116123

117124
/**
@@ -121,12 +128,15 @@ public interface DelayController {
121128
* time and execute coroutines scheduled in the future use, one of [advanceTimeBy],
122129
* or [advanceUntilIdle].
123130
*/
124-
@ExperimentalCoroutinesApi
131+
@Deprecated(
132+
"Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.",
133+
level = DeprecationLevel.WARNING
134+
)
125135
public fun resumeDispatcher()
126136
}
127137

128138
internal interface SchedulerAsDelayController : DelayController {
129-
public val scheduler: TestCoroutineScheduler
139+
val scheduler: TestCoroutineScheduler
130140

131141
/** @suppress */
132142
@Deprecated(
@@ -178,7 +188,7 @@ internal interface SchedulerAsDelayController : DelayController {
178188
scheduler.runCurrent()
179189
if (!scheduler.isIdle()) {
180190
throw UncompletedCoroutinesError(
181-
"Unfinished coroutines during teardown. Ensure all coroutines are" +
191+
"Unfinished coroutines during tear-down. Ensure all coroutines are" +
182192
" completed or cancelled by your test."
183193
)
184194
}

kotlinx-coroutines-test/common/src/TestBuilders.kt

+4-5
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import kotlin.coroutines.*
4444
*/
4545
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
4646
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
47-
val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
47+
val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
4848
val scheduler = scope.testScheduler
4949
val deferred = scope.async {
5050
scope.testBody()
@@ -197,10 +197,9 @@ public expect class TestResult
197197
*
198198
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
199199
* scope created for the test, [context] also can be used to change how the test is executed.
200-
* See the [TestCoroutineScope] constructor documentation for details.
200+
* See the [createTestCoroutineScope] documentation for details.
201201
*
202-
* @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for
203-
* details.
202+
* @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details.
204203
*/
205204
@ExperimentalCoroutinesApi
206205
public fun runTest(
@@ -210,7 +209,7 @@ public fun runTest(
210209
): TestResult {
211210
if (context[RunningInRunTest] != null)
212211
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
213-
val testScope = TestBodyCoroutine<Unit>(TestCoroutineScope(context + RunningInRunTest))
212+
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest))
214213
val scheduler = testScope.testScheduler
215214
return createTestResult {
216215
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with

kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt

+3-7
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import kotlin.coroutines.*
2121
*
2222
* @see DelayController
2323
*/
24-
@ExperimentalCoroutinesApi
24+
@Deprecated("The execution order of `TestCoroutineDispatcher` can be confusing, and the mechanism of " +
25+
"pausing is typically misunderstood. Please use `StandardTestDispatcher` or `UnconfinedTestDispatcher` instead.",
26+
level = DeprecationLevel.WARNING)
2527
public class TestCoroutineDispatcher(public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()):
2628
TestDispatcher(), Delay, SchedulerAsDelayController
2729
{
@@ -34,12 +36,6 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin
3436
}
3537
}
3638

37-
/** @suppress */
38-
override fun processEvent(time: Long, marker: Any) {
39-
check(marker is Runnable)
40-
marker.run()
41-
}
42-
4339
/** @suppress */
4440
override fun dispatch(context: CoroutineContext, block: Runnable) {
4541
checkSchedulerInContext(scheduler, context)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.test
6+
7+
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.channels.*
9+
import kotlinx.coroutines.flow.*
10+
import kotlin.coroutines.*
11+
12+
/**
13+
* Creates an instance of an unconfined [TestDispatcher].
14+
*
15+
* This dispatcher is similar to [Dispatchers.Unconfined]: the tasks that it executes are not confined to any particular
16+
* thread and form an event loop; it's different in that it skips delays, as all [TestDispatcher]s do.
17+
*
18+
* Using this [TestDispatcher] can greatly simplify writing tests where it's not important which thread is used when and
19+
* in which order the queued coroutines are executed.
20+
* The typical use case for this is launching child coroutines that are resumed immediately, without going through a
21+
* dispatch; this can be helpful for testing [Channel] and [StateFlow] usages.
22+
*
23+
* ```
24+
* @Test
25+
* fun testUnconfinedDispatcher() = runTest {
26+
* val values = mutableListOf<Int>()
27+
* val stateFlow = MutableStateFlow(0)
28+
* val job = launch(UnconfinedTestDispatcher(testScheduler)) {
29+
* stateFlow.collect {
30+
* values.add(it)
31+
* }
32+
* }
33+
* stateFlow.value = 1
34+
* stateFlow.value = 2
35+
* stateFlow.value = 3
36+
* job.cancel()
37+
* // each assignment will immediately resume the collecting child coroutine,
38+
* // so no values will be skipped.
39+
* assertEquals(listOf(0, 1, 2, 3), values)
40+
* }
41+
* ```
42+
*
43+
* However, please be aware that, like [Dispatchers.Unconfined], this is a specific dispatcher with execution order
44+
* guarantees that are unusual and not shared by most other dispatchers, so it can only be used reliably for testing
45+
* functionality, not the specific order of actions.
46+
* See [Dispatchers.Unconfined] for a discussion of the execution order guarantees.
47+
*
48+
* In order to support delay skipping, this dispatcher is linked to a [TestCoroutineScheduler], which is used to control
49+
* the virtual time and can be shared among many test dispatchers. If no [scheduler] is passed as an argument, a new one
50+
* is created.
51+
*
52+
* Additionally, [name] can be set to distinguish each dispatcher instance when debugging.
53+
*
54+
* @see StandardTestDispatcher for a more predictable [TestDispatcher].
55+
*/
56+
@ExperimentalCoroutinesApi
57+
@Suppress("FunctionName")
58+
public fun UnconfinedTestDispatcher(
59+
scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
60+
name: String? = null
61+
): TestDispatcher = UnconfinedTestDispatcherImpl(scheduler, name)
62+
63+
private class UnconfinedTestDispatcherImpl(
64+
override val scheduler: TestCoroutineScheduler,
65+
private val name: String? = null
66+
): TestDispatcher() {
67+
68+
override fun isDispatchNeeded(context: CoroutineContext): Boolean = false
69+
70+
@Suppress("INVISIBLE_MEMBER")
71+
override fun dispatch(context: CoroutineContext, block: Runnable) {
72+
checkSchedulerInContext(scheduler, context)
73+
scheduler.sendDispatchEvent()
74+
75+
/** copy-pasted from [kotlinx.coroutines.Unconfined.dispatch] */
76+
/** It can only be called by the [yield] function. See also code of [yield] function. */
77+
val yieldContext = context[YieldContext]
78+
if (yieldContext !== null) {
79+
// report to "yield" that it is an unconfined dispatcher and don't call "block.run()"
80+
yieldContext.dispatcherWasUnconfined = true
81+
return
82+
}
83+
throw UnsupportedOperationException(
84+
"Function UnconfinedTestCoroutineDispatcher.dispatch can only be used by " +
85+
"the yield function. If you wrap Unconfined dispatcher in your code, make sure you properly delegate " +
86+
"isDispatchNeeded and dispatch calls."
87+
)
88+
}
89+
90+
override fun toString(): String = "${name ?: "UnconfinedTestDispatcher"}[scheduler=$scheduler]"
91+
}
92+
93+
/**
94+
* Creates an instance of a [TestDispatcher] whose tasks are run inside calls to the [scheduler].
95+
*
96+
* This [TestDispatcher] instance does not itself execute any of the tasks. Instead, it always sends them to its
97+
* [scheduler], which can then be accessed via [TestCoroutineScheduler.runCurrent],
98+
* [TestCoroutineScheduler.advanceUntilIdle], or [TestCoroutineScheduler.advanceTimeBy], which will then execute these
99+
* tasks in a blocking manner.
100+
*
101+
* In practice, this means that [launch] or [async] blocks will not be entered immediately (unless they are
102+
* parameterized with [CoroutineStart.UNDISPATCHED]), and one should either call [TestCoroutineScheduler.runCurrent] to
103+
* run these pending tasks, which will block until there are no more tasks scheduled at this point in time, or, when
104+
* inside [runTest], call [yield] to yield the (only) thread used by [runTest] to the newly-launched coroutines.
105+
*
106+
* If a [scheduler] is not passed as an argument, a new one is created.
107+
*
108+
* One can additionally pass a [name] in order to more easily distinguish this dispatcher during debugging.
109+
*
110+
* @see UnconfinedTestDispatcher for a dispatcher that is not confined to any particular thread.
111+
*/
112+
@Suppress("FunctionName")
113+
public fun StandardTestDispatcher(
114+
scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
115+
name: String? = null
116+
): TestDispatcher = StandardTestDispatcherImpl(scheduler, name)
117+
118+
private class StandardTestDispatcherImpl(
119+
override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
120+
private val name: String? = null
121+
): TestDispatcher() {
122+
123+
override fun dispatch(context: CoroutineContext, block: Runnable) {
124+
checkSchedulerInContext(scheduler, context)
125+
scheduler.registerEvent(this, 0, block) { false }
126+
}
127+
128+
override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]"
129+
}

0 commit comments

Comments
 (0)