Skip to content

Commit 04ff300

Browse files
committed
WIP: fix exception handling in the test module
1 parent d07b6b2 commit 04ff300

6 files changed

+72
-69
lines changed

kotlinx-coroutines-test/api/kotlinx-coroutines-test.api

+5-4
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor
3434
public fun toString ()Ljava/lang/String;
3535
}
3636

37-
public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor {
37+
public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler {
3838
public fun <init> ()V
39-
public fun cleanupTestCoroutines ()V
40-
public fun getUncaughtExceptions ()Ljava/util/List;
39+
public final fun cleanupTestCoroutines ()V
40+
public final fun getUncaughtExceptions ()Ljava/util/List;
4141
public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V
4242
}
4343

44-
public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/DelayController, kotlinx/coroutines/test/UncaughtExceptionCaptor {
44+
public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/DelayController {
4545
public abstract fun cleanupTestCoroutines ()V
46+
public abstract fun getUncaughtExceptions ()Ljava/util/List;
4647
}
4748

4849
public final class kotlinx/coroutines/test/TestCoroutineScopeKt {

kotlinx-coroutines-test/src/TestBuilders.kt

+15-17
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,20 @@ import kotlin.coroutines.*
4343
*/
4444
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
4545
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
46-
val (safeContext, dispatcher) = context.checkArguments()
46+
val (safeContext, dispatcher) = context.checkTestScopeArguments()
4747
val startingJobs = safeContext.activeJobs()
4848
val scope = TestCoroutineScope(safeContext)
49-
val deferred = scope.async {
50-
scope.testBody()
51-
}
52-
dispatcher.advanceUntilIdle()
53-
deferred.getCompletionExceptionOrNull()?.let {
54-
throw it
49+
try {
50+
val deferred = scope.async {
51+
scope.testBody()
52+
}
53+
dispatcher.advanceUntilIdle()
54+
deferred.getCompletionExceptionOrNull()?.let {
55+
throw it
56+
}
57+
} catch (e: CancellationException) {
58+
scope.cleanupTestCoroutines()
59+
throw e
5560
}
5661
scope.cleanupTestCoroutines()
5762
val endingJobs = safeContext.activeJobs()
@@ -79,20 +84,13 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.
7984
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
8085
runBlockingTest(this, block)
8186

82-
private fun CoroutineContext.checkArguments(): Pair<CoroutineContext, DelayController> {
87+
internal fun CoroutineContext.checkTestScopeArguments(): Pair<CoroutineContext, DelayController> {
8388
// TODO optimize it
8489
val dispatcher = get(ContinuationInterceptor).run {
8590
this?.let { require(this is DelayController) { "Dispatcher must implement DelayController: $this" } }
8691
this ?: TestCoroutineDispatcher()
8792
}
88-
89-
val exceptionHandler = get(CoroutineExceptionHandler).run {
90-
this?.let {
91-
require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $this" }
92-
}
93-
this ?: TestCoroutineExceptionHandler()
94-
}
95-
96-
val job = get(Job) ?: SupervisorJob()
93+
val exceptionHandler = get(CoroutineExceptionHandler) ?: TestCoroutineExceptionHandler()
94+
val job = get(Job) ?: Job()
9795
return Pair(this + dispatcher + exceptionHandler + job, dispatcher as DelayController)
9896
}

kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt

+18-8
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import kotlin.coroutines.*
1010
/**
1111
* Access uncaught coroutine exceptions captured during test execution.
1212
*/
13-
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
13+
@Deprecated("Consider whether a plain `CoroutineExceptionHandler` would suffice. If not, please report your use case at https://github.com/Kotlin/kotlinx.coroutines/issues.", level = DeprecationLevel.ERROR)
1414
public interface UncaughtExceptionCaptor {
1515
/**
1616
* List of uncaught coroutine exceptions.
@@ -33,27 +33,37 @@ public interface UncaughtExceptionCaptor {
3333

3434
/**
3535
* An exception handler that captures uncaught exceptions in tests.
36+
*
37+
* In order to work as intended, this exception handler requires that the [Job] of the test coroutine scope is a
38+
* [SupervisorJob].
3639
*/
37-
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
40+
// @Deprecated("In order to work correctly, this exception handler requires that " +
41+
42+
// "the test coroutine scope's Job is a SupervisorJob, which may lead to tests running indefinitely " +
43+
// "instead of quickly crashing. Please consider specifying another `CoroutineExceptionHandler`", level = DeprecationLevel.WARNING)
3844
public class TestCoroutineExceptionHandler :
39-
AbstractCoroutineContextElement(CoroutineExceptionHandler), UncaughtExceptionCaptor, CoroutineExceptionHandler
45+
AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler
4046
{
4147
private val _exceptions = mutableListOf<Throwable>()
48+
private var _coroutinesCleanedUp = false
4249

43-
/** @suppress **/
50+
@Suppress("INVISIBLE_MEMBER")
4451
override fun handleException(context: CoroutineContext, exception: Throwable) {
4552
synchronized(_exceptions) {
53+
if (_coroutinesCleanedUp) {
54+
handleCoroutineExceptionImpl(context, exception)
55+
return
56+
}
4657
_exceptions += exception
4758
}
4859
}
4960

50-
/** @suppress **/
51-
override val uncaughtExceptions: List<Throwable>
61+
public val uncaughtExceptions: List<Throwable>
5262
get() = synchronized(_exceptions) { _exceptions.toList() }
5363

54-
/** @suppress **/
55-
override fun cleanupTestCoroutines() {
64+
public fun cleanupTestCoroutines() {
5665
synchronized(_exceptions) {
66+
_coroutinesCleanedUp = true
5767
val exception = _exceptions.firstOrNull() ?: return
5868
// log the rest
5969
_exceptions.drop(1).forEach { it.printStackTrace() }

kotlinx-coroutines-test/src/TestCoroutineScope.kt

+34-20
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,56 @@ import kotlin.coroutines.*
1111
* A scope which provides detailed control over the execution of coroutines for tests.
1212
*/
1313
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
14-
public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, DelayController {
14+
public interface TestCoroutineScope: CoroutineScope, DelayController {
1515
/**
16-
* Call after the test completes.
17-
* Calls [UncaughtExceptionCaptor.cleanupTestCoroutines] and [DelayController.cleanupTestCoroutines].
16+
* Is being called after a test completes.
17+
*
18+
* Calls [DelayController.cleanupTestCoroutines].
19+
*
20+
* If the [CoroutineExceptionHandler] is a [TestCoroutineExceptionHandler], its
21+
* [TestCoroutineExceptionHandler.cleanupTestCoroutines] behavior is performed:
22+
* the first exception in [uncaughtExceptions] is rethrown, and all the other exceptions are
23+
* printed using [Throwable.printStackTrace].
1824
*
1925
* @throws Throwable the first uncaught exception, if there are any uncaught exceptions.
2026
* @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended
2127
* coroutines.
2228
*/
2329
public override fun cleanupTestCoroutines()
30+
31+
/**
32+
* List of uncaught coroutine exceptions.
33+
*
34+
* Exceptions are only collected in this list if [TestCoroutineExceptionHandler] is in the test context and this
35+
* scope's job is a [SupervisorJob].
36+
* Otherwise, the failure of one child cancels all the others and the scope itself.
37+
*
38+
* The returned list is a copy of the exceptions caught during execution.
39+
* During [cleanupTestCoroutines] the first element of this list is rethrown if it is not empty.
40+
*/
41+
@Deprecated(
42+
"This list is only populated if `TestCoroutineExceptionHandler` in the test context, and so can be " +
43+
"easily misused. It is only present for backward compatibility and will be removed in the subsequent " +
44+
"releases. If you need to check the list of exceptions, please consider your own" +
45+
"`CoroutineExceptionHandler`.",
46+
level = DeprecationLevel.WARNING)
47+
public val uncaughtExceptions: List<Throwable>
2448
}
2549

2650
private class TestCoroutineScopeImpl (
2751
override val coroutineContext: CoroutineContext
2852
):
2953
TestCoroutineScope,
30-
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor,
3154
DelayController by coroutineContext.delayController
3255
{
3356
override fun cleanupTestCoroutines() {
34-
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutines()
57+
(coroutineContext[CoroutineExceptionHandler] as? TestCoroutineExceptionHandler)?.cleanupTestCoroutines()
3558
coroutineContext.delayController.cleanupTestCoroutines()
3659
}
60+
61+
override val uncaughtExceptions: List<Throwable>
62+
get() = (coroutineContext[CoroutineExceptionHandler] as? TestCoroutineExceptionHandler)?.uncaughtExceptions
63+
?: emptyList()
3764
}
3865

3966
/**
@@ -46,21 +73,8 @@ private class TestCoroutineScopeImpl (
4673
*/
4774
@Suppress("FunctionName")
4875
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
49-
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
50-
var safeContext = context
51-
if (context[ContinuationInterceptor] == null) safeContext += TestCoroutineDispatcher()
52-
if (context[CoroutineExceptionHandler] == null) safeContext += TestCoroutineExceptionHandler()
53-
return TestCoroutineScopeImpl(safeContext)
54-
}
55-
56-
private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
57-
get() {
58-
val handler = this[CoroutineExceptionHandler]
59-
return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException(
60-
"TestCoroutineScope requires a UncaughtExceptionCaptor such as " +
61-
"TestCoroutineExceptionHandler as the CoroutineExceptionHandler"
62-
)
63-
}
76+
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope =
77+
TestCoroutineScopeImpl(context.checkTestScopeArguments().first)
6478

6579
private inline val CoroutineContext.delayController: DelayController
6680
get() {

kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt

-8
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,6 @@ import org.junit.Test
99
import kotlin.test.*
1010

1111
class TestCoroutineScopeTest {
12-
@Test
13-
fun whenGivenInvalidExceptionHandler_throwsException() {
14-
val handler = CoroutineExceptionHandler { _, _ -> Unit }
15-
assertFails {
16-
TestCoroutineScope(handler)
17-
}
18-
}
19-
2012
@Test
2113
fun whenGivenInvalidDispatcher_throwsException() {
2214
assertFails {

kotlinx-coroutines-test/test/TestRunBlockingTest.kt

-12
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,6 @@ class TestRunBlockingTest {
288288
}
289289
}
290290

291-
@Test(expected = java.lang.IllegalArgumentException::class)
292-
fun runBlockingTestBuilder_throwsOnBadHandler() {
293-
runBlockingTest(CoroutineExceptionHandler { _, _ -> Unit} ) {
294-
295-
}
296-
}
297-
298291
@Test
299292
fun pauseDispatcher_disablesAutoAdvance_forCurrent() = runBlockingTest {
300293
var mutable = 0
@@ -389,9 +382,4 @@ class TestRunBlockingTest {
389382
fun testOverrideExceptionHandler() = runBlockingTest(exceptionHandler) {
390383
assertSame(coroutineContext[CoroutineExceptionHandler], exceptionHandler)
391384
}
392-
393-
@Test(expected = IllegalArgumentException::class)
394-
fun testOverrideExceptionHandlerError() = runBlockingTest(CoroutineExceptionHandler { _, _ -> }) {
395-
fail("Unreached")
396-
}
397385
}

0 commit comments

Comments
 (0)