-
Notifications
You must be signed in to change notification settings - Fork 1.9k
/
Copy pathTestBuilders.kt
572 lines (556 loc) · 24.4 KB
/
TestBuilders.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
/*
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/
@file:JvmName("TestBuildersKt")
@file:JvmMultifileClass
package kotlinx.coroutines.test
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.selects.*
import kotlin.coroutines.*
import kotlin.jvm.*
import kotlin.time.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.internal.*
/**
* A test result.
*
* * On JVM and Native, this resolves to [Unit], representing the fact that tests are run in a blocking manner on these
* platforms: a call to a function returning a [TestResult] will simply execute the test inside it.
* * On JS, this is a `Promise`, which reflects the fact that the test-running function does not wait for a test to
* finish. The JS test frameworks typically support returning `Promise` from a test and will correctly handle it.
*
* Because of the behavior on JS, extra care must be taken when writing multiplatform tests to avoid losing test errors:
* * Don't do anything after running the functions returning a [TestResult]. On JS, this code will execute *before* the
* test finishes.
* * As a corollary, don't run functions returning a [TestResult] more than once per test. The only valid thing to do
* with a [TestResult] is to immediately `return` it from a test.
* * Don't nest functions returning a [TestResult].
*/
@Suppress("NO_ACTUAL_FOR_EXPECT")
public expect class TestResult
/**
* Executes [testBody] as a test in a new coroutine, returning [TestResult].
*
* On JVM and Native, this function behaves similarly to `runBlocking`, with the difference that the code that it runs
* will skip delays. This allows to use [delay] in tests without causing them to take more time than necessary.
* On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior.
*
* ```
* @Test
* fun exampleTest() = runTest {
* val deferred = async {
* delay(1.seconds)
* async {
* delay(1.seconds)
* }.await()
* }
*
* deferred.await() // result available immediately
* }
* ```
*
* The platform difference entails that, in order to use this function correctly in common code, one must always
* immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See
* [TestResult] for details on this.
*
* The test is run on a single thread, unless other [CoroutineDispatcher] are used for child coroutines.
* Because of this, child coroutines are not executed in parallel to the test body.
* In order for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the
* test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]).
*
* ```
* @Test
* fun exampleWaitingForAsyncTasks1() = runTest {
* // 1
* val job = launch {
* // 3
* }
* // 2
* job.join() // the main test coroutine suspends here, so the child is executed
* // 4
* }
*
* @Test
* fun exampleWaitingForAsyncTasks2() = runTest {
* // 1
* launch {
* // 3
* }
* // 2
* testScheduler.advanceUntilIdle() // runs the tasks until their queue is empty
* // 4
* }
* ```
*
* ### Task scheduling
*
* Delay skipping is achieved by using virtual time.
* If [Dispatchers.Main] is set to a [TestDispatcher] via [Dispatchers.setMain] before the test,
* then its [TestCoroutineScheduler] is used;
* otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control
* the virtual time, advancing it, running the tasks scheduled at a specific time etc.
* The scheduler can be accessed via [TestScope.testScheduler].
*
* Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped:
* ```
* @Test
* fun exampleTest() = runTest {
* val elapsed = TimeSource.Monotonic.measureTime {
* val deferred = async {
* delay(1.seconds) // will be skipped
* withContext(Dispatchers.Default) {
* delay(5.seconds) // Dispatchers.Default doesn't know about TestCoroutineScheduler
* }
* }
* deferred.await()
* }
* println(elapsed) // about five seconds
* }
* ```
*
* ### Failures
*
* #### Test body failures
*
* If the created coroutine completes with an exception, then this exception will be thrown at the end of the test.
*
* #### Timing out
*
* There's a built-in timeout of 10 seconds for the test body. If the test body doesn't complete within this time,
* then the test fails with an [AssertionError]. The timeout can be changed by setting the [timeout] parameter.
*
* On timeout, the test body is cancelled so that the test finishes. If the code inside the test body does not
* respond to cancellation, the timeout will not be able to make the test execution stop.
* In that case, the test will hang despite the attempt to terminate it.
*
* On the JVM, if `DebugProbes` from the `kotlinx-coroutines-debug` module are installed, the current dump of the
* coroutines' stack is printed to the console on timeout before the test body is cancelled.
*
* #### Reported exceptions
*
* Unhandled exceptions will be thrown at the end of the test.
* If uncaught exceptions happen after the test finishes, they are propagated in a platform-specific manner:
* see [handleCoroutineException] for details.
* If the test coroutine completes with an exception, the unhandled exceptions are suppressed by it.
*
* #### Uncompleted coroutines
*
* Otherwise, the test will hang until all the coroutines launched inside [testBody] complete.
* This may be an issue when there are some coroutines that are not supposed to complete, like infinite loops that
* perform some background work and are supposed to outlive the test.
* In that case, [TestScope.backgroundScope] can be used to launch such coroutines.
* They will be cancelled automatically when the test finishes.
*
* ### Configuration
*
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
* scope created for the test, [context] also can be used to change how the test is executed.
* See the [TestScope] constructor function documentation for details.
*
* @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details.
*/
public fun runTest(
context: CoroutineContext = EmptyCoroutineContext,
timeout: Duration = DEFAULT_TIMEOUT,
testBody: suspend TestScope.() -> Unit
): TestResult {
check(context[RunningInRunTest] == null) {
"Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details."
}
return TestScope(context + RunningInRunTest).runTest(timeout, testBody)
}
/**
* Executes [testBody] as a test in a new coroutine, returning [TestResult].
*
* On JVM and Native, this function behaves similarly to `runBlocking`, with the difference that the code that it runs
* will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary.
* On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior.
*
* ```
* @Test
* fun exampleTest() = runTest {
* val deferred = async {
* delay(1.seconds)
* async {
* delay(1.seconds)
* }.await()
* }
*
* deferred.await() // result available immediately
* }
* ```
*
* The platform difference entails that, in order to use this function correctly in common code, one must always
* immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See
* [TestResult] for details on this.
*
* The test is run in a single thread, unless other [CoroutineDispatcher] are used for child coroutines.
* Because of this, child coroutines are not executed in parallel to the test body.
* In order for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the
* test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]).
*
* ```
* @Test
* fun exampleWaitingForAsyncTasks1() = runTest {
* // 1
* val job = launch {
* // 3
* }
* // 2
* job.join() // the main test coroutine suspends here, so the child is executed
* // 4
* }
*
* @Test
* fun exampleWaitingForAsyncTasks2() = runTest {
* // 1
* launch {
* // 3
* }
* // 2
* advanceUntilIdle() // runs the tasks until their queue is empty
* // 4
* }
* ```
*
* ### Task scheduling
*
* Delay-skipping is achieved by using virtual time.
* If [Dispatchers.Main] is set to a [TestDispatcher] via [Dispatchers.setMain] before the test,
* then its [TestCoroutineScheduler] is used;
* otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control
* the virtual time, advancing it, running the tasks scheduled at a specific time etc.
* Some convenience methods are available on [TestScope] to control the scheduler.
*
* Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped:
* ```
* @Test
* fun exampleTest() = runTest {
* val elapsed = TimeSource.Monotonic.measureTime {
* val deferred = async {
* delay(1.seconds) // will be skipped
* withContext(Dispatchers.Default) {
* delay(5.seconds) // Dispatchers.Default doesn't know about TestCoroutineScheduler
* }
* }
* deferred.await()
* }
* println(elapsed) // about five seconds
* }
* ```
*
* ### Failures
*
* #### Test body failures
*
* If the created coroutine completes with an exception, then this exception will be thrown at the end of the test.
*
* #### Reported exceptions
*
* Unhandled exceptions will be thrown at the end of the test.
* If the uncaught exceptions happen after the test finishes, the error is propagated in a platform-specific manner.
* If the test coroutine completes with an exception, the unhandled exceptions are suppressed by it.
*
* #### Uncompleted coroutines
*
* This method requires that, after the test coroutine has completed, all the other coroutines launched inside
* [testBody] also complete, or are cancelled.
* Otherwise, the test will be failed (which, on JVM and Native, means that [runTest] itself will throw
* [AssertionError], whereas on JS, the `Promise` will fail with it).
*
* In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
* to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
* for [dispatchTimeoutMs] from the moment when [TestCoroutineScheduler] becomes
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
* task during that time, the timer gets reset.
*
* ### Configuration
*
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
* scope created for the test, [context] also can be used to change how the test is executed.
* See the [TestScope] constructor function documentation for details.
*
* @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details.
*/
@Deprecated(
"Define a total timeout for the whole test instead of using dispatchTimeoutMs. " +
"Warning: the proposed replacement is not identical as it uses 'dispatchTimeoutMs' as the timeout for the whole test!",
ReplaceWith("runTest(context, timeout = dispatchTimeoutMs.milliseconds, testBody)",
"kotlin.time.Duration.Companion.milliseconds"),
DeprecationLevel.WARNING
) // Warning since 1.7.0, was experimental in 1.6.x
public fun runTest(
context: CoroutineContext = EmptyCoroutineContext,
dispatchTimeoutMs: Long,
testBody: suspend TestScope.() -> Unit
): TestResult {
if (context[RunningInRunTest] != null)
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
@Suppress("DEPRECATION")
return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs = dispatchTimeoutMs, testBody)
}
/**
* Performs [runTest] on an existing [TestScope]. See the documentation for [runTest] for details.
*/
public fun TestScope.runTest(
timeout: Duration = DEFAULT_TIMEOUT,
testBody: suspend TestScope.() -> Unit
): TestResult = asSpecificImplementation().let { scope ->
scope.enter()
createTestResult {
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
scope.start(CoroutineStart.UNDISPATCHED, scope) {
/* we're using `UNDISPATCHED` to avoid the event loop, but we do want to set up the timeout machinery
before any code executes, so we have to park here. */
yield()
testBody()
}
var timeoutError: Throwable? = null
var cancellationException: CancellationException? = null
val workRunner = launch(CoroutineName("kotlinx.coroutines.test runner")) {
while (true) {
val executedSomething = testScheduler.tryRunNextTaskUnless { !isActive }
if (executedSomething) {
/** yield to check for cancellation. On JS, we can't use [ensureActive] here, as the cancellation
* procedure needs a chance to run concurrently. */
yield()
} else {
// waiting for the next task to be scheduled, or for the test runner to be cancelled
testScheduler.receiveDispatchEvent()
}
}
}
try {
withTimeout(timeout) {
coroutineContext.job.invokeOnCompletion(onCancelling = true) { exception ->
if (exception is TimeoutCancellationException) {
dumpCoroutines()
val activeChildren = scope.children.filter(Job::isActive).toList()
val completionCause = if (scope.isCancelled) scope.tryGetCompletionCause() else null
var message = "After waiting for $timeout"
if (completionCause == null)
message += ", the test coroutine is not completing"
if (activeChildren.isNotEmpty())
message += ", there were active child jobs: $activeChildren"
if (completionCause != null && activeChildren.isEmpty()) {
message += if (scope.isCompleted)
", the test coroutine completed"
else
", the test coroutine was not completed"
}
timeoutError = UncompletedCoroutinesError(message)
cancellationException = CancellationException("The test timed out")
(scope as Job).cancel(cancellationException!!)
}
}
scope.join()
workRunner.cancelAndJoin()
}
} catch (_: TimeoutCancellationException) {
scope.join()
val completion = scope.getCompletionExceptionOrNull()
if (completion != null && completion !== cancellationException) {
timeoutError!!.addSuppressed(completion)
}
workRunner.cancelAndJoin()
} finally {
backgroundScope.cancel()
testScheduler.advanceUntilIdleOr { false }
val uncaughtExceptions = scope.leave()
throwAll(timeoutError ?: scope.getCompletionExceptionOrNull(), uncaughtExceptions)
}
}
}
/**
* Performs [runTest] on an existing [TestScope].
*
* In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
* to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
* for [dispatchTimeoutMs] from the moment when [TestCoroutineScheduler] becomes
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
* task during that time, the timer gets reset.
*/
@Deprecated(
"Define a total timeout for the whole test instead of using dispatchTimeoutMs. " +
"Warning: the proposed replacement is not identical as it uses 'dispatchTimeoutMs' as the timeout for the whole test!",
ReplaceWith("this.runTest(timeout = dispatchTimeoutMs.milliseconds, testBody)",
"kotlin.time.Duration.Companion.milliseconds"),
DeprecationLevel.WARNING
) // Warning since 1.7.0, was experimental in 1.6.x
public fun TestScope.runTest(
dispatchTimeoutMs: Long,
testBody: suspend TestScope.() -> Unit
): TestResult = asSpecificImplementation().let {
it.enter()
@Suppress("DEPRECATION")
createTestResult {
runTestCoroutineLegacy(it, dispatchTimeoutMs.milliseconds, TestScopeImpl::tryGetCompletionCause, testBody) {
backgroundScope.cancel()
testScheduler.advanceUntilIdleOr { false }
it.legacyLeave()
}
}
}
/**
* Runs [testProcedure], creating a [TestResult].
*/
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
internal expect fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit): TestResult
/** A coroutine context element indicating that the coroutine is running inside `runTest`. */
internal object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
override val key: CoroutineContext.Key<*>
get() = this
override fun toString(): String = "RunningInRunTest"
}
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
* a [TestCoroutineScheduler]. */
internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
/**
* The default timeout to use when running a test.
*/
internal val DEFAULT_TIMEOUT = 10.seconds
/**
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
* [dispatchTimeout] and performing the [cleanup] procedure at the end.
*
* [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
*
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
* return a list of uncaught exceptions that should be reported at the end of the test.
*/
@Deprecated("Used for support of legacy behavior")
internal suspend fun <T : AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutineLegacy(
coroutine: T,
dispatchTimeout: Duration,
tryGetCompletionCause: T.() -> Throwable?,
testBody: suspend T.() -> Unit,
cleanup: () -> List<Throwable>,
) {
val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!!
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) {
testBody()
}
/**
* This is the legacy behavior, kept for now for compatibility only.
*
* The general procedure here is as follows:
* 1. Try running the work that the scheduler knows about, both background and foreground.
*
* 2. Wait until we run out of foreground work to do. This could mean one of the following:
* * The main coroutine is already completed. This is checked separately; then we leave the procedure.
* * It's switched to another dispatcher that doesn't know about the [TestCoroutineScheduler].
* * Generally, it's waiting for something external (like a network request, or just an arbitrary callback).
* * The test simply hanged.
* * The main coroutine is waiting for some background work.
*
* 3. We await progress from things that are not the code under test:
* the background work that the scheduler knows about, the external callbacks,
* the work on dispatchers not linked to the scheduler, etc.
*
* When we observe that the code under test can proceed, we go to step 1 again.
* If there is no activity for [dispatchTimeoutMs] milliseconds, we consider the test to have hanged.
*
* The background work is not running on a dedicated thread.
* Instead, the test thread itself is used, by spawning a separate coroutine.
*/
var completed = false
while (!completed) {
scheduler.advanceUntilIdle()
if (coroutine.isCompleted) {
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
non-trivial dispatches. */
completed = true
continue
}
// in case progress depends on some background work, we need to keep spinning it.
val backgroundWorkRunner = launch(CoroutineName("background work runner")) {
while (true) {
val executedSomething = scheduler.tryRunNextTaskUnless { !isActive }
if (executedSomething) {
// yield so that the `select` below has a chance to finish successfully or time out
yield()
} else {
// no more tasks, we should suspend until there are some more.
// this doesn't interfere with the `select` below, because different channels are used.
scheduler.receiveDispatchEvent()
}
}
}
try {
select<Unit> {
coroutine.onJoin {
// observe that someone completed the test coroutine and leave without waiting for the timeout
completed = true
}
scheduler.onDispatchEventForeground {
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
}
onTimeout(dispatchTimeout) {
throw handleTimeout(coroutine, dispatchTimeout, tryGetCompletionCause, cleanup)
}
}
} finally {
backgroundWorkRunner.cancelAndJoin()
}
}
coroutine.getCompletionExceptionOrNull()?.let { exception ->
val exceptions = try {
cleanup()
} catch (e: UncompletedCoroutinesError) {
// it's normal that some jobs are not completed if the test body has failed, won't clutter the output
emptyList()
}
throwAll(exception, exceptions)
}
throwAll(null, cleanup())
}
/**
* Invoked on timeout in [runTest]. Just builds a nice [UncompletedCoroutinesError] and returns it.
*/
private inline fun <T : AbstractCoroutine<Unit>> handleTimeout(
coroutine: T,
dispatchTimeout: Duration,
tryGetCompletionCause: T.() -> Throwable?,
cleanup: () -> List<Throwable>,
): AssertionError {
val uncaughtExceptions = try {
cleanup()
} catch (e: UncompletedCoroutinesError) {
// we expect these and will instead throw a more informative exception.
emptyList()
}
val activeChildren = coroutine.children.filter { it.isActive }.toList()
val completionCause = if (coroutine.isCancelled) coroutine.tryGetCompletionCause() else null
var message = "After waiting for $dispatchTimeout"
if (completionCause == null)
message += ", the test coroutine is not completing"
if (activeChildren.isNotEmpty())
message += ", there were active child jobs: $activeChildren"
if (completionCause != null && activeChildren.isEmpty()) {
message += if (coroutine.isCompleted)
", the test coroutine completed"
else
", the test coroutine was not completed"
}
val error = UncompletedCoroutinesError(message)
completionCause?.let { cause -> error.addSuppressed(cause) }
uncaughtExceptions.forEach { error.addSuppressed(it) }
return error
}
internal fun throwAll(head: Throwable?, other: List<Throwable>) {
if (head != null) {
other.forEach { head.addSuppressed(it) }
throw head
} else {
with(other) {
firstOrNull()?.apply {
drop(1).forEach { addSuppressed(it) }
throw this
}
}
}
}
internal expect fun dumpCoroutines()