5
5
package kotlinx.coroutines.test
6
6
7
7
import kotlinx.coroutines.*
8
+ import kotlinx.coroutines.selects.select
9
+ import java.lang.StringBuilder
8
10
import kotlin.coroutines.*
9
11
12
+ private const val DEFAULT_TEST_TIMEOUT = 30_000L
13
+
14
+ /* *
15
+ * A strategy for waiting on coroutines executed on other dispatchers inside a [runBlockingTest].
16
+ *
17
+ * Most tests should use [MultiDispatcherWaitConfig]. As an optimization, a test that executes coroutines only on a
18
+ * [TestCoroutineDispatcher] and never interacts with other dispatchers may use [SingleDispatcherWaitConfig].
19
+ *
20
+ * A test may subclass this to customize the wait in advanced cases.
21
+ */
22
+ interface WaitConfig {
23
+ /* *
24
+ * How long (in wall-clock time) to wait for other Dispatchers to complete coroutines during a [runBlockingTest].
25
+ *
26
+ * This delay is not related to the virtual time of a [TestCoroutineDispatcher], but is how long a test should allow
27
+ * another dispatcher, like Dispatchers.IO, to perform a time consuming activity such as reading from a database.
28
+ */
29
+ val wait: Long
30
+ }
31
+
32
+ /* *
33
+ * Do not wait for coroutines executing on another [Dispatcher] in [runBlockingTest].
34
+
35
+ * Always fails with an uncompleted coroutine when any coroutine in the test executes on any other dispatcher (including
36
+ * calls to [withContext]). It should not be used for most tests, instead use the default value of
37
+ * [MultiDispatcherWaitConfig].
38
+ *
39
+ * This configuration should only be used as an optimization for tests that intentionally create an uncompleted
40
+ * coroutine and execute all coroutines on the [TestCoroutineDispatcher] used by [runBlockingTest].
41
+ *
42
+ * If in doubt, prefer [MultiDispatcherWaitConfig].
43
+ */
44
+ object SingleDispatcherWaitConfig : WaitConfig {
45
+ /* *
46
+ * This value is ignored by [runBlockingTest] on [SingleDispatcherWaitConfig]
47
+ */
48
+ override val wait = 0L
49
+
50
+ override fun toString () = " SingleDispatcherWaitConfig"
51
+ }
52
+
53
+ /* *
54
+ * Wait up to 30 seconds for any coroutines running on another [Dispatcher] to complete in [runBlockingTest].
55
+ *
56
+ * This is the default value for [runBlockingTest] and the recommendation for most tests. This configuration will allow
57
+ * for coroutines to be launched on another dispatcher inside the test (e.g. calls to `withContext(Dispatchers.IO)`).
58
+ *
59
+ * This allows for code like the following to be tested correctly using [runBlockingTest]:
60
+ *
61
+ * ```
62
+ * suspend fun delayOnDefault() = withContext(Dispatchers.Default) {
63
+ * // this delay does not use the virtual-time of runBlockingTest since it's executing on Dispatchers.Default
64
+ * delay(50)
65
+ * }
66
+ *
67
+ * runBlockingTest {
68
+ * // Note: This test takes at least 50ms (real time) to run
69
+ *
70
+ * // delayOnDefault will suspend the runBlockingTest coroutine for 50ms [real-time: 0; virtual-time: 0]
71
+ * delayOnDefault()
72
+ * // runBlockingTest resumes 50ms later (real time) [real-time: 50; virtual-time: 0]
73
+ *
74
+ * delay(10)
75
+ * //this delay will auto-progress since it's in runBlockingTest [real-time: 50; virtual-time: 10]
76
+ * }
77
+ * ```
78
+ */
79
+ object MultiDispatcherWaitConfig: WaitConfig {
80
+ /* *
81
+ * Default wait is 30 seconds.
82
+ *
83
+ * {@inheritDoc}
84
+ */
85
+ override val wait = DEFAULT_TEST_TIMEOUT
86
+
87
+ override fun toString () = " MultiDispatcherWaitConfig[wait = 30s]"
88
+ }
89
+
10
90
/* *
11
91
* Executes a [testBody] inside an immediate execution dispatcher.
12
92
*
@@ -38,64 +118,104 @@ import kotlin.coroutines.*
38
118
* (including coroutines suspended on join/await).
39
119
*
40
120
* @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler],
41
- * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively.
121
+ * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively.
122
+ * @param waitConfig strategy for waiting on other dispatchers to complete during the test. [SingleDispatcherWaitConfig]
123
+ * will never wait, other values will wait for [WaitConfig.wait]ms.
42
124
* @param testBody The code of the unit-test.
43
125
*/
44
126
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
45
- public fun runBlockingTest (context : CoroutineContext = EmptyCoroutineContext , testBody : suspend TestCoroutineScope .() -> Unit ) {
127
+ public fun runBlockingTest (
128
+ context : CoroutineContext = EmptyCoroutineContext ,
129
+ waitConfig : WaitConfig = MultiDispatcherWaitConfig ,
130
+ testBody : suspend TestCoroutineScope .() -> Unit
131
+ ) {
46
132
val (safeContext, dispatcher) = context.checkArguments()
47
133
val startingJobs = safeContext.activeJobs()
48
- val scope = TestCoroutineScope (safeContext)
49
134
50
- val deferred = scope.async {
51
- scope.testBody()
135
+ var testScope: TestCoroutineScope ? = null
136
+
137
+ val deferred = CoroutineScope (safeContext).async {
138
+ val localTestScope = TestCoroutineScope (coroutineContext)
139
+ testScope = localTestScope
140
+ localTestScope.testBody()
52
141
}
53
142
54
- // run any outstanding coroutines that can be completed by advancing virtual-time
55
- dispatcher.advanceUntilIdle()
56
-
57
- // fetch results from the coroutine - this may require a thread hop if some child coroutine was *completed* on
58
- // another thread during this test so we must use an invokeOnCompletion handler to retrieve the result.
59
-
60
- // There are two code paths for fetching the error:
61
- //
62
- // 1. The job was already completed (happy path, normal test)
63
- // - invokeOnCompletion was executed immediately and errorThrownByTestOrNull is already at it's final value so
64
- // we can throw it
65
- // 2. The job has not already completed (always fail the test due to error or time-based non-determinism)
66
- // - invokeOnCompletion will not be triggered right away. To avoid introducing wall non-deterministic behavior
67
- // (the deferred may complete between here and the call to activeJobs below) this will always be considered a
68
- // test failure.
69
- // - this will not happen if all coroutines are only waiting to complete due to thread hops, but may happen
70
- // if another thread triggers completion concurrently with this cleanup code.
71
- //
72
- // give test code errors a priority in the happy path, throw here if the error is already known.
73
- val (wasCompletedAfterTest, errorThrownByTestOrNull) = deferred.getResultIfKnown()
74
- errorThrownByTestOrNull?.let { throw it }
75
-
76
- scope.cleanupTestCoroutines()
77
- val endingJobs = safeContext.activeJobs()
78
- if ((endingJobs - startingJobs).isNotEmpty()) {
79
- throw UncompletedCoroutinesError (" Test finished with active jobs: $endingJobs " )
143
+ val didTimeout = deferred.waitForCompletion(waitConfig, dispatcher)
144
+
145
+ if (deferred.isCompleted) {
146
+ deferred.getCompletionExceptionOrNull()?.let {
147
+ throw it
148
+ }
80
149
}
81
150
82
- if (! wasCompletedAfterTest) {
83
- // Handle path #2, we are going to fail the test in an opinionated way at this point so let the developer know
84
- // how to fix it.
85
- throw UncompletedCoroutinesError (" Test completed all jobs after cleanup code started. This is " +
86
- " thrown to avoid non-deterministic behavior in tests (the next execution may fail randomly). Ensure " +
87
- " all threads started by the test are completed before returning from runBlockingTest." )
151
+ testScope!! .cleanupTestCoroutines()
152
+ val endingJobs = safeContext.activeJobs()
153
+
154
+ // TODO: should these be separate exceptions to allow for tests to detect difference?
155
+ if (didTimeout) {
156
+ val message = """
157
+ runBlockingTest timed out after waiting ${waitConfig.wait} ms for coroutines to complete due waitConfig = $waitConfig .
158
+ Active jobs after test (may be empty): $endingJobs
159
+ """ .trimIndent()
160
+ throw UncompletedCoroutinesError (message)
161
+ } else if ((endingJobs - startingJobs).isNotEmpty()) {
162
+ val message = StringBuilder (" Test finished with active jobs: " )
163
+ message.append(endingJobs)
164
+ if (waitConfig == SingleDispatcherWaitConfig ) {
165
+ message.append("""
166
+
167
+ Note: runBlockingTest did not wait for other dispatchers due to argument waitConfig = $waitConfig
168
+
169
+ Tip: If this runBlockingTest starts any code on another dispatcher (such as Dispatchers.Default,
170
+ Dispatchers.IO, etc) in any of the functions it calls it will never pass when configured with
171
+ SingleDispatcherWaitConfig. Please update your test to use the default value of MultiDispatcherWaitConfig
172
+ like:
173
+
174
+ runBlockingTest { }
175
+
176
+ """ .trimIndent())
177
+ }
178
+ throw UncompletedCoroutinesError (message.toString())
88
179
}
89
180
}
90
181
91
- private fun Deferred<Unit>.getResultIfKnown (): Pair <Boolean , Throwable ?> {
92
- var testError: Throwable ? = null
93
- var wasExecuted = false
94
- invokeOnCompletion { errorFromTestOrNull ->
95
- testError = errorFromTestOrNull
96
- wasExecuted = true
97
- }.dispose()
98
- return Pair (wasExecuted, testError)
182
+ private fun Deferred<Unit>.waitForCompletion (waitConfig : WaitConfig , dispatcher : DelayController ): Boolean {
183
+ var didTimeout = false
184
+ when (waitConfig) {
185
+ SingleDispatcherWaitConfig -> dispatcher.advanceUntilIdle()
186
+ else -> {
187
+ runBlocking {
188
+ val subscription = dispatcher.queueState.openSubscription()
189
+ dispatcher.advanceUntilIdle()
190
+
191
+ var finished = false
192
+ try {
193
+ while (! finished) {
194
+ finished = select {
195
+ this @waitForCompletion.onAwait {
196
+ true
197
+ }
198
+ onTimeout(waitConfig.wait) {
199
+ didTimeout = true
200
+ true
201
+ }
202
+ subscription.onReceive { queueState ->
203
+ when (queueState) {
204
+ DelayController .QueueState .Idle -> Unit
205
+ else -> dispatcher.advanceUntilIdle()
206
+ }
207
+ false
208
+ }
209
+ }
210
+ }
211
+ } finally {
212
+ subscription.cancel()
213
+ }
214
+ }
215
+
216
+ }
217
+ }
218
+ return didTimeout
99
219
}
100
220
101
221
private fun CoroutineContext.activeJobs (): Set <Job > {
@@ -107,13 +227,13 @@ private fun CoroutineContext.activeJobs(): Set<Job> {
107
227
*/
108
228
// todo: need documentation on how this extension is supposed to be used
109
229
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
110
- public fun TestCoroutineScope.runBlockingTest (block : suspend TestCoroutineScope .() -> Unit ) = runBlockingTest(coroutineContext, block)
230
+ public fun TestCoroutineScope.runBlockingTest (configuration : WaitConfig = MultiDispatcherWaitConfig , block : suspend TestCoroutineScope .() -> Unit ) = runBlockingTest(coroutineContext, configuration , block)
111
231
112
232
/* *
113
233
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher].
114
234
*/
115
235
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
116
- public fun TestCoroutineDispatcher.runBlockingTest (block : suspend TestCoroutineScope .() -> Unit ) = runBlockingTest(this , block)
236
+ public fun TestCoroutineDispatcher.runBlockingTest (configuration : WaitConfig = MultiDispatcherWaitConfig , block : suspend TestCoroutineScope .() -> Unit ) = runBlockingTest(this , configuration , block)
117
237
118
238
private fun CoroutineContext.checkArguments (): Pair <CoroutineContext , DelayController > {
119
239
// TODO optimize it
0 commit comments