Skip to content

Commit 6b7c614

Browse files
committed
Fix 'TestCoroutineScope.runTest' not working
1 parent cb0494b commit 6b7c614

File tree

4 files changed

+60
-8
lines changed

4 files changed

+60
-8
lines changed

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes
269269
/**
270270
* Runs a test in a [TestCoroutineScope] based on this one.
271271
*
272-
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run
272+
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the
273273
* [block] will be different from this one, but will use its [Job] as a parent.
274274
*
275275
* Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned

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)