Skip to content

Commit f90ba4c

Browse files
committed
Improve TestCoroutineScope
* Add more detailed documentation; * Move most verification logic from `runBlockingTest` to `cleanupTestCoroutines` Fixes #1749 * Complete the scope's job if a new job was created for it Fixes #1772
1 parent 8948c5c commit f90ba4c

File tree

5 files changed

+151
-53
lines changed

5 files changed

+151
-53
lines changed

kotlinx-coroutines-test/common/src/TestBuilders.kt

+3-42
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,16 @@ import kotlin.coroutines.*
4343
*/
4444
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
4545
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
46-
val (safeContext, dispatcher) = context.checkTestScopeArguments()
47-
val startingJobs = safeContext.activeJobs()
48-
val scope = TestCoroutineScope(safeContext)
46+
val scope = TestCoroutineScope(context)
47+
val scheduler = scope.coroutineContext[TestCoroutineScheduler]!!
4948
val deferred = scope.async {
5049
scope.testBody()
5150
}
52-
dispatcher.scheduler.advanceUntilIdle()
51+
scheduler.advanceUntilIdle()
5352
deferred.getCompletionExceptionOrNull()?.let {
5453
throw it
5554
}
5655
scope.cleanupTestCoroutines()
57-
val endingJobs = safeContext.activeJobs()
58-
if ((endingJobs - startingJobs).isNotEmpty()) {
59-
throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs")
60-
}
61-
}
62-
63-
private fun CoroutineContext.activeJobs(): Set<Job> {
64-
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
6556
}
6657

6758
/**
@@ -78,33 +69,3 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.
7869
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
7970
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
8071
runBlockingTest(this, block)
81-
82-
internal fun CoroutineContext.checkTestScopeArguments(): Pair<CoroutineContext, TestDispatcher> {
83-
val scheduler: TestCoroutineScheduler
84-
val dispatcher = when (val dispatcher = get(ContinuationInterceptor)) {
85-
is TestDispatcher -> {
86-
val ctxScheduler = get(TestCoroutineScheduler)
87-
if (ctxScheduler == null) {
88-
scheduler = dispatcher.scheduler
89-
} else {
90-
require(dispatcher.scheduler === ctxScheduler) {
91-
"Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " +
92-
"another scheduler were passed."
93-
}
94-
scheduler = ctxScheduler
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 = get(CoroutineExceptionHandler).run {
105-
this?.let { require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $this" } }
106-
this ?: TestCoroutineExceptionHandler()
107-
}
108-
val job = get(Job) ?: SupervisorJob()
109-
return Pair(this + scheduler + dispatcher + exceptionHandler + job, dispatcher)
110-
}

kotlinx-coroutines-test/common/src/TestCoroutineScope.kt

+74-10
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import kotlin.coroutines.*
1313
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
1414
public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor {
1515
/**
16-
* Call after the test completes.
16+
* Called after the test completes.
17+
*
1718
* Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines].
19+
* If a new job was created for this scope, the job is completed.
1820
*
1921
* @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.
2223
*/
2324
@ExperimentalCoroutinesApi
2425
public fun cleanupTestCoroutines()
@@ -34,29 +35,92 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor {
3435

3536
private class TestCoroutineScopeImpl (
3637
override val coroutineContext: CoroutineContext,
37-
override val testScheduler: TestCoroutineScheduler
38+
val ownJob: CompletableJob?
3839
):
3940
TestCoroutineScope,
4041
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
4142
{
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+
4249
override fun cleanupTestCoroutines() {
4350
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
4451
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()
4557
}
4658
}
4759

60+
private fun CoroutineContext.activeJobs(): Set<Job> {
61+
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
62+
}
63+
4864
/**
49-
* A scope which provides detailed control over the execution of coroutines for tests.
65+
* A coroutine scope for launching test coroutines.
5066
*
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].
5376
*
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].
5582
*/
5683
@Suppress("FunctionName")
5784
@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+
}
60124

61125
private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
62126
get() {

kotlinx-coroutines-test/common/test/TestBuildersTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class TestBuildersTest {
104104
}
105105

106106
@Test
107-
fun whenInrunBlocking_runBlockingTest_nestsProperly() {
107+
fun whenInRunBlocking_runBlockingTest_nestsProperly() {
108108
// this is not a supported use case, but it is possible so ensure it works
109109

110110
val scope = TestCoroutineScope()

kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlin.coroutines.*
89
import kotlin.test.*
910

1011
class TestCoroutineSchedulerTest {

kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt

+72
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlin.coroutines.*
89
import kotlin.test.*
910

1011
class TestCoroutineScopeTest {
12+
1113
@Test
1214
fun whenGivenInvalidExceptionHandler_throwsException() {
1315
val handler = CoroutineExceptionHandler { _, _ -> }
@@ -22,4 +24,74 @@ class TestCoroutineScopeTest {
2224
TestCoroutineScope(Dispatchers.Default)
2325
}
2426
}
27+
28+
/** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */
29+
@Test
30+
fun testPresentDelaysThrowing() {
31+
val scope = TestCoroutineScope()
32+
var result = false
33+
scope.launch {
34+
delay(5)
35+
result = true
36+
}
37+
assertFalse(result)
38+
assertFailsWith<AssertionError> { scope.cleanupTestCoroutines() }
39+
assertFalse(result)
40+
}
41+
42+
/** Tests that the cleanup procedure throws if there were active jobs by the end. */
43+
@Test
44+
fun testActiveJobsThrowing() {
45+
val scope = TestCoroutineScope()
46+
var result = false
47+
val deferred = CompletableDeferred<String>()
48+
scope.launch {
49+
deferred.await()
50+
result = true
51+
}
52+
assertFalse(result)
53+
assertFailsWith<AssertionError> { scope.cleanupTestCoroutines() }
54+
assertFalse(result)
55+
}
56+
57+
/** Tests that the cleanup procedure doesn't throw if it detects that the job is already cancelled. */
58+
@Test
59+
fun testCancelledDelaysNotThrowing() {
60+
val scope = TestCoroutineScope()
61+
var result = false
62+
val deferred = CompletableDeferred<String>()
63+
val job = scope.launch {
64+
deferred.await()
65+
result = true
66+
}
67+
job.cancel()
68+
assertFalse(result)
69+
scope.cleanupTestCoroutines()
70+
assertFalse(result)
71+
}
72+
73+
/** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */
74+
@Test
75+
fun testCompletesOwnJob() {
76+
val scope = TestCoroutineScope()
77+
var handlerCalled = false
78+
scope.coroutineContext.job.invokeOnCompletion {
79+
handlerCalled = true
80+
}
81+
scope.cleanupTestCoroutines()
82+
assertTrue(handlerCalled)
83+
}
84+
85+
/** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */
86+
@Test
87+
fun testDoesNotCompleteGivenJob() {
88+
var handlerCalled = false
89+
val job = Job()
90+
job.invokeOnCompletion {
91+
handlerCalled = true
92+
}
93+
val scope = TestCoroutineScope(job)
94+
scope.cleanupTestCoroutines()
95+
assertFalse(handlerCalled)
96+
}
2597
}

0 commit comments

Comments
 (0)