Skip to content

Commit 86d3488

Browse files
committed
Update coroutine test dispatcher to use structured concurrency.
1 parent e478715 commit 86d3488

10 files changed

+1456
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package kotlinx.coroutines.test
2+
3+
import kotlinx.coroutines.*
4+
import java.util.concurrent.TimeoutException
5+
import kotlin.coroutines.ContinuationInterceptor
6+
import kotlin.coroutines.CoroutineContext
7+
import kotlin.coroutines.coroutineContext
8+
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+
/**
87+
* Executes a [testBody] inside an immediate execution dispatcher.
88+
*
89+
* This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks.
90+
* You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take
91+
* extra time.
92+
*
93+
* Compared to [asyncTest], it provides a smaller API for tests that don't need detailed control over execution.
94+
*
95+
* ```
96+
* @Test
97+
* fun exampleTest() = runBlockingTest {
98+
* val deferred = async {
99+
* delay(1_000)
100+
* async {
101+
* delay(1_000)
102+
* }.await()
103+
* }
104+
*
105+
* deferred.await() // result available immediately
106+
* }
107+
*
108+
* ```
109+
*
110+
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test
111+
* conditions.
112+
*
113+
* In unhandled exceptions inside coroutines will not fail the test.
114+
*
115+
* @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches
116+
* (including coroutines suspended on await).
117+
*
118+
* @param context An optional context, during [testBody] execution [DelayController.dispatchImmediately] will be set to true
119+
* @param testBody The code of the unit-test.
120+
*
121+
* @see [asyncTest]
122+
*/
123+
fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCoroutineScope.() -> Unit) {
124+
val (safeContext, dispatcher) = context.checkArguments()
125+
// smart cast dispatcher to expose interface
126+
dispatcher as DelayController
127+
128+
val oldDispatch = dispatcher.dispatchImmediately
129+
dispatcher.dispatchImmediately = true
130+
val scope = TestCoroutineScope(safeContext)
131+
try {
132+
133+
val deferred = scope.async {
134+
scope.testBody()
135+
}
136+
dispatcher.advanceUntilIdle()
137+
deferred.getCompletionExceptionOrNull()?.let {
138+
throw it
139+
}
140+
scope.cleanupTestCoroutines()
141+
val activeChildren = checkNotNull(safeContext[Job]).children.filter { it.isActive }.toList()
142+
if (activeChildren.isNotEmpty()) {
143+
throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}")
144+
}
145+
} finally {
146+
dispatcher.dispatchImmediately = oldDispatch
147+
}
148+
}
149+
150+
/**
151+
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope].
152+
*/
153+
fun TestCoroutineScope.runBlockingTest(block: suspend CoroutineScope.() -> Unit) {
154+
runBlockingTest(coroutineContext, block)
155+
}
156+
157+
/**
158+
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher].
159+
*
160+
*/
161+
fun TestCoroutineDispatcher.runBlockingTest(block: suspend CoroutineScope.() -> Unit) {
162+
runBlockingTest(this, block)
163+
}
164+
165+
private fun CoroutineContext?.checkArguments(): Pair<CoroutineContext, ContinuationInterceptor> {
166+
var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher()
167+
168+
val dispatcher = safeContext[ContinuationInterceptor].run {
169+
this?.let {
170+
require(this is DelayController) { "Dispatcher must implement DelayController" }
171+
}
172+
this ?: TestCoroutineDispatcher()
173+
}
174+
175+
val exceptionHandler = safeContext[CoroutineExceptionHandler].run {
176+
this?.let {
177+
require(this is ExceptionCaptor) { "coroutineExceptionHandler must implement ExceptionCaptor" }
178+
}
179+
this ?: TestCoroutineExceptionHandler()
180+
}
181+
182+
val job = safeContext[Job] ?: SupervisorJob()
183+
184+
safeContext = safeContext + dispatcher + exceptionHandler + job
185+
return Pair(safeContext, dispatcher)
186+
}

0 commit comments

Comments
 (0)