Skip to content

Commit 11c3712

Browse files
committed
Change exception handling in the test module
1 parent e821121 commit 11c3712

10 files changed

+387
-59
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/TestCoroutineScopeKt {
@@ -81,6 +82,7 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
8182
public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope;
8283
public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
8384
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J
85+
public static final fun getUncaughtExceptions (Lkotlinx/coroutines/test/TestCoroutineScope;)Ljava/util/List;
8486
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
8587
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
8688
public static final fun resumeDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
@@ -100,8 +102,13 @@ public final class kotlinx/coroutines/test/TestDispatchers {
100102
public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V
101103
}
102104

105+
public final class kotlinx/coroutines/test/TestExceptionHandlerKt {
106+
public static final fun TestExceptionHandler (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function3;)Lkotlinx/coroutines/CoroutineExceptionHandler;
107+
public static synthetic fun TestExceptionHandler$default (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lkotlinx/coroutines/CoroutineExceptionHandler;
108+
}
109+
103110
public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor {
104-
public abstract fun cleanupTestCoroutinesCaptor ()V
111+
public abstract fun cleanupTestCoroutines ()V
105112
public abstract fun getUncaughtExceptions ()Ljava/util/List;
106113
}
107114

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,11 @@ private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
280280

281281
private class TestBodyCoroutine<T>(
282282
private val testScope: TestCoroutineScope,
283-
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope,
284-
UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor
283+
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope
285284
{
286285
override val testScheduler get() = testScope.testScheduler
287286

288287
override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines()
289288

289+
override fun reportException(throwable: Throwable) = testScope.reportException(throwable)
290290
}

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
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
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

+91-23
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
14-
public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor {
15+
public sealed 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 AssertionError if any pending tasks are active.
34+
* @throws IllegalStateException if called more than once.
2335
*/
2436
@ExperimentalCoroutinesApi
2537
public fun cleanupTestCoroutines()
@@ -29,14 +41,36 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap
2941
*/
3042
@ExperimentalCoroutinesApi
3143
public val testScheduler: TestCoroutineScheduler
44+
45+
/**
46+
* Reports an exception so that it is thrown on [cleanupTestCoroutines].
47+
*
48+
* If several exceptions are reported, only the first one will be thrown, and the other ones will be suppressed by
49+
* it.
50+
*
51+
* @throws IllegalStateException with the [Throwable.cause] set to [throwable] if [cleanupTestCoroutines] was
52+
* already called.
53+
*/
54+
@ExperimentalCoroutinesApi
55+
public fun reportException(throwable: Throwable)
3256
}
3357

3458
private class TestCoroutineScopeImpl(
3559
override val coroutineContext: CoroutineContext
36-
):
37-
TestCoroutineScope,
38-
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
60+
): TestCoroutineScope
3961
{
62+
private val lock = SynchronizedObject()
63+
private var exceptions = mutableListOf<Throwable>()
64+
private var cleanedUp = false
65+
66+
override fun reportException(throwable: Throwable) {
67+
synchronized(lock) {
68+
if (cleanedUp)
69+
throw ExceptionReportAfterCleanup(throwable)
70+
exceptions.add(throwable)
71+
}
72+
}
73+
4074
override val testScheduler: TestCoroutineScheduler
4175
get() = coroutineContext[TestCoroutineScheduler]!!
4276

@@ -56,7 +90,16 @@ private class TestCoroutineScopeImpl(
5690
testScheduler.runCurrent()
5791
!testScheduler.isIdle()
5892
}
59-
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
93+
(coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.cleanupTestCoroutines()
94+
synchronized(lock) {
95+
if (cleanedUp)
96+
throw IllegalStateException("Attempting to clean up a test coroutine scope more than once.")
97+
cleanedUp = true
98+
}
99+
exceptions.firstOrNull()?.let { toThrow ->
100+
exceptions.drop(1).forEach { toThrow.addSuppressed(it) }
101+
throw toThrow
102+
}
60103
if (hasUnfinishedJobs)
61104
throw UncompletedCoroutinesError(
62105
"Unfinished coroutines during teardown. Ensure all coroutines are" +
@@ -68,6 +111,11 @@ private class TestCoroutineScopeImpl(
68111
}
69112
}
70113

114+
internal class ExceptionReportAfterCleanup(cause: Throwable): IllegalStateException(
115+
"Attempting to report an uncaught exception after the test coroutine scope was already cleaned up",
116+
cause
117+
)
118+
71119
private fun CoroutineContext.activeJobs(): Set<Job> {
72120
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
73121
}
@@ -79,13 +127,13 @@ private fun CoroutineContext.activeJobs(): Set<Job> {
79127
*/
80128
@Deprecated("This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " +
81129
"Please use `createTestCoroutineScope` instead.",
82-
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + context)",
130+
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + context)",
83131
"kotlin.coroutines.EmptyCoroutineContext"),
84132
level = DeprecationLevel.WARNING
85133
)
86134
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
87135
val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler()
88-
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + context)
136+
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + TestCoroutineExceptionHandler() + context)
89137
}
90138

