Skip to content

Commit 68832cc

Browse files
committed
Change exception handling in the test module
1 parent 5fd866a commit 68832cc

7 files changed

+167
-61
lines changed

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

+10-3
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor
5151

5252
public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor {
5353
public fun <init> ()V
54-
public fun cleanupTestCoroutinesCaptor ()V
54+
public fun cleanupTestCoroutines ()V
5555
public fun getUncaughtExceptions ()Ljava/util/List;
5656
public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V
5757
}
@@ -68,9 +68,10 @@ public final class kotlinx/coroutines/test/TestCoroutineScheduler : kotlin/corou
6868
public final class kotlinx/coroutines/test/TestCoroutineScheduler$Key : kotlin/coroutines/CoroutineContext$Key {
6969
}
7070

71-
public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/UncaughtExceptionCaptor {
71+
public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope {
7272
public abstract fun cleanupTestCoroutines ()V
7373
public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
74+
public abstract fun reportException (Ljava/lang/Throwable;)V
7475
}
7576

7677
public final class kotlinx/coroutines/test/TestCoroutineScope$DefaultImpls {
@@ -85,6 +86,7 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
8586
public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope;
8687
public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
8788
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J
89+
public static final fun getUncaughtExceptions (Lkotlinx/coroutines/test/TestCoroutineScope;)Ljava/util/List;
8890
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
8991
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
9092
public static final fun resumeDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
@@ -104,8 +106,13 @@ public final class kotlinx/coroutines/test/TestDispatchers {
104106
public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V
105107
}
106108

109+
public final class kotlinx/coroutines/test/TestExceptionHandlerKt {
110+
public static final fun TestExceptionHandler (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function3;)Lkotlinx/coroutines/CoroutineExceptionHandler;
111+
public static synthetic fun TestExceptionHandler$default (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lkotlinx/coroutines/CoroutineExceptionHandler;
112+
}
113+
107114
public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor {
108-
public abstract fun cleanupTestCoroutinesCaptor ()V
115+
public abstract fun cleanupTestCoroutines ()V
109116
public abstract fun getUncaughtExceptions ()Ljava/util/List;
110117
}
111118

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

+19-10
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import kotlin.coroutines.*
1111
/**
1212
* Access uncaught coroutine exceptions captured during test execution.
1313
*/
14-
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
14+
@Deprecated(
15+
"Consider whether a `TestExceptionHandler` would work instead. If not, please report your use case at https://github.com/Kotlin/kotlinx.coroutines/issues.",
16+
level = DeprecationLevel.WARNING
17+
)
1518
public interface UncaughtExceptionCaptor {
1619
/**
1720
* List of uncaught coroutine exceptions.
1821
*
1922
* The returned list is a copy of the currently caught exceptions.
20-
* During [cleanupTestCoroutinesCaptor] the first element of this list is rethrown if it is not empty.
23+
* During [cleanupTestCoroutines] the first element of this list is rethrown if it is not empty.
2124
*/
2225
public val uncaughtExceptions: List<Throwable>
2326

@@ -29,33 +32,39 @@ public interface UncaughtExceptionCaptor {
2932
*
3033
* @throws Throwable the first uncaught exception, if there are any uncaught exceptions.
3134
*/
32-
public fun cleanupTestCoroutinesCaptor()
35+
public fun cleanupTestCoroutines()
3336
}
3437

3538
/**
3639
* An exception handler that captures uncaught exceptions in tests.
3740
*/
38-
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
41+
@Deprecated("You can use `TestExceptionHandler` to define an exception handler that is linked to " +
42+
"a `TestCoroutineScope`, or define your own `CoroutineExceptionHandler` if you just need to handle uncaught " +
43+
"exceptions without a special `TestCoroutineScope` integration.", level = DeprecationLevel.WARNING)
3944
public class TestCoroutineExceptionHandler :
40-
AbstractCoroutineContextElement(CoroutineExceptionHandler), UncaughtExceptionCaptor, CoroutineExceptionHandler
45+
AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler, UncaughtExceptionCaptor
4146
{
4247
private val _exceptions = mutableListOf<Throwable>()
4348
private val _lock = SynchronizedObject()
49+
private var _coroutinesCleanedUp = false
4450

45-
/** @suppress **/
51+
@Suppress("INVISIBLE_MEMBER")
4652
override fun handleException(context: CoroutineContext, exception: Throwable) {
4753
synchronized(_lock) {
54+
if (_coroutinesCleanedUp) {
55+
handleCoroutineExceptionImpl(context, exception)
56+
return
57+
}
4858
_exceptions += exception
4959
}
5060
}
5161

52-
/** @suppress **/
53-
override val uncaughtExceptions: List<Throwable>
62+
public override val uncaughtExceptions: List<Throwable>
5463
get() = synchronized(_lock) { _exceptions.toList() }
5564

56-
/** @suppress **/
57-
override fun cleanupTestCoroutinesCaptor() {
65+
public override fun cleanupTestCoroutines() {
5866
synchronized(_lock) {
67+
_coroutinesCleanedUp = true
5968
val exception = _exceptions.firstOrNull() ?: return
6069
// log the rest
6170
_exceptions.drop(1).forEach { it.printStackTrace() }

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

+75-26
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,33 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.internal.*
89
import kotlin.coroutines.*
910

1011
/**
1112
* A scope which provides detailed control over the execution of coroutines for tests.
1213
*/
1314
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
14-
public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor {
15+
public interface TestCoroutineScope: CoroutineScope {
1516
/**
1617
* Called after the test completes.
1718
*
18-
* Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines].
19-
* If a new job was created for this scope, the job is completed.
19+
* * It checks that there were no uncaught exceptions reported via [reportException]. If there were any, then the
20+
* first one is thrown, whereas the rest are printed to the standard output or the standard error output
21+
* (see [Throwable.printStackTrace]).
22+
* * It runs the tasks pending in the scheduler at the current time. If there are any uncompleted tasks afterwards,
23+
* it fails with [UncompletedCoroutinesError].
24+
* * It checks whether some new child [Job]s were created but not completed since this [TestCoroutineScope] was
25+
* created. If so, it fails with [UncompletedCoroutinesError].
26+
*
27+
* For backwards compatibility, if the [CoroutineExceptionHandler] is an [UncaughtExceptionCaptor], its
28+
* [TestCoroutineExceptionHandler.cleanupTestCoroutines] behavior is performed.
29+
* Likewise, if the [ContinuationInterceptor] is a [DelayController], its [DelayController.cleanupTestCoroutines]
30+
* is called.
2031
*
2132
* @throws Throwable the first uncaught exception, if there are any uncaught exceptions.
2233
* @throws UncompletedCoroutinesError if any pending tasks are active.
34+
* @throws IllegalStateException if called more than once.
2335
*/
2436
@ExperimentalCoroutinesApi
2537
public fun cleanupTestCoroutines()
@@ -31,23 +43,56 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor {
3143
public val testScheduler: TestCoroutineScheduler
3244
get() = coroutineContext[TestCoroutineScheduler]
3345
?: throw UnsupportedOperationException("This scope does not have a TestCoroutineScheduler linked to it")
46+
47+
/**
48+
* Reports an exception so that it is thrown on [cleanupTestCoroutines].
49+
*
50+
* If several exceptions are reported, only the first one will be thrown, and the other ones will be printed to the
51+
* console.
52+
*
53+
* @throws IllegalStateException with the [Throwable.cause] set to [throwable] if [cleanupTestCoroutines] was
54+
* already called.
55+
*/
56+
@ExperimentalCoroutinesApi
57+
public fun reportException(throwable: Throwable)
3458
}
3559

3660
private class TestCoroutineScopeImpl (
3761
override val coroutineContext: CoroutineContext,
3862
val ownJob: CompletableJob?
39-
):
40-
TestCoroutineScope,
41-
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
63+
): TestCoroutineScope
4264
{
65+
private val lock = SynchronizedObject()
66+
private var exceptions = mutableListOf<Throwable>()
67+
private var cleanedUp = false
68+
69+
override fun reportException(throwable: Throwable) {
70+
synchronized(lock) {
71+
if (cleanedUp)
72+
throw IllegalStateException(
73+
"Attempting to report an uncaught exception after the test coroutine scope was already cleaned up",
74+
throwable)
75+
exceptions.add(throwable)
76+
}
77+
}
78+
4379
override val testScheduler: TestCoroutineScheduler
4480
get() = coroutineContext[TestCoroutineScheduler]!!
4581

4682
/** These jobs existed before the coroutine scope was used, so it's alright if they don't get cancelled. */
4783
val initialJobs = coroutineContext.activeJobs()
4884

4985
override fun cleanupTestCoroutines() {
50-
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
86+
synchronized(lock) {
87+
if (cleanedUp)
88+
throw IllegalStateException("Attempting to clean up a test coroutine scope more than once.")
89+
cleanedUp = true
90+
}
91+
exceptions.apply {
92+
drop(1).forEach { it.printStackTrace() }
93+
singleOrNull()?.let { throw it }
94+
}
95+
(coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.cleanupTestCoroutines()
5196
val delayController = coroutineContext.delayController
5297
if (delayController != null) {
5398
delayController.cleanupTestCoroutines()
@@ -79,13 +124,13 @@ private fun CoroutineContext.activeJobs(): Set<Job> {
79124
*/
80125
@Deprecated("This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " +
81126
"Please use `createTestCoroutineScope` instead.",
82-
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + context)",
127+
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + context)",
83128
"kotlin.coroutines.EmptyCoroutineContext"),
84129
level = DeprecationLevel.WARNING
85130
)
86131
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
87132
val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler()
88-
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + context)
133+
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + TestCoroutineExceptionHandler() + context)
89134
}
90135

91136
/**
@@ -128,14 +173,8 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
128173
}
129174
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
130175
}
131-
val exceptionHandler = context[CoroutineExceptionHandler].run {
132-
this?.let {
133-
require(this is UncaughtExceptionCaptor) {
134-
"coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context"
135-
}
136-
}
137-
this ?: TestCoroutineExceptionHandler()
138-
}
176+
val exceptionHandler = context[CoroutineExceptionHandler]
177+
?: TestExceptionHandler { _, throwable -> reportException(throwable) }
139178
val job: Job
140179
val ownJob: CompletableJob?
141180
if (context[Job] == null) {
@@ -146,17 +185,9 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
146185
job = context[Job]!!
147186
}
148187
return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job, ownJob)
188+
.also { (exceptionHandler as? TestExceptionHandlerContextElement)?.tryRegisterTestCoroutineScope(it) }
149189
}
150190

151-
private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
152-
get() {
153-
val handler = this[CoroutineExceptionHandler]
154-
return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException(
155-
"TestCoroutineScope requires a UncaughtExceptionCaptor such as " +
156-
"TestCoroutineExceptionHandler as the CoroutineExceptionHandler"
157-
)
158-
}
159-
160191
private inline val CoroutineContext.delayController: DelayController?
161192
get() {
162193
val handler = this[ContinuationInterceptor]
@@ -248,6 +279,24 @@ public fun TestCoroutineScope.resumeDispatcher() {
248279
delayControllerForPausing.resumeDispatcher()
249280
}
250281

282+
/**
283+
* List of uncaught coroutine exceptions, for backward compatibility.
284+
*
285+
* The returned list is a copy of the exceptions caught during execution.
286+
* During [TestCoroutineScope.cleanupTestCoroutines] the first element of this list is rethrown if it is not empty.
287+
*
288+
* Exceptions are only collected in this list if the [UncaughtExceptionCaptor] is in the test context.
289+
*/
290+
@Deprecated(
291+
"This list is only populated if `UncaughtExceptionCaptor` is in the test context, and so can be " +
292+
"easily misused. It is only present for backward compatibility and will be removed in the subsequent " +
293+
"releases. If you need to check the list of exceptions, please consider creating your own " +
294+
"`CoroutineExceptionHandler`.",
295+
level = DeprecationLevel.WARNING)
296+
public val TestCoroutineScope.uncaughtExceptions: List<Throwable>
297+
get() = (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.uncaughtExceptions
298+
?: emptyList()
299+
251300
private val TestCoroutineScope.delayControllerForPausing: DelayController
252301
get() = coroutineContext.delayController
253302
?: throw IllegalStateException("This scope isn't able to pause its dispatchers")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.test
6+
7+
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.internal.*
9+
import kotlin.coroutines.*
10+
11+
/**
12+
* A [CoroutineExceptionHandler] connected to a [TestCoroutineScope].
13+
*
14+
* This function accepts a [handler] that describes how to handle uncaught exceptions during tests; see
15+
* [CoroutineExceptionHandler] for details. As opposed to [CoroutineExceptionHandler], however, this has access to the
16+
* [TestCoroutineScope], which allows [cancelling][CoroutineScope.cancel] it or
17+
* [reporting][TestCoroutineScope.reportException] the error so that it is thrown on the call to
18+
* [TestCoroutineScope.cleanupTestCoroutines].
19+
*
20+
* If [linkedScope] is `null`, the [CoroutineExceptionHandler] returned from this function has special behavior when
21+
* passed to [createTestCoroutineScope]: the newly-created scope is linked to this handler. If [linkedScope] is not
22+
* null, then the resulting [CoroutineExceptionHandler] will be linked to it, and passing it to [TestCoroutineScope]
23+
* will not lead to it re-linking.
24+
*/
25+
public fun TestExceptionHandler(
26+
linkedScope: TestCoroutineScope? = null,
27+
handler: TestCoroutineScope.(CoroutineContext, Throwable) -> Unit
28+
): CoroutineExceptionHandler = TestExceptionHandlerContextElement(handler, linkedScope)
29+
30+
/** The [CoroutineExceptionHandler] corresponding to the given [handler]. */
31+
internal class TestExceptionHandlerContextElement(
32+
private val handler: TestCoroutineScope.(CoroutineContext, Throwable) -> Unit,
33+
private var testCoroutineScope: TestCoroutineScope?
34+
): AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler
35+
{
36+
private val lock = SynchronizedObject()
37+
38+
/**
39+
* Links a [TestCoroutineScope] to this, unless there's already one linked.
40+
*/
41+
fun tryRegisterTestCoroutineScope(scope: TestCoroutineScope): Boolean =
42+
synchronized(lock) {
43+
if (testCoroutineScope != null) {
44+
false
45+
} else {
46+
testCoroutineScope = scope
47+
true
48+
}
49+
}
50+
51+
override fun handleException(context: CoroutineContext, exception: Throwable) {
52+
synchronized(lock) {
53+
testCoroutineScope
54+
?: throw RuntimeException("Attempting to handle an exception using a `TestExceptionHandler` that is not linked to a `TestCoroutineScope`")
55+
}.handler(context, exception)
56+
/** it's okay if [handler] throws: [handleCoroutineException] deals with this. */
57+
}
58+
}

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

-1
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,6 @@ class TestCoroutineScopeTest {
124124
internal val invalidContexts = listOf(
125125
Dispatchers.Default, // not a [TestDispatcher]
126126
StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler
127-
CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor`
128127
)
129128
}
130129
}

kotlinx-coroutines-test/common/test/TestCoroutineExceptionHandlerTest.kt renamed to kotlinx-coroutines-test/common/test/migration/TestCoroutineExceptionHandlerTest.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/*
2-
* Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
44

55
package kotlinx.coroutines.test
66

77
import kotlin.test.*
88

9+
@Suppress("DEPRECATION")
910
class TestCoroutineExceptionHandlerTest {
1011
@Test
1112
fun whenExceptionsCaught_availableViaProperty() {

0 commit comments

Comments
 (0)