Skip to content

Commit 44d46ea

Browse files
authored
Ensure runTest unsubscribes from the exception handler (#3909)
Fixes #3897
1 parent 26085d7 commit 44d46ea

File tree

5 files changed

+125
-5
lines changed

5 files changed

+125
-5
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

+65
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,69 @@ 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+
* The JVM-only source set contains a test equivalent to this one that isn't ignored.
418+
*/
419+
@Test
420+
@Ignore
421+
fun testExceptionCaptorCleanedUpOnPreliminaryExit(): TestResult = testResultChain({
422+
// step 1: installing the exception handler
423+
println("step 1")
424+
runTest { }
425+
}, {
426+
it.getOrThrow()
427+
// step 2: throwing an uncaught exception to be caught by the exception-handling system
428+
println("step 2")
429+
createTestResult {
430+
launch(NonCancellable) { throw TestException("A") }
431+
}
432+
}, {
433+
it.getOrThrow()
434+
// step 3: trying to run a test should immediately fail, even before entering the test body
435+
println("step 3")
436+
try {
437+
runTest {
438+
fail("unreached")
439+
}
440+
fail("unreached")
441+
} catch (e: UncaughtExceptionsBeforeTest) {
442+
val cause = e.suppressedExceptions.single()
443+
assertIs<TestException>(cause)
444+
assertEquals("A", cause.message)
445+
}
446+
// step 4: trying to run a test again should not fail with an exception
447+
println("step 4")
448+
runTest {
449+
}
450+
}, {
451+
it.getOrThrow()
452+
// step 5: throwing an uncaught exception to be caught by the exception-handling system, again
453+
println("step 5")
454+
createTestResult {
455+
launch(NonCancellable) { throw TestException("B") }
456+
}
457+
}, {
458+
it.getOrThrow()
459+
// step 6: trying to run a test should immediately fail, again
460+
println("step 6")
461+
try {
462+
runTest {
463+
fail("unreached")
464+
}
465+
fail("unreached")
466+
} catch (e: Exception) {
467+
val cause = e.suppressedExceptions.single()
468+
assertIs<TestException>(cause)
469+
assertEquals("B", cause.message)
470+
}
471+
// step 7: trying to run a test again should not fail with an exception, again
472+
println("step 7")
473+
runTest {
474+
}
475+
})
411476
}

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -495,9 +495,11 @@ class TestScopeTest {
495495
* Tests that the [TestScope] exception reporting mechanism will report the exceptions that happen between
496496
* different tests.
497497
*
498-
* This test must be ran manually, because such exceptions still go through the global exception handler
498+
* This test must be run manually, because such exceptions still go through the global exception handler
499499
* (as there's no guarantee that another test will happen), and the global exception handler will
500500
* log the exceptions or, on Native, crash the test suite.
501+
*
502+
* The JVM-only source set contains a test equivalent to this one that isn't ignored.
501503
*/
502504
@Test
503505
@Ignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2016-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
package kotlinx.coroutines.test
5+
6+
import org.junit.Test
7+
import kotlin.test.*
8+
9+
/**
10+
* Tests that check the behavior of the test framework when there are stray uncaught exceptions.
11+
* These tests are JVM-only because only the JVM allows to set a global uncaught exception handler and validate the
12+
* behavior without checking the test logs.
13+
* Nevertheless, each test here has a corresponding test in the common source set that can be run manually.
14+
*/
15+
class UncaughtExceptionsTest {
16+
17+
val oldExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
18+
val uncaughtExceptions = mutableListOf<Throwable>()
19+
20+
@BeforeTest
21+
fun setUp() {
22+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
23+
uncaughtExceptions.add(throwable)
24+
}
25+
}
26+
27+
@AfterTest
28+
fun tearDown() {
29+
Thread.setDefaultUncaughtExceptionHandler(oldExceptionHandler)
30+
}
31+
32+
@Test
33+
fun testReportingStrayUncaughtExceptionsBetweenTests() {
34+
TestScopeTest().testReportingStrayUncaughtExceptionsBetweenTests()
35+
assertEquals(1, uncaughtExceptions.size, "Expected 1 uncaught exception, but got $uncaughtExceptions")
36+
val exception = assertIs<TestException>(uncaughtExceptions.single())
37+
assertEquals("x", exception.message)
38+
}
39+
40+
@Test
41+
fun testExceptionCaptorCleanedUpOnPreliminaryExit() {
42+
RunTestTest().testExceptionCaptorCleanedUpOnPreliminaryExit()
43+
assertEquals(2, uncaughtExceptions.size, "Expected 2 uncaught exceptions, but got $uncaughtExceptions")
44+
for (exception in uncaughtExceptions) {
45+
assertIs<TestException>(exception)
46+
}
47+
assertEquals("A", uncaughtExceptions[0].message)
48+
assertEquals("B", uncaughtExceptions[1].message)
49+
}
50+
}

0 commit comments

Comments
 (0)