Skip to content

Commit 522f633

Browse files
authored
Structured concurrency in runTest (#3000)
1 parent 35aa581 commit 522f633

File tree

4 files changed

+35
-29
lines changed

4 files changed

+35
-29
lines changed

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

+21-26
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import kotlin.coroutines.*
4444
*/
4545
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
4646
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
47-
val scope = createTestCoroutineScope(TestCoroutineDispatcher() + context)
47+
val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
4848
val scheduler = scope.testScheduler
4949
val deferred = scope.async {
5050
scope.testBody()
@@ -179,32 +179,16 @@ public fun runTest(
179179
): TestResult {
180180
if (context[RunningInRunTest] != null)
181181
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
182-
val testScope = createTestCoroutineScope(context + RunningInRunTest())
182+
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest()))
183183
val scheduler = testScope.testScheduler
184+
testScope.start(CoroutineStart.DEFAULT, testScope) {
185+
testBody()
186+
}
184187
return createTestResult {
185-
val deferred = testScope.async {
186-
testScope.testBody()
187-
}
188188
var completed = false
189189
while (!completed) {
190-
while (scheduler.tryRunNextTask()) {
191-
if (deferred.isCompleted && deferred.getCompletionExceptionOrNull() != null && testScope.isActive) {
192-
/**
193-
* Here, we already know how the test will finish: it will throw
194-
* [Deferred.getCompletionExceptionOrNull]. Therefore, we won't care if there are uncompleted jobs,
195-
* and may as well just exit right here. However, in order to lower the surprise factor, we
196-
* cancel the child jobs here and wait for them to finish instead of dropping them: there could be
197-
* some cleanup procedures involved, and not having finalizers run could mean leaking resources.
198-
*
199-
* Another approach to take if this turns out not to be enough and some child jobs still fail is to
200-
* only make at most a fixed number of [TestCoroutineScheduler.tryRunNextTask] once we detect the
201-
* failure with which the test will finish. This has the downside that there is still some
202-
* negligible risk of not running the finalizers.
203-
*/
204-
testScope.cancel()
205-
}
206-
}
207-
if (deferred.isCompleted) {
190+
scheduler.advanceUntilIdle()
191+
if (testScope.isCompleted) {
208192
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
209193
non-trivial dispatches. */
210194
completed = true
@@ -213,7 +197,7 @@ public fun runTest(
213197
try {
214198
withTimeout(dispatchTimeoutMs) {
215199
select<Unit> {
216-
deferred.onAwait {
200+
testScope.onJoin {
217201
completed = true
218202
}
219203
scheduler.onDispatchEvent {
@@ -230,7 +214,7 @@ public fun runTest(
230214
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
231215
}
232216
}
233-
deferred.getCompletionExceptionOrNull()?.let {
217+
testScope.getCompletionExceptionOrNull()?.let {
234218
try {
235219
testScope.cleanupTestCoroutines()
236220
} catch (e: UncompletedCoroutinesError) {
@@ -254,7 +238,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes
254238
* Runs a test in a [TestCoroutineScope] based on this one.
255239
*
256240
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run
257-
* [block] will be different from this one, but will reuse its [Job]; therefore, even if calling
241+
* [block] will be different from this one, but will use its [Job] as a parent; therefore, even if calling
258242
* [TestCoroutineScope.cleanupTestCoroutines] on this scope were to complete its job, [runTest] won't complete it at the
259243
* end of the test.
260244
*
@@ -291,3 +275,14 @@ private class RunningInRunTest: AbstractCoroutineContextElement(RunningInRunTest
291275
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
292276
* a [TestCoroutineScheduler]. */
293277
private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L
278+
279+
private class TestBodyCoroutine<T>(
280+
private val testScope: TestCoroutineScope,
281+
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope
282+
{
283+
override val testScheduler get() = testScope.testScheduler
284+
285+
override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines()
286+
287+
override fun reportException(throwable: Throwable) = testScope.reportException(throwable)
288+
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
200200
val job: Job
201201
val ownJob: CompletableJob?
202202
if (context[Job] == null) {
203-
ownJob = SupervisorJob()
203+
ownJob = Job()
204204
job = ownJob
205205
} else {
206206
ownJob = null

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

+11
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,15 @@ class RunTestTest {
205205
}
206206
})
207207

208+
/** Checks that [runTest] throws the root cause and not [JobCancellationException] when a child coroutine throws. */
209+
@Test
210+
fun testRunTestThrowsRootCause() = testResultMap({
211+
assertFailsWith<TestException> { it() }
212+
}, {
213+
runTest {
214+
launch {
215+
throw TestException()
216+
}
217+
}
218+
})
208219
}

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ class TestCoroutineScopeTest {
123123
/** Tests that uncaught exceptions are thrown at the cleanup. */
124124
@Test
125125
fun testThrowsUncaughtExceptionsOnCleanup() {
126-
val scope = createTestCoroutineScope(SupervisorJob())
126+
val scope = createTestCoroutineScope()
127127
val exception = TestException("test")
128128
scope.launch {
129129
throw exception
@@ -136,7 +136,7 @@ class TestCoroutineScopeTest {
136136
/** Tests that uncaught exceptions take priority over uncompleted jobs when throwing on cleanup. */
137137
@Test
138138
fun testUncaughtExceptionsPrioritizedOnCleanup() {
139-
val scope = createTestCoroutineScope(SupervisorJob())
139+
val scope = createTestCoroutineScope()
140140
val exception = TestException("test")
141141
scope.launch {
142142
throw exception

0 commit comments

Comments
 (0)