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