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