Skip to content

Commit 063262b

Browse files
committed
Better handle the exceptions from child coroutines in runTest (#2995)
Fixes #1910
1 parent 123b550 commit 063262b

File tree

7 files changed

+167
-31
lines changed

7 files changed

+167
-31
lines changed

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

+45-12
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 = TestCoroutineScope(context)
47+
val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
4848
val scheduler = scope.testScheduler
4949
val deferred = scope.async {
5050
scope.testBody()
@@ -141,6 +141,18 @@ public expect class TestResult
141141
*
142142
* ### Failures
143143
*
144+
* #### Test body failures
145+
*
146+
* If the test body finishes with an exception, then this exception will be thrown at the end of the test.
147+
*
148+
* #### Reported exceptions
149+
*
150+
* Exceptions reported to the test coroutine scope via [TestCoroutineScope.reportException] will be thrown at the end.
151+
* By default, unless an explicit [TestExceptionHandler] is passed, this includes all unhandled exceptions. If the test
152+
* body also fails, the reported exceptions are suppressed by it.
153+
*
154+
* #### Uncompleted coroutines
155+
*
144156
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test
145157
* will be failed (which, on JVM and Native, means that [runTest] itself will throw [AssertionError],
146158
* whereas on JS, the `Promise` will fail with it).
@@ -151,8 +163,6 @@ public expect class TestResult
151163
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
152164
* task during that time, the timer gets reset.
153165
*
154-
* Unhandled exceptions thrown by coroutines in the test will be rethrown at the end of the test.
155-
*
156166
* ### Configuration
157167
*
158168
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
@@ -170,16 +180,18 @@ public fun runTest(
170180
): TestResult {
171181
if (context[RunningInRunTest] != null)
172182
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
173-
val testScope = TestCoroutineScope(context + RunningInRunTest())
183+
val testScope = TestBodyCoroutine<Unit>(TestCoroutineScope(context + RunningInRunTest))
174184
val scheduler = testScope.testScheduler
175185
return createTestResult {
176-
val deferred = testScope.async {
177-
testScope.testBody()
186+
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
187+
* [TestCoroutineDispatcher], because the event loop is not started. */
188+
testScope.start(CoroutineStart.DEFAULT, testScope) {
189+
testBody()
178190
}
179191
var completed = false
180192
while (!completed) {
181193
scheduler.advanceUntilIdle()
182-
if (deferred.isCompleted) {
194+
if (testScope.isCompleted) {
183195
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
184196
non-trivial dispatches. */
185197
completed = true
@@ -188,7 +200,7 @@ public fun runTest(
188200
try {
189201
withTimeout(dispatchTimeoutMs) {
190202
select<Unit> {
191-
deferred.onAwait {
203+
testScope.onJoin {
192204
completed = true
193205
}
194206
scheduler.onDispatchEvent {
@@ -205,7 +217,14 @@ public fun runTest(
205217
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
206218
}
207219
}
208-
deferred.getCompletionExceptionOrNull()?.let {
220+
testScope.getCompletionExceptionOrNull()?.let {
221+
try {
222+
testScope.cleanupTestCoroutines()
223+
} catch (e: UncompletedCoroutinesError) {
224+
// it's normal that some jobs are not completed if the test body has failed, won't clutter the output
225+
} catch (e: Throwable) {
226+
it.addSuppressed(e)
227+
}
209228
throw it
210229
}
211230
testScope.cleanupTestCoroutines()
@@ -222,7 +241,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes
222241
* Runs a test in a [TestCoroutineScope] based on this one.
223242
*
224243
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run
225-
* [block] will be different from this one, but will reuse its [Job]; therefore, even if calling
244+
* [block] will be different from this one, but will use its [Job] as a parent; therefore, even if calling
226245
* [TestCoroutineScope.cleanupTestCoroutines] on this scope were to complete its job, [runTest] won't complete it at the
227246
* end of the test.
228247
*
@@ -252,10 +271,24 @@ public fun TestDispatcher.runTest(
252271
runTest(this, dispatchTimeoutMs, block)
253272

254273
/** A coroutine context element indicating that the coroutine is running inside `runTest`. */
255-
private class RunningInRunTest: AbstractCoroutineContextElement(RunningInRunTest), CoroutineContext.Element {
256-
companion object Key : CoroutineContext.Key<RunningInRunTest>
274+
private object RunningInRunTest: CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
275+
override val key: CoroutineContext.Key<*>
276+
get() = this
277+
278+
override fun toString(): String = "RunningInRunTest"
257279
}
258280

259281
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
260282
* a [TestCoroutineScheduler]. */
261283
private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L
284+
285+
private class TestBodyCoroutine<T>(
286+
private val testScope: TestCoroutineScope,
287+
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope,
288+
UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor
289+
{
290+
override val testScheduler get() = testScope.testScheduler
291+
292+
override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines()
293+
294+
}

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

+17-9
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
8181
}
8282
}
8383

84+
/**
85+
* Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening.
86+
*/
87+
private fun tryRunNextTask(): Boolean {
88+
val event = synchronized(lock) {
89+
val event = events.removeFirstOrNull() ?: return false
90+
if (currentTime > event.time)
91+
currentTimeAheadOfEvents()
92+
currentTime = event.time
93+
event
94+
}
95+
event.dispatcher.processEvent(event.time, event.marker)
96+
return true
97+
}
98+
8499
/**
85100
* Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more
86101
* tasks associated with the dispatchers linked to this scheduler.
@@ -91,15 +106,8 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
91106
*/
92107
@ExperimentalCoroutinesApi
93108
public fun advanceUntilIdle() {
94-
while (!events.isEmpty) {
95-
val event = synchronized(lock) {
96-
val event = events.removeFirstOrNull() ?: return
97-
if (currentTime > event.time)
98-
currentTimeAheadOfEvents()
99-
currentTime = event.time
100-
event
101-
}
102-
event.dispatcher.processEvent(event.time, event.marker)
109+
while (!synchronized(lock) { events.isEmpty }) {
110+
tryRunNextTask()
103111
}
104112
}
105113

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext)
107107
return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job)
108108
}
109109

110-
private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
110+
internal inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
111111
get() {
112112
val handler = this[CoroutineExceptionHandler]
113113
return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException(

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ inline fun <T> assertRunsFast(block: () -> T): T = assertRunsFast(Duration.secon
3232
/**
3333
* Passes [test] as an argument to [block], but as a function returning not a [TestResult] but [Unit].
3434
*/
35-
expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult
35+
expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult
36+
37+
class TestException(message: String? = null): Exception(message)

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

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

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

@@ -153,4 +154,67 @@ class RunTestTest {
153154
}
154155
}
155156

157+
@Test
158+
fun reproducer2405() = runTest {
159+
val dispatcher = TestCoroutineDispatcher(testScheduler)
160+
var collectedError = false
161+
withContext(dispatcher) {
162+
flow { emit(1) }
163+
.combine(
164+
flow<String> { throw IllegalArgumentException() }
165+
) { int, string -> int.toString() + string }
166+
.catch { emit("error") }
167+
.collect {
168+
assertEquals("error", it)
169+
collectedError = true
170+
}
171+
}
172+
assertTrue(collectedError)
173+
}
174+
175+
/** Tests that, once the test body has thrown, the child coroutines are cancelled. */
176+
@Test
177+
fun testChildrenCancellationOnTestBodyFailure() {
178+
var job: Job? = null
179+
testResultMap({
180+
assertFailsWith<AssertionError> { it() }
181+
assertTrue(job!!.isCancelled)
182+
}, {
183+
runTest {
184+
job = launch {
185+
while (true) {
186+
delay(1000)
187+
}
188+
}
189+
throw AssertionError()
190+
}
191+
})
192+
}
193+
194+
/** Tests that [runTest] reports [TimeoutCancellationException]. */
195+
@Test
196+
fun testTimeout() = testResultMap({
197+
assertFailsWith<TimeoutCancellationException> { it() }
198+
}, {
199+
runTest {
200+
withTimeout(50) {
201+
launch {
202+
delay(1000)
203+
}
204+
}
205+
}
206+
})
207+
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+
})
219+
156220
}

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

+36-5
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,40 @@ class TestCoroutineScopeTest {
9595
assertFalse(result)
9696
}
9797

98-
private val invalidContexts = listOf(
99-
Dispatchers.Default, // not a [TestDispatcher]
100-
TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler
101-
CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor`
102-
)
98+
/** Tests that uncaught exceptions are thrown at the cleanup. */
99+
@Test
100+
fun testThrowsUncaughtExceptionsOnCleanup() {
101+
val scope = TestCoroutineScope()
102+
val exception = TestException("test")
103+
scope.launch {
104+
throw exception
105+
}
106+
assertFailsWith<TestException> {
107+
scope.cleanupTestCoroutines()
108+
}
109+
}
110+
111+
/** Tests that uncaught exceptions take priority over uncompleted jobs when throwing on cleanup. */
112+
@Test
113+
fun testUncaughtExceptionsPrioritizedOnCleanup() {
114+
val scope = TestCoroutineScope()
115+
val exception = TestException("test")
116+
scope.launch {
117+
throw exception
118+
}
119+
scope.launch {
120+
delay(1000)
121+
}
122+
assertFailsWith<TestException> {
123+
scope.cleanupTestCoroutines()
124+
}
125+
}
126+
127+
companion object {
128+
internal val invalidContexts = listOf(
129+
Dispatchers.Default, // not a [TestDispatcher]
130+
TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler
131+
CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor`
132+
)
133+
}
103134
}

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

+1-3
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,4 @@ class TestRunBlockingTest {
435435
}
436436
}
437437
}
438-
}
439-
440-
private class TestException(message: String? = null): Exception(message)
438+
}

0 commit comments

Comments
 (0)