Skip to content

Commit 23d228b

Browse files
committed
Update TestCoroutineContext to support structured concurrency.
1 parent b8a559d commit 23d228b

9 files changed

+1195
-388
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
8+
/**
9+
* Executes a [testBody] in a [TestCoroutineScope] which provides detailed control over the execution of coroutines.
10+
*
11+
* This function should be used when you need detailed control over the execution of your test. For most tests consider
12+
* using [runBlockingTest].
13+
*
14+
* Code executed in a `asyncTest` will dispatch lazily. That means calling builders such as [launch] or [async] will
15+
* not execute the block immediately. You can use methods like [TestCoroutineScope.runCurrent] and
16+
* [TestCoroutineScope.advanceTimeTo] on the [TestCoroutineScope]. For a full list of execution methods see
17+
* [DelayController].
18+
*
19+
* ```
20+
* @Test
21+
* fun exampleTest() = asyncTest {
22+
* // 1: launch will execute but not run the body
23+
* launch {
24+
* // 3: the body of launch will execute in response to runCurrent [currentTime = 0ms]
25+
* delay(1_000)
26+
* // 5: After the time is advanced, delay(1_000) will return [currentTime = 1000ms]
27+
* println("Faster delays!")
28+
* }
29+
*
30+
* // 2: use runCurrent() to execute the body of launch [currentTime = 0ms]
31+
* runCurrent()
32+
*
33+
* // 4: advance the dispatcher "time" by 1_000, which will resume after the delay
34+
* advanceTimeTo(1_000)
35+
*
36+
* ```
37+
*
38+
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test
39+
* conditions.
40+
*
41+
* In addition any unhandled exceptions thrown in coroutines must be rethrown by
42+
* [TestCoroutineScope.rethrowUncaughtCoroutineException] or cleared via [TestCoroutineScope.exceptions] inside of
43+
* [testBody].
44+
*
45+
* @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches
46+
* (including coroutines suspended on await).
47+
* @throws UnhandledExceptionsError If an uncaught exception is not handled by [testBody]
48+
*
49+
* @param dispatcher An optional dispatcher, during [testBody] execution [TestCoroutineDispatcher.dispatchImmediately] will be set to false
50+
* @param testBody The code of the unit-test.
51+
*
52+
* @see [runBlockingTest]
53+
*/
54+
fun asyncTest(context: CoroutineContext? = null, testBody: TestCoroutineScope.() -> Unit) {
55+
val (safeContext, dispatcher) = context.checkArguments()
56+
// smart cast dispatcher to expose interface
57+
dispatcher as DelayController
58+
val scope = TestCoroutineScope(safeContext)
59+
60+
val oldDispatch = dispatcher.dispatchImmediately
61+
dispatcher.dispatchImmediately = false
62+
63+
try {
64+
scope.testBody()
65+
scope.cleanupTestCoroutines()
66+
67+
// check for any active child jobs after cleanup (e.g. coroutines suspended on calls to await)
68+
val job = checkNotNull(safeContext[Job]) { "Job required for asyncTest" }
69+
val activeChildren = job.children.filter { it.isActive }.toList()
70+
if (activeChildren.isNotEmpty()) {
71+
throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}")
72+
}
73+
} finally {
74+
dispatcher.dispatchImmediately = oldDispatch
75+
}
76+
}
77+
78+
/**
79+
* @see [asyncTest]
80+
*/
81+
fun TestCoroutineScope.asyncTest(testBody: TestCoroutineScope.() -> Unit) =
82+
asyncTest(coroutineContext, testBody)
83+
84+
85+
/**
86+
* Executes a [testBody] inside an immediate execution dispatcher.
87+
*
88+
* This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks.
89+
* You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take
90+
* extra time.
91+
*
92+
* Compared to [asyncTest], it provides a smaller API for tests that don't need detailed control over execution.
93+
*
94+
* ```
95+
* @Test
96+
* fun exampleTest() = runBlockingTest {
97+
* val deferred = async {
98+
* delay(1_000)
99+
* async {
100+
* delay(1_000)
101+
* }.await()
102+
* }
103+
*
104+
* deferred.await() // result available immediately
105+
* }
106+
*
107+
* ```
108+
*
109+
* [runBlockingTest] will allow tests to finish successfully while started coroutines are unfinished. In addition unhandled
110+
* exceptions inside coroutines will not fail the test.
111+
*
112+
* @param dispatcher An optional dispatcher, during [testBody] execution [TestCoroutineDispatcher.dispatchImmediately] will be set to true
113+
* @param testBody The code of the unit-test.
114+
*
115+
* @see [asyncTest]
116+
*/
117+
fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend CoroutineScope.() -> Unit) {
118+
val (safeContext, dispatcher) = context.checkArguments()
119+
// smart cast dispatcher to expose interface
120+
dispatcher as DelayController
121+
122+
val oldDispatch = dispatcher.dispatchImmediately
123+
dispatcher.dispatchImmediately = true
124+
val scope = TestCoroutineScope(safeContext)
125+
try {
126+
runBlocking(scope.coroutineContext) {
127+
scope.testBody()
128+
scope.cleanupTestCoroutines()
129+
}
130+
} finally {
131+
dispatcher.dispatchImmediately = oldDispatch
132+
}
133+
}
134+
135+
/**
136+
* Convenience method for calling runBlocking on an existing [TestCoroutineScope].
137+
*
138+
* [block] will be executed in immediate execution mode, similar to [runBlockingTest].
139+
*/
140+
fun <T> TestCoroutineScope.runBlocking(block: suspend CoroutineScope.() -> T): T {
141+
val oldDispatch = dispatchImmediately
142+
dispatchImmediately = true
143+
try {
144+
return runBlocking(coroutineContext, block)
145+
} finally {
146+
dispatchImmediately = oldDispatch
147+
}
148+
}
149+
150+
/**
151+
* Convenience method for calling runBlocking on an existing [TestCoroutineDispatcher].
152+
*
153+
* [block] will be executed in immediate execution mode, similar to [runBlockingTest].
154+
*/
155+
fun <T> TestCoroutineDispatcher.runBlocking(block: suspend CoroutineScope.() -> T): T {
156+
val oldDispatch = dispatchImmediately
157+
dispatchImmediately = true
158+
try {
159+
return runBlocking(this, block)
160+
} finally {
161+
dispatchImmediately = oldDispatch
162+
}
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+
if (this !is DelayController) {
171+
throw IllegalArgumentException("Dispatcher must implement DelayController")
172+
}
173+
}
174+
this ?: TestCoroutineDispatcher()
175+
}
176+
177+
val exceptionHandler = safeContext[CoroutineExceptionHandler].run {
178+
this?.let {
179+
if (this !is ExceptionCaptor) {
180+
throw IllegalArgumentException("coroutineExceptionHandler must implement ExceptionCaptor")
181+
}
182+
}
183+
this ?: TestCoroutineExceptionHandler()
184+
}
185+
186+
val job = safeContext[Job] ?: SupervisorJob()
187+
188+
safeContext = safeContext + dispatcher + exceptionHandler + job
189+
return Pair(safeContext, dispatcher)
190+
}

0 commit comments

Comments
 (0)