Skip to content

Commit e64ac09

Browse files
committed
- Finished making TestCoroutineExceptionHandler threadsafe
- Added missing defaults to ctor of TestCoroutineScope - Docs cleanup & tests for coverage
1 parent 9c1d3fd commit e64ac09

8 files changed

+285
-47
lines changed

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

+12-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
@file:JvmName("TestBuilders")
2+
13
package kotlinx.coroutines.test
24

35
import kotlinx.coroutines.*
46
import kotlin.coroutines.ContinuationInterceptor
57
import kotlin.coroutines.CoroutineContext
68

7-
89
/**
910
* Executes a [testBody] inside an immediate execution dispatcher.
1011
*
@@ -35,9 +36,10 @@ import kotlin.coroutines.CoroutineContext
3536
* @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches
3637
* (including coroutines suspended on join/await).
3738
*
38-
* @param context An optional context that MAY contain a [DelayController] and/or [TestCoroutineExceptionHandler]
39+
* @param context An optional context that MUST contain a [DelayController] and/or [TestCoroutineCoroutineExceptionHandler]
3940
* @param testBody The code of the unit-test.
4041
*/
42+
@ExperimentalCoroutinesApi
4143
fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCoroutineScope.() -> Unit) {
4244
val (safeContext, dispatcher) = context.checkArguments()
4345
// smart cast dispatcher to expose interface
@@ -60,26 +62,28 @@ fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCor
6062
}
6163
}
6264

63-
private fun CoroutineContext.activeJobs() =
64-
checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
65+
private fun CoroutineContext.activeJobs(): Set<Job> {
66+
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
67+
}
6568

6669
/**
6770
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope].
6871
*/
72+
@ExperimentalCoroutinesApi
6973
fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) {
7074
runBlockingTest(coroutineContext, block)
7175
}
7276

7377
/**
7478
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher].
75-
*
7679
*/
80+
@ExperimentalCoroutinesApi
7781
fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) {
7882
runBlockingTest(this, block)
7983
}
8084

