Skip to content

Commit f4a0fcd

Browse files
committed
Fix 'TestCoroutineScope.runTest' not working
1 parent e609627 commit f4a0fcd

File tree

4 files changed

+61
-11
lines changed

4 files changed

+61
-11
lines changed

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

+2-4
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,8 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes
239239
/**
240240
* Runs a test in a [TestCoroutineScope] based on this one.
241241
*
242-
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run
243-
* [block] will be different from this one, but will use its [Job] as a parent; therefore, even if calling
244-
* [TestCoroutineScope.cleanupTestCoroutines] on this scope were to complete its job, [runTest] won't complete it at the
245-
* end of the test.
242+
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the
243+
* [block] will be different from this one, but will use its [Job] as a parent.
246244
*
247245
* Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned
248246
* immediately from the test body. See the docs for [TestResult] for details.

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

+15-7
Original file line numberDiff line numberDiff line change
@@ -179,18 +179,22 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
179179
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
180180
}
181181
var scope: TestCoroutineScopeImpl? = null
182-
val exceptionHandler = when (val exceptionHandler = context[CoroutineExceptionHandler]) {
183-
is UncaughtExceptionCaptor -> exceptionHandler
184-
null -> {
185-
val lock = SynchronizedObject()
186-
CoroutineExceptionHandler { _, throwable ->
182+
val ownExceptionHandler = run {
183+
val lock = SynchronizedObject()
184+
object : AbstractCoroutineContextElement(CoroutineExceptionHandler), TestCoroutineScopeExceptionHandler {
185+
override fun handleException(context: CoroutineContext, exception: Throwable) {
187186
val reported = synchronized(lock) {
188-
scope!!.reportException(throwable)
187+
scope!!.reportException(exception)
189188
}
190189
if (!reported)
191-
throw throwable // let this exception crash everything
190+
throw exception // let this exception crash everything
192191
}
193192
}
193+
}
194+
val exceptionHandler = when (val exceptionHandler = context[CoroutineExceptionHandler]) {
195+
is UncaughtExceptionCaptor -> exceptionHandler
196+
null -> ownExceptionHandler
197+
is TestCoroutineScopeExceptionHandler -> ownExceptionHandler
194198
else -> throw IllegalArgumentException(
195199
"A CoroutineExceptionHandler was passed to TestCoroutineScope. " +
196200
"Please pass it as an argument to a `launch` or `async` block on an already-created scope " +
@@ -203,6 +207,10 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
203207
}
204208
}
205209

210+
/** A marker that shows that this [CoroutineExceptionHandler] was created for [TestCoroutineScope]. With this,
211+
* constructing a new [TestCoroutineScope] with the [CoroutineScope.coroutineContext] of an existing one will override
212+
* the exception handler, instead of failing. */
213+
private interface TestCoroutineScopeExceptionHandler: CoroutineExceptionHandler
206214

207215
private inline val CoroutineContext.delayController: DelayController?
208216
get() {

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

+18
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,22 @@ class RunTestTest {
275275
throw TestException("w")
276276
}
277277
})
278+
279+
/** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */
280+
@Test
281+
fun testScopeRunTestExceptionHandler(): TestResult {
282+
val scope = createTestCoroutineScope()
283+
return testResultMap({
284+
try {
285+
it()
286+
fail("should not be reached")
287+
} catch (e: TestException) {
288+
scope.cleanupTestCoroutines() // should not fail
289+
}
290+
}, {
291+
scope.runTest {
292+
launch(SupervisorJob()) { throw TestException("x") }
293+
}
294+
})
295+
}
278296
}

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

+26
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,32 @@ class TestCoroutineScopeTest {
153153
}
154154
}
155155

156+
/** Tests that constructing a new [TestCoroutineScope] using another one's scope works and overrides the exception
157+
* handler. */
158+
@Test
159+
fun testCopyingContexts() {
160+
val deferred = CompletableDeferred<Unit>()
161+
val scope1 = createTestCoroutineScope()
162+
scope1.launch { deferred.await() } // a pending job in the outer scope
163+
val scope2 = createTestCoroutineScope(scope1.coroutineContext)
164+
val scope3 = createTestCoroutineScope(scope1.coroutineContext)
165+
assertEquals(
166+
scope1.coroutineContext.minusKey(CoroutineExceptionHandler),
167+
scope2.coroutineContext.minusKey(CoroutineExceptionHandler))
168+
scope2.launch(SupervisorJob()) { throw TestException("x") } // will fail the cleanup of scope2
169+
try {
170+
scope2.cleanupTestCoroutines()
171+
fail("should not be reached")
172+
} catch (e: TestException) { }
173+
scope3.cleanupTestCoroutines() // the pending job in the outer scope will not cause this to fail
174+
try {
175+
scope1.cleanupTestCoroutines()
176+
fail("should not be reached")
177+
} catch (e: UncompletedCoroutinesError) {
178+
// the pending job in the outer scope
179+
}
180+
}
181+
156182
companion object {
157183
internal val invalidContexts = listOf(
158184
Dispatchers.Default, // not a [TestDispatcher]

0 commit comments

Comments
 (0)