Skip to content

Commit 8328a39

Browse files
committed
API Cleanup based on review
- removed asyncTest - added pauseDispatcher(), pausDispatcher(block), and resumeDispatcher() API to delayController - removed dispatchImmediately (the name was fairly opaque in an actual test case) - added exception handling to runBlockingTest
1 parent eba24c5 commit 8328a39

File tree

5 files changed

+174
-468
lines changed

5 files changed

+174
-468
lines changed
Lines changed: 32 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,17 @@
11
package kotlinx.coroutines.test
22

33
import kotlinx.coroutines.*
4-
import java.util.concurrent.TimeoutException
54
import kotlin.coroutines.ContinuationInterceptor
65
import kotlin.coroutines.CoroutineContext
7-
import kotlin.coroutines.coroutineContext
86

9-
/**
10-
* Executes a [testBody] in a [TestCoroutineScope] which provides detailed control over the execution of coroutines.
11-
*
12-
* This function should be used when you need detailed control over the execution of your test. For most tests consider
13-
* using [runBlockingTest].
14-
*
15-
* Code executed in a `asyncTest` will dispatch lazily. That means calling builders such as [launch] or [async] will
16-
* not execute the block immediately. You can use methods like [TestCoroutineScope.runCurrent] and
17-
* [TestCoroutineScope.advanceTimeTo] on the [TestCoroutineScope]. For a full list of execution methods see
18-
* [DelayController].
19-
*
20-
* ```
21-
* @Test
22-
* fun exampleTest() = asyncTest {
23-
* // 1: launch will execute but not run the body
24-
* launch {
25-
* // 3: the body of launch will execute in response to runCurrent [currentTime = 0ms]
26-
* delay(1_000)
27-
* // 5: After the time is advanced, delay(1_000) will return [currentTime = 1000ms]
28-
* println("Faster delays!")
29-
* }
30-
*
31-
* // 2: use runCurrent() to execute the body of launch [currentTime = 0ms]
32-
* runCurrent()
33-
*
34-
* // 4: advance the dispatcher "time" by 1_000, which will resume after the delay
35-
* advanceTimeTo(1_000)
36-
*
37-
* ```
38-
*
39-
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test
40-
* conditions.
41-
*
42-
* In addition any unhandled exceptions thrown in coroutines must be rethrown by
43-
* [TestCoroutineScope.rethrowUncaughtCoroutineException] or cleared via [TestCoroutineScope.exceptions] inside of
44-
* [testBody].
45-
*
46-
* @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches
47-
* (including coroutines suspended on await).
48-
* @throws Throwable If an uncaught exception was captured by this test it will be rethrown.
49-
*
50-
* @param context An optional dispatcher, during [testBody] execution [DelayController.dispatchImmediately] will be set to false
51-
* @param testBody The code of the unit-test.
52-
*
53-
* @see [runBlockingTest]
54-
*/
55-
fun asyncTest(context: CoroutineContext? = null, testBody: TestCoroutineScope.() -> Unit) {
56-
val (safeContext, dispatcher) = context.checkArguments()
57-
// smart cast dispatcher to expose interface
58-
dispatcher as DelayController
59-
val scope = TestCoroutineScope(safeContext)
60-
61-
val oldDispatch = dispatcher.dispatchImmediately
62-
dispatcher.dispatchImmediately = false
63-
64-
try {
65-
scope.testBody()
66-
scope.cleanupTestCoroutines()
67-
68-
// check for any active child jobs after cleanup (e.g. coroutines suspended on calls to await)
69-
val job = checkNotNull(safeContext[Job]) { "Job required for asyncTest" }
70-
val activeChildren = job.children.filter { it.isActive }.toList()
71-
if (activeChildren.isNotEmpty()) {
72-
throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}")
73-
}
74-
} finally {
75-
dispatcher.dispatchImmediately = oldDispatch
76-
}
77-
}
78-
79-
/**
80-
* @see [asyncTest]
81-
*/
82-
fun TestCoroutineScope.asyncTest(testBody: TestCoroutineScope.() -> Unit) =
83-
asyncTest(coroutineContext, testBody)
84-
85-
/**
86-
* This method is deprecated.
87-
*
88-
* @see [cleanupTestCoroutines]
89-
*/
90-
@Deprecated("This API has been deprecated to integrate with Structured Concurrency.",
91-
ReplaceWith("scope.runBlockingTest(testBody)", "kotlinx.coroutines.test"),
92-
level = DeprecationLevel.ERROR)
93-
fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineScope.() -> Unit) {
94-
scope.runBlockingTest(testBody)
95-
}
967