8185
private fun CoroutineContext?.checkArguments(): Pair<CoroutineContext, ContinuationInterceptor> {
82-
var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher()
86+
var safeContext= this ?: TestCoroutineCoroutineExceptionHandler() + TestCoroutineDispatcher()
8387

8488
val dispatcher = safeContext[ContinuationInterceptor].run {
8589
this?.let {
@@ -90,9 +94,9 @@ private fun CoroutineContext?.checkArguments(): Pair<CoroutineContext, Continuat
9094

9195
val exceptionHandler = safeContext[CoroutineExceptionHandler].run {
9296
this?.let {
93-
require(this is ExceptionCaptor) { "coroutineExceptionHandler must implement ExceptionCaptor" }
97+
require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor" }
9498
}
95-
this ?: TestCoroutineExceptionHandler()
99+
this ?: TestCoroutineCoroutineExceptionHandler()
96100
}
97101

98102
val job = safeContext[Job] ?: SupervisorJob()

core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt renamed to core/kotlinx-coroutines-test/src/TestCoroutineCoroutineExceptionHandler.kt

+11-13
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
11
package kotlinx.coroutines.test
22

33
import kotlinx.coroutines.CoroutineExceptionHandler
4-
import java.util.*
4+
import kotlinx.coroutines.ExperimentalCoroutinesApi
55
import kotlin.coroutines.CoroutineContext
66

77
/**
8-
* Access uncaught coroutines exceptions captured during test execution.
9-
*
10-
* Note, tests executed via [runBlockingTest] or [TestCoroutineScope.runBlocking] will not trigger uncaught exception
11-
* handling and should use [Deferred.await] or [Job.getCancellationException] to test exceptions.
8+
* Access uncaught coroutine exceptions captured during test execution.
129
*/
13-
interface ExceptionCaptor {
10+
@ExperimentalCoroutinesApi
11+
interface UncaughtExceptionCaptor {
1412
/**
1513
* List of uncaught coroutine exceptions.
1614
*
1715
* The returned list will be a copy of the currently caught exceptions.
1816
*
1917
* During [cleanupTestCoroutines] the first element of this list will be rethrown if it is not empty.
2018
*/
21-
val exceptions: List<Throwable>
19+
val uncaughtExceptions: List<Throwable>
2220

2321
/**
2422
* Call after the test completes.
@@ -31,11 +29,11 @@ interface ExceptionCaptor {
3129
/**
3230
* An exception handler that can be used to capture uncaught exceptions in tests.
3331
*/
34-
class TestCoroutineExceptionHandler: ExceptionCaptor, CoroutineExceptionHandler {
35-
val lock = Object()
32+
@ExperimentalCoroutinesApi
33+
class TestCoroutineCoroutineExceptionHandler: UncaughtExceptionCaptor, CoroutineExceptionHandler {
3634

3735
override fun handleException(context: CoroutineContext, exception: Throwable) {
38-
synchronized(lock) {
36+
synchronized(_exceptions) {
3937
_exceptions += exception
4038
}
4139
}
@@ -44,11 +42,11 @@ class TestCoroutineExceptionHandler: ExceptionCaptor, CoroutineExceptionHandler
4442

4543
private val _exceptions = mutableListOf<Throwable>()
4644

47-
override val exceptions
48-
get() = _exceptions.toList()
45+
override val uncaughtExceptions
46+
get() = synchronized(_exceptions) { _exceptions.toList() }
4947

5048
override fun cleanupTestCoroutines() {
51-
synchronized(lock) {
49+
synchronized(_exceptions) {
5250
val exception = _exceptions.firstOrNull() ?: return
5351
throw exception
5452
}

core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt

+14-2
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import kotlin.coroutines.CoroutineContext
1111
*
1212
* Testing libraries may expose this interface to tests instead of [TestCoroutineDispatcher].
1313
*/
14+
@ExperimentalCoroutinesApi
1415
interface DelayController {
1516
/**
1617
* Returns the current virtual clock-time as it is known to this Dispatcher.
1718
*
1819
* @param unit The [TimeUnit] in which the clock-time must be returned.
1920
* @return The virtual clock-time
2021
*/
22+
@ExperimentalCoroutinesApi
2123
fun currentTime(unit: TimeUnit = TimeUnit.MILLISECONDS): Long
2224

2325
/**
@@ -30,27 +32,31 @@ interface DelayController {
3032
* @param unit The [TimeUnit] in which [delayTime] and the return value is expressed.
3133
* @return The amount of delay-time that this Dispatcher's clock has been forwarded.
3234
*/
35+
@ExperimentalCoroutinesApi
3336
fun advanceTimeBy(delayTime: Long, unit: TimeUnit = TimeUnit.MILLISECONDS): Long
3437

3538
/**
3639
* Moves the current virtual clock forward just far enough so the next delay will return.
3740
*
3841
* @return the amount of delay-time that this Dispatcher's clock has been forwarded.
3942
*/
43+
@ExperimentalCoroutinesApi
4044
fun advanceTimeToNextDelayed(): Long
4145

4246
/**
4347
* Immediately execute all pending tasks and advance the virtual clock-time to the last delay.
4448
*
4549
* @return the amount of delay-time that this Dispatcher's clock has been forwarded.
4650
*/
51+
@ExperimentalCoroutinesApi
4752
fun advanceUntilIdle(): Long
4853

4954
/**
5055
* Run any tasks that are pending at or before the current virtual clock-time.
5156
*
5257
* Calling this function will never advance the clock.
5358
*/
59+
@ExperimentalCoroutinesApi
5460
fun runCurrent()
5561

5662
/**
@@ -59,6 +65,7 @@ interface DelayController {
5965
* @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended
6066
* coroutines.
6167
*/
68+
@ExperimentalCoroutinesApi
6269
@Throws(UncompletedCoroutinesError::class)
6370
fun cleanupTestCoroutines()
6471

@@ -71,6 +78,7 @@ interface DelayController {
7178
* This is useful when testing functions that that start a coroutine. By pausing the dispatcher assertions or
7279
* setup may be done between the time the coroutine is created and started.
7380
*/
81+
@ExperimentalCoroutinesApi
7482
suspend fun pauseDispatcher(block: suspend () -> Unit)
7583

7684
/**
@@ -79,6 +87,7 @@ interface DelayController {
7987
* When paused the dispatcher will not execute any coroutines automatically, and you must call [runCurrent], or one
8088
* of [advanceTimeBy], [advanceTimeToNextDelayed], or [advanceUntilIdle] to execute coroutines.
8189
*/
90+
@ExperimentalCoroutinesApi
8291
fun pauseDispatcher()
8392

8493
/**
@@ -88,6 +97,7 @@ interface DelayController {
8897
* time and execute coroutines scheduled in the future use one of [advanceTimeBy], [advanceTimeToNextDelayed],
8998
* or [advanceUntilIdle].
9099
*/
100+
@ExperimentalCoroutinesApi
91101
fun resumeDispatcher()
92102

93103
@Deprecated("This API has been deprecated to integrate with Structured Concurrency.",
@@ -112,12 +122,13 @@ interface DelayController {
112122
/**
113123
* Thrown when a test has completed by there are tasks that are not completed or cancelled.
114124
*/
125+
@ExperimentalCoroutinesApi
115126
class UncompletedCoroutinesError(message: String, cause: Throwable? = null): AssertionError(message, cause)
116127

117128
/**
118129
* [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines.
119130
*
120-
* By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run immediately will
131+
* By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run without delay will
121132
* be immediately executed. If they were scheduled with a delay, the virtual clock-time must be advanced via one of the
122133
* methods on [DelayController]
123134
*
@@ -127,6 +138,7 @@ class UncompletedCoroutinesError(message: String, cause: Throwable? = null): Ass
127138
*
128139
* @see DelayController
129140
*/
141+
@ExperimentalCoroutinesApi
130142
class TestCoroutineDispatcher:
131143
CoroutineDispatcher(),
132144
Delay,
@@ -171,7 +183,7 @@ class TestCoroutineDispatcher:
171183
}
172184
}
173185

174-
override fun toString(): String = "TestCoroutineDispatcher[time=$time ns]"
186+
override fun toString(): String = "TestCoroutineDispatcher[time=${time}ns]"
175187

176188
private fun post(block: Runnable) =
177189
queue.addLast(TimedRunnable(block, counter++))

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

+44-23
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,12 @@ import kotlinx.coroutines.*
88
import kotlin.coroutines.ContinuationInterceptor
99
import kotlin.coroutines.CoroutineContext
1010

11+
1112
/**
1213
* A scope which provides detailed control over the execution of coroutines for tests.
13-
*
14-
* @param context an optional context that must provide delegates [ExceptionCaptor] and [DelayController]
1514
*/
16-
class TestCoroutineScope(
17-
context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineExceptionHandler()):
18-
CoroutineScope,
19-
ExceptionCaptor by context.exceptionDelegate,
20-
DelayController by context.delayDelegate
21-
{
22-
override fun cleanupTestCoroutines() {
23-
coroutineContext.exceptionDelegate.cleanupTestCoroutines()
24-
coroutineContext.delayDelegate.cleanupTestCoroutines()
25-
}
26-
27-
override val coroutineContext = context
28-
15+
@ExperimentalCoroutinesApi
16+
interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, DelayController {
2917
/**
3018
* This method is deprecated.
3119
*
@@ -37,21 +25,54 @@ class TestCoroutineScope(
3725
fun cancelAllActions() = cleanupTestCoroutines()
3826
}
3927

40-
fun TestCoroutineScope(dispatcher: TestCoroutineDispatcher) =
41-
TestCoroutineScope(dispatcher + TestCoroutineExceptionHandler())
28+
private class TestCoroutineScopeImpl (
29+
context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineCoroutineExceptionHandler()):
30+
TestCoroutineScope,
31+
UncaughtExceptionCaptor by context.uncaughtExceptionDelegate,
32+
DelayController by context.delayDelegate
33+
{
34+
35+
override fun cleanupTestCoroutines() {
36+
coroutineContext.uncaughtExceptionDelegate.cleanupTestCoroutines()
37+
coroutineContext.delayDelegate.cleanupTestCoroutines()
38+
}
39+
40+
override val coroutineContext = context
41+
}
42+
43+
/**
44+
* A scope which provides detailed control over the execution of coroutines for tests.
45+
*
46+
* If the provided context does not provide a [ContinuationInterceptor] (Dispatcher) or [CoroutineExceptionHandler], the
47+
* scope will add [TestCoroutineDispatcher] and [TestCoroutineExceptionHandler] automatically.
48+
*
49+
* @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController]
50+
*/
51+
@ExperimentalCoroutinesApi
52+
fun TestCoroutineScope(context: CoroutineContext? = null): TestCoroutineScope {
53+
var safeContext = context ?: return TestCoroutineScopeImpl()
54+
if (context[ContinuationInterceptor] == null) {
55+
safeContext += TestCoroutineDispatcher()
56+
}
57+
if (context[CoroutineExceptionHandler] == null) {
58+
safeContext += TestCoroutineCoroutineExceptionHandler()
59+
}
60+
61+
return TestCoroutineScopeImpl(safeContext)
62+
}
4263

43-
private inline val CoroutineContext.exceptionDelegate: ExceptionCaptor
64+
private inline val CoroutineContext.uncaughtExceptionDelegate: UncaughtExceptionCaptor
4465
get() {
4566
val handler = this[CoroutineExceptionHandler]
46-
return handler as? ExceptionCaptor ?: throw
47-
IllegalArgumentException("TestCoroutineScope requires a ExceptionCaptor as the " +
48-
"CoroutineExceptionHandler")
67+
return handler as? UncaughtExceptionCaptor ?: throw
68+
IllegalArgumentException("TestCoroutineScope requires a UncaughtExceptionCaptor such as " +
69+
"TestCoroutineCoroutineExceptionHandler as the CoroutineExceptionHandler")
4970
}
5071

5172
private inline val CoroutineContext.delayDelegate: DelayController
5273
get() {
5374
val handler = this[ContinuationInterceptor]
5475
return handler as? DelayController ?: throw
55-
IllegalArgumentException("TestCoroutineScope requires a DelayController as the " +
56-
"ContinuationInterceptor (Dispatcher)")
76+
IllegalArgumentException("TestCoroutineScope requires a DelayController such as TestCoroutineDispatcher as " +
77+
"the ContinuationInterceptor (Dispatcher)")
5778
}

core/kotlinx-coroutines-test/test/TestTestBuilders.kt renamed to core/kotlinx-coroutines-test/test/TestBuildersTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import org.junit.*
88
import org.junit.Assert.*
99
import kotlin.coroutines.ContinuationInterceptor
1010

11-
class TestTestBuilders {
11+
class TestBuildersTest {
1212

1313
@Test
1414
fun scopeRunBlocking_passesDispatcher() {

0 commit comments

Comments
 (0)