Skip to content

Commit bd6edd8

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 67661d9 commit bd6edd8

File tree

5 files changed

+149
-53
lines changed

5 files changed

+149
-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
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
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 = get(TestCoroutineScheduler) ?: 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
1414
public sealed 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 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.
2223
*/
2324
@ExperimentalCoroutinesApi
2425
public fun cleanupTestCoroutines()
@@ -32,29 +33,92 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap
3233

3334
private class TestCoroutineScopeImpl (
3435
override val coroutineContext: CoroutineContext,
35-
override val testScheduler: TestCoroutineScheduler
36+
val ownJob: CompletableJob?
3637
):
3738
TestCoroutineScope,
3839
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
3940
{
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+
4047
override fun cleanupTestCoroutines() {
4148
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
4249
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()
4355
}
4456
}
4557

58+
private fun CoroutineContext.activeJobs(): Set<Job> {
59+
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
60+
}
61+
4662
/**
47-
* A scope which provides detailed control over the execution of coroutines for tests.
63+
* A coroutine scope for launching test coroutines.
4864
*
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].
5174
*
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].
5380
*/
5481
@Suppress("FunctionName")
5582
@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+
}
58122

59123
private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
60124
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

+70
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,76 @@ class TestCoroutineScopeTest {
5050
}
5151
}
5252

53+
/** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */
54+
@Test
55+
fun testPresentDelaysThrowing() {
56+
val scope = TestCoroutineScope()
57+
var result = false
58+
scope.launch {
59+
delay(5)
60+
result = true
61+
}
62+
assertFalse(result)
63+
assertFailsWith<AssertionError> { scope.cleanupTestCoroutines() }
64+
assertFalse(result)
65+
}
66+
67+
/** Tests that the cleanup procedure throws if there were active jobs by the end. */
68+
@Test
69+
fun testActiveJobsThrowing() {
70+
val scope = TestCoroutineScope()
71+
var result = false
72+
val deferred = CompletableDeferred<String>()
73+
scope.launch {
74+
deferred.await()
75+
result = true
76+
}
77+
assertFalse(result)
78+
assertFailsWith<AssertionError> { scope.cleanupTestCoroutines() }
79+
assertFalse(result)
80+
}
81+
82+
/** Tests that the cleanup procedure doesn't throw if it detects that the job is already cancelled. */
83+
@Test
84+
fun testCancelledDelaysNotThrowing() {
85+
val scope = TestCoroutineScope()
86+
var result = false
87+
val deferred = CompletableDeferred<String>()
88+
val job = scope.launch {
89+
deferred.await()
90+
result = true
91+
}
92+
job.cancel()
93+
assertFalse(result)
94+
scope.cleanupTestCoroutines()
95+
assertFalse(result)
96+
}
97+
98+
/** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */
99+
@Test
100+
fun testCompletesOwnJob() {
101+
val scope = TestCoroutineScope()
102+
var handlerCalled = false
103+
scope.coroutineContext.job.invokeOnCompletion {
104+
handlerCalled = true
105+
}
106+
scope.cleanupTestCoroutines()
107+
assertTrue(handlerCalled)
108+
}
109+
110+
/** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */
111+
@Test
112+
fun testDoesNotCompleteGivenJob() {
113+
var handlerCalled = false
114+
val job = Job()
115+
job.invokeOnCompletion {
116+
handlerCalled = true
117+
}
118+
val scope = TestCoroutineScope(job)
119+
scope.cleanupTestCoroutines()
120+
assertFalse(handlerCalled)
121+
}
122+
53123
private val invalidContexts = listOf(
54124
Dispatchers.Default, // not a [TestDispatcher]
55125
TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler

0 commit comments

Comments
 (0)