978
/**
989
* Executes a [testBody] inside an immediate execution dispatcher.
9910
*
10011
* This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks.
10112
* You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take
10213
* extra time.
103-
*
104-
* Compared to [asyncTest], it provides a smaller API for tests that don't need detailed control over execution.
105-
*
14+
**
10615
* ```
10716
* @Test
10817
* fun exampleTest() = runBlockingTest {
@@ -121,43 +30,39 @@ fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineSc
12130
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test
12231
* conditions.
12332
*
124-
* In unhandled exceptions inside coroutines will not fail the test.
33+
* Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test.
12534
*
12635
* @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches
127-
* (including coroutines suspended on await).
36+
* (including coroutines suspended on join/await).
12837
*
129-
* @param context An optional context, during [testBody] execution [DelayController.dispatchImmediately] will be set to true
38+
* @param context An optional context that MAY contain a [DelayController] and/or [TestCoroutineExceptionHandler]
13039
* @param testBody The code of the unit-test.
131-
*
132-
* @see [asyncTest]
13340
*/
13441
fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCoroutineScope.() -> Unit) {
13542
val (safeContext, dispatcher) = context.checkArguments()
13643
// smart cast dispatcher to expose interface
13744
dispatcher as DelayController
13845

139-
val oldDispatch = dispatcher.dispatchImmediately
140-
dispatcher.dispatchImmediately = true
141-
val scope = TestCoroutineScope(safeContext)
142-
try {
46+
val startingJobs = safeContext.activeJobs()
14347

144-
val deferred = scope.async {
145-
scope.testBody()
146-
}
147-
dispatcher.advanceUntilIdle()
148-
deferred.getCompletionExceptionOrNull()?.let {
149-
throw it
150-
}
151-
scope.cleanupTestCoroutines()
152-
val activeChildren = checkNotNull(safeContext[Job]).children.filter { it.isActive }.toList()
153-
if (activeChildren.isNotEmpty()) {
154-
throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}")
155-
}
156-
} finally {
157-
dispatcher.dispatchImmediately = oldDispatch
48+
val scope = TestCoroutineScope(safeContext)
49+
val deferred = scope.async {
50+
scope.testBody()
51+
}
52+
dispatcher.advanceUntilIdle()
53+
deferred.getCompletionExceptionOrNull()?.let {
54+
throw it
55+
}
56+
scope.cleanupTestCoroutines()
57+
val endingJobs = safeContext.activeJobs()
58+
if ((endingJobs - startingJobs).isNotEmpty()) {
59+
throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs")
15860
}
15961
}
16062

63+
private fun CoroutineContext.activeJobs() =
64+
checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
65+
16166
/**
16267
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope].
16368
*/
@@ -194,4 +99,16 @@ private fun CoroutineContext?.checkArguments(): Pair<CoroutineContext, Continuat
19499

195100
safeContext = safeContext + dispatcher + exceptionHandler + job
196101
return Pair(safeContext, dispatcher)
102+
}
103+
104+
/**
105+
* This method is deprecated.
106+
*
107+
* @see [cleanupTestCoroutines]
108+
*/
109+
@Deprecated("This API has been deprecated to integrate with Structured Concurrency.",
110+
ReplaceWith("scope.runBlockingTest(testBody)", "kotlinx.coroutines.test"),
111+
level = DeprecationLevel.ERROR)
112+
fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineScope.() -> Unit) {
113+
scope.runBlockingTest(testBody)
197114
}

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

