Skip to content

Commit 79fc624

Browse files
committed
Ensure runTest unsubscribes from the exception handler
Fixes #3897
1 parent 7659d65 commit 79fc624

File tree

3 files changed

+70
-4
lines changed

3 files changed

+70
-4
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ internal class TestScopeImpl(context: CoroutineContext) :
239239
uncaughtExceptions
240240
}
241241
if (exceptions.isNotEmpty()) {
242+
ExceptionCollector.removeOnExceptionCallback(lock)
242243
throw UncaughtExceptionsBeforeTest().apply {
243244
for (e in exceptions)
244245
addSuppressed(e)

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

+6-4
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,16 @@ fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResu
4747
*/
4848
expect fun testResultChain(block: () -> TestResult, after: (Result<Unit>) -> TestResult): TestResult
4949

50-
fun testResultChain(vararg chained: (Result<Unit>) -> TestResult): TestResult =
50+
fun testResultChain(vararg chained: (Result<Unit>) -> TestResult, initialResult: Result<Unit> = Result.success(Unit)): TestResult =
5151
if (chained.isEmpty()) {
52-
createTestResult { }
52+
createTestResult {
53+
initialResult.getOrThrow()
54+
}
5355
} else {
5456
testResultChain(block = {
55-
chained[0](Result.success(Unit))
57+
chained[0](initialResult)
5658
}) {
57-
testResultChain(*chained.drop(1).toTypedArray())
59+
testResultChain(*chained.drop(1).toTypedArray(), initialResult = it)
5860
}
5961
}
6062

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

+63
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,67 @@ class RunTestTest {
408408
fun testCoroutineCompletingWithoutDispatch() = runTest(timeout = Duration.INFINITE) {
409409
launch(Dispatchers.Default) { delay(100) }
410410
}
411+
412+
/**
413+
* Tests that [runTest] cleans up the exception handler even if it threw on initialization.
414+
*
415+
* This test must be run manually, because it writes garbage to the log.
416+
*/
417+
@Test
418+
@Ignore
419+
fun testExceptionCaptorCleanedUpOnPreliminaryExit(): TestResult = testResultChain({
420+
// step 1: installing the exception handler
421+
println("step 1")
422+
runTest { }
423+
}, {
424+
it.getOrThrow()
425+
// step 2: throwing an uncaught exception to be caught by the exception-handling system
426+
println("step 2")
427+
createTestResult {
428+
launch(NonCancellable) { throw TestException("A") }
429+
}
430+
}, {
431+
it.getOrThrow()
432+
// step 3: trying to run a test should immediately fail, even before entering the test body
433+
println("step 3")
434+
try {
435+
runTest {
436+
fail("unreached")
437+
}
438+
fail("unreached")
439+
} catch (e: UncaughtExceptionsBeforeTest) {
440+
val cause = e.suppressedExceptions.single()
441+
assertIs<TestException>(cause)
442+
assertEquals("A", cause.message)
443+
}
444+
// step 4: trying to run a test again should not fail with an exception
445+
println("step 4")
446+
runTest {
447+
}
448+
}, {
449+
it.getOrThrow()
450+
// step 5: throwing an uncaught exception to be caught by the exception-handling system, again
451+
println("step 5")
452+
createTestResult {
453+
launch(NonCancellable) { throw TestException("B") }
454+
}
455+
}, {
456+
it.getOrThrow()
457+
// step 6: trying to run a test should immediately fail, again
458+
println("step 6")
459+
try {
460+
runTest {
461+
fail("unreached")
462+
}
463+
fail("unreached")
464+
} catch (e: Exception) {
465+
val cause = e.suppressedExceptions.single()
466+
assertIs<TestException>(cause)
467+
assertEquals("B", cause.message)
468+
}
469+
// step 7: trying to run a test again should not fail with an exception, again
470+
println("step 7")
471+
runTest {
472+
}
473+
})
411474
}

0 commit comments

Comments
 (0)