91139
/**
@@ -126,26 +174,28 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
126174
}
127175
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
128176
}
129-
val exceptionHandler = context[CoroutineExceptionHandler].run {
130-
this?.let {
131-
require(this is UncaughtExceptionCaptor) {
132-
"coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context"
133-
}
177+
val linkedHandler: TestExceptionHandlerContextElement?
178+
val exceptionHandler: CoroutineExceptionHandler
179+
val handlerOwner = Any()
180+
when (val exceptionHandlerInCtx = context[CoroutineExceptionHandler]) {
181+
null -> {
182+
linkedHandler = TestExceptionHandlerContextElement(
183+
{ _, throwable -> reportException(throwable) },
184+
null,
185+
handlerOwner)
186+
exceptionHandler = linkedHandler
187+
}
188+
else -> {
189+
linkedHandler = (exceptionHandlerInCtx as? TestExceptionHandlerContextElement
190+
)?.claimOwnershipOrCopy(handlerOwner)
191+
exceptionHandler = linkedHandler ?: exceptionHandlerInCtx
134192
}
135-
this ?: TestCoroutineExceptionHandler()
136193
}
137194
val job: Job = context[Job] ?: SupervisorJob()
138195
return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job)
196+
.also { linkedHandler?.registerTestCoroutineScope(handlerOwner, it) }
139197
}
140198

141-
internal inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
142-
get() {
143-
val handler = this[CoroutineExceptionHandler]
144-
return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException(
145-
"TestCoroutineScope requires a UncaughtExceptionCaptor such as " +
146-
"TestCoroutineExceptionHandler as the CoroutineExceptionHandler"
147-
)
148-
}
149199

150200
private inline val CoroutineContext.delayController: DelayController?
151201
get() {
@@ -238,6 +288,24 @@ public fun TestCoroutineScope.resumeDispatcher() {
238288
delayControllerForPausing.resumeDispatcher()
239289
}
240290

291+
/**
292+
* List of uncaught coroutine exceptions, for backward compatibility.
293+
*
294+
* The returned list is a copy of the exceptions caught during execution.
295+
* During [TestCoroutineScope.cleanupTestCoroutines] the first element of this list is rethrown if it is not empty.
296+
*
297+
* Exceptions are only collected in this list if the [UncaughtExceptionCaptor] is in the test context.
298+
*/
299+
@Deprecated(
300+
"This list is only populated if `UncaughtExceptionCaptor` is in the test context, and so can be " +
301+
"easily misused. It is only present for backward compatibility and will be removed in the subsequent " +
302+
"releases. If you need to check the list of exceptions, please consider creating your own " +
303+
"`CoroutineExceptionHandler`.",
304+
level = DeprecationLevel.WARNING)
305+
public val TestCoroutineScope.uncaughtExceptions: List<Throwable>
306+
get() = (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.uncaughtExceptions
307+
?: emptyList()
308+
241309
private val TestCoroutineScope.delayControllerForPausing: DelayController
242310
get() = coroutineContext.delayController
243311
?: throw IllegalStateException("This scope isn't able to pause its dispatchers")

0 commit comments

Comments
 (0)