Skip to content

Commit 2a12be4

Browse files
committed
Change exception handling in the test module
1 parent 015b06c commit 2a12be4

9 files changed

+401
-66
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
@@ -283,11 +283,11 @@ private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L
283283

284284
private class TestBodyCoroutine<T>(
285285
private val testScope: TestCoroutineScope,
286-
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope,
287-
UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor
286+
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope
288287
{
289288
override val testScheduler get() = testScope.testScheduler
290289

291290
override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines()
292291

292+
override fun reportException(throwable: Throwable) = testScope.reportException(throwable)
293293
}

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

+113-30
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,37 +41,83 @@ 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,
3660
private val ownJob: CompletableJob?
37-
):
38-
TestCoroutineScope,
39-
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
61+
): TestCoroutineScope
4062
{
63+
private val lock = SynchronizedObject()
64+
private var exceptions = mutableListOf<Throwable>()
65+
private var cleanedUp = false
66+
67+
override fun reportException(throwable: Throwable) {
68+
synchronized(lock) {
69+
if (cleanedUp)
70+
throw ExceptionReportAfterCleanup(throwable)
71+
exceptions.add(throwable)
72+
}
73+
}
74+
4175
override val testScheduler: TestCoroutineScheduler
4276
get() = coroutineContext[TestCoroutineScheduler]!!
4377

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

4781
override fun cleanupTestCoroutines() {
48-
try {
82+
val hasUncompletedJobs = try {
83+
var hasUncompletedJobs = false
4984
val delayController = coroutineContext.delayController
5085
if (delayController != null) {
51-
delayController.cleanupTestCoroutines()
52-
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
86+
try {
87+
delayController.cleanupTestCoroutines() // may throw something else
88+
} catch (e: UncompletedCoroutinesError) {
89+
hasUncompletedJobs = true
90+
}
5391
} else {
5492
testScheduler.runCurrent()
55-
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
5693
if (!testScheduler.isIdle()) {
57-
throw UncompletedCoroutinesError(
58-
"Unfinished coroutines during teardown. Ensure all coroutines are" +
59-
" completed or cancelled by your test."
60-
)
94+
hasUncompletedJobs = true
6195
}
6296
}
97+
(coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.cleanupTestCoroutines() // may throw
98+
hasUncompletedJobs
99+
} catch (exception: Throwable) {
100+
// this `catch` block can be removed when the deprecated API is hidden.
101+
ownJob?.completeExceptionally(exception)
102+
throw exception
103+
} finally {
104+
synchronized(lock) {
105+
if (cleanedUp)
106+
throw IllegalStateException("Attempting to clean up a test coroutine scope more than once.")
107+
cleanedUp = true
108+
}
109+
}
110+
try {
111+
exceptions.firstOrNull()?.let {
112+
val toThrow = it
113+
exceptions.drop(1).forEach { toThrow.addSuppressed(it) }
114+
throw toThrow
115+
}
116+
if (hasUncompletedJobs)
117+
throw UncompletedCoroutinesError(
118+
"Unfinished coroutines during teardown. Ensure all coroutines are" +
119+
" completed or cancelled by your test."
120+
)
63121
val jobs = coroutineContext.activeJobs()
64122
if ((jobs - initialJobs).isNotEmpty())
65123
throw UncompletedCoroutinesError("Test finished with active jobs: $jobs")
@@ -71,6 +129,11 @@ private class TestCoroutineScopeImpl(
71129
}
72130
}
73131

132+
internal class ExceptionReportAfterCleanup(cause: Throwable): IllegalStateException(
133+
"Attempting to report an uncaught exception after the test coroutine scope was already cleaned up",
134+
cause
135+
)
136+
74137
private fun CoroutineContext.activeJobs(): Set<Job> {
75138
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
76139
}
@@ -82,13 +145,13 @@ private fun CoroutineContext.activeJobs(): Set<Job> {
82145
*/
83146
@Deprecated("This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " +
84147
"Please use `createTestCoroutineScope` instead.",
85-
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + context)",
148+
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + context)",
86149
"kotlin.coroutines.EmptyCoroutineContext"),
87150
level = DeprecationLevel.WARNING
88151
)
89152
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
90153
val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler()
91-
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + context)
154+
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + TestCoroutineExceptionHandler() + context)
92155
}
93156

94157
/**
@@ -131,13 +194,22 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
131194
}
132195
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
133196
}
134-
val exceptionHandler = context[CoroutineExceptionHandler].run {
135-
this?.let {
136-
require(this is UncaughtExceptionCaptor) {
137-
"coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context"
138-
}
197+
val linkedHandler: TestExceptionHandlerContextElement?
198+
val exceptionHandler: CoroutineExceptionHandler
199+
val handlerOwner = Any()
200+
when (val exceptionHandlerInCtx = context[CoroutineExceptionHandler]) {
201+
null -> {
202+
linkedHandler = TestExceptionHandlerContextElement(
203+
{ _, throwable -> reportException(throwable) },
204+
null,
205+
handlerOwner)
206+
exceptionHandler = linkedHandler
207+
}
208+
else -> {
209+
linkedHandler = (exceptionHandlerInCtx as? TestExceptionHandlerContextElement
210+
)?.claimOwnershipOrCopy(handlerOwner)
211+
exceptionHandler = linkedHandler ?: exceptionHandlerInCtx
139212
}
140-
this ?: TestCoroutineExceptionHandler()
141213
}
142214
val job: Job
143215
val ownJob: CompletableJob?
@@ -149,16 +221,9 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo
149221
job = context[Job]!!
150222
}
151223
return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job, ownJob)
224+
.also { linkedHandler?.registerTestCoroutineScope(handlerOwner, it) }
152225
}
153226

154-
internal inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
155-
get() {
156-
val handler = this[CoroutineExceptionHandler]
157-
return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException(
158-
"TestCoroutineScope requires a UncaughtExceptionCaptor such as " +
159-
"TestCoroutineExceptionHandler as the CoroutineExceptionHandler"
160-
)
161-
}
162227

163228
private inline val CoroutineContext.delayController: DelayController?
164229
get() {
@@ -251,6 +316,24 @@ public fun TestCoroutineScope.resumeDispatcher() {
251316
delayControllerForPausing.resumeDispatcher()
252317
}
253318

319+
/**
320+
* List of uncaught coroutine exceptions, for backward compatibility.
321+
*
322+
* The returned list is a copy of the exceptions caught during execution.
323+
* During [TestCoroutineScope.cleanupTestCoroutines] the first element of this list is rethrown if it is not empty.
324+
*
325+
* Exceptions are only collected in this list if the [UncaughtExceptionCaptor] is in the test context.
326+
*/
327+
@Deprecated(
328+
"This list is only populated if `UncaughtExceptionCaptor` is in the test context, and so can be " +
329+
"easily misused. It is only present for backward compatibility and will be removed in the subsequent " +
330+
"releases. If you need to check the list of exceptions, please consider creating your own " +
331+
"`CoroutineExceptionHandler`.",
332+
level = DeprecationLevel.WARNING)
333+
public val TestCoroutineScope.uncaughtExceptions: List<Throwable>
334+
get() = (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.uncaughtExceptions
335+
?: emptyList()
336+
254337
private val TestCoroutineScope.delayControllerForPausing: DelayController
255338
get() = coroutineContext.delayController
256339
?: throw IllegalStateException("This scope isn't able to pause its dispatchers")

0 commit comments

Comments
 (0)