Skip to content

Commit 721b5fe

Browse files
committed
Improve TestCoroutineScope (#2975)
* Add more detailed documentation; * Move most verification logic from `runBlockingTest` to `cleanupTestCoroutines` Fixes #1749
1 parent 17aed1e commit 721b5fe

File tree

5 files changed

+112
-55
lines changed

5 files changed

+112
-55
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

+62-12
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ 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].
1819
*
1920
* @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.
2222
*/
2323
@ExperimentalCoroutinesApi
2424
public fun cleanupTestCoroutines()
@@ -30,31 +30,81 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap
3030
public val testScheduler: TestCoroutineScheduler
3131
}
3232

33-
private class TestCoroutineScopeImpl (
34-
override val coroutineContext: CoroutineContext,
35-
override val testScheduler: TestCoroutineScheduler
33+
private class TestCoroutineScopeImpl(
34+
override val coroutineContext: CoroutineContext
3635
):
3736
TestCoroutineScope,
3837
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
3938
{
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+
4045
override fun cleanupTestCoroutines() {
4146
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
4247
coroutineContext.delayController?.cleanupTestCoroutines()
48+
val jobs = coroutineContext.activeJobs()
49+
if ((jobs - initialJobs).isNotEmpty())
50+
throw UncompletedCoroutinesError("Test finished with active jobs: $jobs")
4351
}
4452
}
4553

54+
private fun CoroutineContext.activeJobs(): Set<Job> {
55+
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
56+
}
57+
4658
/**
47-
* A scope which provides detailed control over the execution of coroutines for tests.
59+
* A coroutine scope for launching test coroutines.
4860
*
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.
5168
*
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].
5374
*/
5475
@Suppress("FunctionName")
5576
@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+
}
58108

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

+45
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,51 @@ 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+
5398
private val invalidContexts = listOf(
5499
Dispatchers.Default, // not a [TestDispatcher]
55100
TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler

0 commit comments

Comments
 (0)