@@ -13,12 +13,13 @@ import kotlin.coroutines.*
13
13
@ExperimentalCoroutinesApi
14
14
public sealed interface TestCoroutineScope : CoroutineScope , UncaughtExceptionCaptor {
15
15
/* *
16
- * Call after the test completes.
16
+ * Called after the test completes.
17
+ *
17
18
* Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines].
19
+ * If a new job was created for this scope, the job is completed.
18
20
*
19
21
* @throws Throwable the first uncaught exception, if there are any uncaught exceptions.
20
- * @throws AssertionError if any pending tasks are active, however it will not throw for suspended
21
- * coroutines.
22
+ * @throws AssertionError if any pending tasks are active.
22
23
*/
23
24
@ExperimentalCoroutinesApi
24
25
public fun cleanupTestCoroutines ()
@@ -32,29 +33,92 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap
32
33
33
34
private class TestCoroutineScopeImpl (
34
35
override val coroutineContext : CoroutineContext ,
35
- override val testScheduler : TestCoroutineScheduler
36
+ val ownJob : CompletableJob ?
36
37
):
37
38
TestCoroutineScope ,
38
39
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
39
40
{
41
+ override val testScheduler: TestCoroutineScheduler
42
+ get() = coroutineContext[TestCoroutineScheduler ]!!
43
+
44
+ /* * These jobs existed before the coroutine scope was used, so it's alright if they don't get cancelled. */
45
+ val initialJobs = coroutineContext.activeJobs()
46
+
40
47
override fun cleanupTestCoroutines () {
41
48
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
42
49
coroutineContext.delayController?.cleanupTestCoroutines()
50
+ val jobs = coroutineContext.activeJobs()
51
+ if ((jobs - initialJobs).isNotEmpty()) {
52
+ throw UncompletedCoroutinesError (" Test finished with active jobs: $jobs " )
53
+ }
54
+ ownJob?.complete()
43
55
}
44
56
}
45
57
58
+ private fun CoroutineContext.activeJobs (): Set <Job > {
59
+ return checkNotNull(this [Job ]).children.filter { it.isActive }.toSet()
60
+ }
61
+
46
62
/* *
47
- * A scope which provides detailed control over the execution of coroutines for tests .
63
+ * A coroutine scope for launching test coroutines.
48
64
*
49
- * If the provided context does not provide a [ContinuationInterceptor] (Dispatcher) or [CoroutineExceptionHandler], the
50
- * scope adds [TestCoroutineDispatcher] and [TestCoroutineExceptionHandler] automatically.
65
+ * It ensures that all the test module machinery is properly initialized.
66
+ * * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping,
67
+ * a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used.
68
+ * * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created.
69
+ * * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created
70
+ * automatically.
71
+ * * If [context] provides a [Job], that job is used for the new scope, but is not completed once the scope completes.
72
+ * On the other hand, if there is no [Job] in the context, a [CompletableJob] is created and completed on
73
+ * [TestCoroutineScope.cleanupTestCoroutines].
51
74
*
52
- * @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController]
75
+ * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a
76
+ * different scheduler.
77
+ * @throws IllegalArgumentException if [context] has a [ContinuationInterceptor] that is not a [TestDispatcher].
78
+ * @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an
79
+ * [UncaughtExceptionCaptor].
53
80
*/
54
81
@Suppress(" FunctionName" )
55
82
@ExperimentalCoroutinesApi
56
- public fun TestCoroutineScope (context : CoroutineContext = EmptyCoroutineContext ): TestCoroutineScope =
57
- context.checkTestScopeArguments().let { TestCoroutineScopeImpl (it.first, it.second.scheduler) }
83
+ public fun TestCoroutineScope (context : CoroutineContext = EmptyCoroutineContext ): TestCoroutineScope {
84
+ val scheduler: TestCoroutineScheduler
85
+ val dispatcher = when (val dispatcher = context[ContinuationInterceptor ]) {
86
+ is TestDispatcher -> {
87
+ scheduler = dispatcher.scheduler
88
+ val ctxScheduler = context[TestCoroutineScheduler ]
89
+ if (ctxScheduler != null ) {
90
+ require(dispatcher.scheduler == = ctxScheduler) {
91
+ " Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " +
92
+ " another scheduler were passed."
93
+ }
94
+ }
95
+ dispatcher
96
+ }
97
+ null -> {
98
+ scheduler = context[TestCoroutineScheduler ] ? : TestCoroutineScheduler ()
99
+ TestCoroutineDispatcher (scheduler)
100
+ }
101
+ else -> throw IllegalArgumentException (" Dispatcher must implement TestDispatcher: $dispatcher " )
102
+ }
103
+ val exceptionHandler = context[CoroutineExceptionHandler ].run {
104
+ this ?.let {
105
+ require(this is UncaughtExceptionCaptor ) {
106
+ " coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context "
107
+ }
108
+ }
109
+ this ? : TestCoroutineExceptionHandler ()
110
+ }
111
+ val job: Job
112
+ val ownJob: CompletableJob ?
113
+ if (context[Job ] == null ) {
114
+ ownJob = SupervisorJob ()
115
+ job = ownJob
116
+ } else {
117
+ ownJob = null
118
+ job = context[Job ]!!
119
+ }
120
+ return TestCoroutineScopeImpl (context + scheduler + dispatcher + exceptionHandler + job, ownJob)
121
+ }
58
122
59
123
private inline val CoroutineContext .uncaughtExceptionCaptor: UncaughtExceptionCaptor
60
124
get() {
0 commit comments