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