Lines changed: 49 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -57,45 +57,38 @@ interface DelayController {
5757
* Call after a test case completes.
5858
*
5959
* @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended
60-
* coroutines that called await.
60+
* coroutines.
6161
*/
6262
@Throws(UncompletedCoroutinesError::class)
6363
fun cleanupTestCoroutines()
6464

6565
/**
66-
* When true, this dispatcher will perform as an immediate executor.
66+
* Run a block of code in a paused dispatcher.
6767
*
68-
* It will immediately run any tasks, which means it will auto-advance the virtual clock-time to the last pending
69-
* delay.
68+
* By pausing the dispatcher any new coroutines will not execute immediately. After block executes, the dispatcher
69+
* will resume auto-advancing.
7070
*
71-
* Test code will rarely call this method directly., Instead use a test builder like [asyncTest], [runBlockingTest] or
72-
* the convenience methods [TestCoroutineDispatcher.runBlocking] and [TestCoroutineScope.runBlocking].
73-
*
74-
* ```
75-
* @Test
76-
* fun aTest() {
77-
* val scope = TestCoroutineScope() // dispatchImmediately is false
78-
* scope.async {
79-
* // delay will be pending execution (lazy mode)
80-
* delay(1_000)
81-
* }
82-
*
83-
* scope.runBlocking {
84-
* // the pending delay will immediately execute
85-
* // dispatchImmediately is true
86-
* }
87-
*
88-
* // scope is returned to lazy mode
89-
* // dispatchImmediately is false
90-
* }
91-
* ```
71+
* This is useful when testing functions that that start a coroutine. By pausing the dispatcher assertions or
72+
* setup may be done between the time the coroutine is created and started.
73+
*/
74+
suspend fun pauseDispatcher(block: suspend () -> Unit)
75+
76+
/**
77+
* Pause the dispatcher.
9278
*
93-
* Setting this to true will immediately execute any pending tasks and advance the virtual clock-time to the last
94-
* pending delay. While true, dispatch will continue to execute immediately, auto-advancing the virtual clock-time.
79+
* When paused the dispatcher will not execute any coroutines automatically, and you must call [runCurrent], or one
80+
* of [advanceTimeBy], [advanceTimeToNextDelayed], or [advanceUntilIdle] to execute coroutines.
81+
*/
82+
fun pauseDispatcher()
83+
84+
/**
85+
* Resume the dispatcher from a paused state.
9586
*
96-
* Setting it to false will resume lazy execution.
87+
* Resumed dispatchers will automatically progress through all coroutines scheduled at the current time. To advance
88+
* time and execute coroutines scheduled in the future use one of [advanceTimeBy], [advanceTimeToNextDelayed],
89+
* or [advanceUntilIdle].
9790
*/
98-
var dispatchImmediately: Boolean
91+
fun resumeDispatcher()
9992

10093
@Deprecated("This API has been deprecated to integrate with Structured Concurrency.",
10194
ReplaceWith("if (targetTime > currentTime(unit)) { advanceTimeBy(targetTime - currentTime(unit), unit) }",
@@ -122,23 +115,24 @@ interface DelayController {
122115
class UncompletedCoroutinesError(message: String, cause: Throwable? = null): AssertionError(message, cause)
123116

124117
/**
125-
* [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines.
118+
* [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines.
119+
*
120+
* By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run immediately will
121+
* be immediately executed. If they were scheduled with a delay, the virtual clock-time must be advanced via one of the
122+
* methods on [DelayController]
126123
*
127-
* By default, [TestCoroutineDispatcher] will be lazy. That means any coroutines started via [launch] or [async] will
124+
* When swiched to lazy execution any coroutines started via [launch] or [async] will
128125
* not execute until a call to [DelayController.runCurrent] or the virtual clock-time has been advanced via one of the
129126
* methods on [DelayController].
130127
*
131-
* When switched to immediate mode, any tasks will be immediately executed. If they were scheduled with a delay,
132-
* the virtual clock-time will auto-advance to the last submitted delay.
133-
*
134128
* @see DelayController
135129
*/
136130
class TestCoroutineDispatcher:
137131
CoroutineDispatcher(),
138132
Delay,
139133
DelayController {
140134

141-
override var dispatchImmediately = false
135+
private var dispatchImmediately = true
142136
set(value) {
143137
field = value
144138
if (value) {
@@ -166,9 +160,6 @@ class TestCoroutineDispatcher:
166160

167161
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
168162
postDelayed(CancellableContinuationRunnable(continuation) { resumeUndispatched(Unit) }, timeMillis)
169-
if (dispatchImmediately) {
170-
// advanceTimeBy(timeMillis, TimeUnit.MILLISECONDS)
171-
}
172163
}
173164

174165
override fun invokeOnTimeout(timeMillis: Long, block: Runnable): DisposableHandle {
@@ -180,15 +171,6 @@ class TestCoroutineDispatcher:
180171
}
181172
}
182173

183-
// override fun processNextEvent(): Long {
184-
// val current = queue.peek()
185-
// if (current != null) {
186-
// // Automatically advance time for EventLoop callbacks
187-
// triggerActions(current.time)
188-
// }
189-
// return if (queue.isEmpty) Long.MAX_VALUE else 0L
190-
// }
191-
192174
override fun toString(): String = "TestCoroutineDispatcher[time=$time ns]"
193175

194176
private fun post(block: Runnable) =
@@ -247,6 +229,26 @@ class TestCoroutineDispatcher:
247229
return time - oldTime
248230
}
249231

232+
override fun runCurrent() = triggerActions(time)
233+
234+
override suspend fun pauseDispatcher(block: suspend () -> Unit) {
235+
val previous = dispatchImmediately
236+
dispatchImmediately = false
237+
try {
238+
block()
239+
} finally {
240+
dispatchImmediately = previous
241+
}
242+
}
243+
244+
override fun pauseDispatcher() {
245+
dispatchImmediately = false
246+
}
247+
248+
override fun resumeDispatcher() {
249+
dispatchImmediately = true
250+
}
251+
250252
override fun cleanupTestCoroutines() {
251253
// process any pending cancellations or completions, but don't advance time
252254
triggerActions(time)
@@ -266,8 +268,6 @@ class TestCoroutineDispatcher:
266268
" completed or cancelled by your test.")
267269
}
268270
}
269-
270-
override fun runCurrent() = triggerActions(time)
271271
}
272272

273273

0 commit comments

Comments
 (0)