Skip to content

Commit acf4830

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

9 files changed

+1228
-388
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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+
* @see [asyncTest]
86+
*/
87+
fun TestCoroutineDispatcher.asyncTest(testBody: TestCoroutineScope.() -> Unit) =
88+
asyncTest(this, testBody)
89+
90+
/**
91+
* Executes a [testBody] inside an immediate execution dispatcher.
92+
*
93+
* This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks.
94+
* You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take
95+
* extra time.
96+
*
97+
* Compared to [asyncTest], it provides a smaller API for tests that don't need detailed control over execution.
98+
*
99+
* ```
100+
* @Test
101+
* fun exampleTest() = runBlockingTest {
102+
* val deferred = async {
103+
* delay(1_000)
104+
* async {
105+
* delay(1_000)
106+
* }.await()
107+
* }
108+
*
109+
* deferred.await() // result available immediately
110+
* }
111+
*
112+
* ```
113+
*
114+
* [runBlockingTest] will allow tests to finish successfully while started coroutines are unfinished. In addition unhandled
115+
* exceptions inside coroutines will not fail the test.
116+
*
117+
* @param dispatcher An optional dispatcher, during [testBody] execution [TestCoroutineDispatcher.dispatchImmediately] will be set to true
118+
* @param testBody The code of the unit-test.
119+
*
120+
* @see [asyncTest]
121+
*/
122+
fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend CoroutineScope.() -> Unit) {
123+
val (safeContext, dispatcher) = context.checkArguments()
124+
// smart cast dispatcher to expose interface
125+
dispatcher as DelayController
126+
127+
val oldDispatch = dispatcher.dispatchImmediately
128+
dispatcher.dispatchImmediately = true
129+
val scope = TestCoroutineScope(safeContext)
130+
try {
131+
runBlocking(scope.coroutineContext) {
132+
scope.testBody()
133+
scope.cleanupTestCoroutines()
134+
}
135+
} finally {
136+
dispatcher.dispatchImmediately = oldDispatch
137+
}
138+
}
139+
140+
/**
141+
* Convenience method for calling runBlocking on an existing [TestCoroutineScope].
142+
*
143+
* [block] will be executed in immediate execution mode, similar to [runBlockingTest].
144+
*/
145+
fun <T> TestCoroutineScope.runBlocking(block: suspend CoroutineScope.() -> T): T {
146+
val oldDispatch = dispatchImmediately
147+
dispatchImmediately = true
148+
try {
149+
return runBlocking(coroutineContext, block)
150+
} finally {
151+
dispatchImmediately = oldDispatch
152+
}
153+
}
154+
155+
/**
156+
* Convenience method for calling runBlocking on an existing [TestCoroutineDispatcher].
157+
*
158+
* [block] will be executed in immediate execution mode, similar to [runBlockingTest].
159+
*/
160+
fun <T> TestCoroutineDispatcher.runBlocking(block: suspend CoroutineScope.() -> T): T {
161+
val oldDispatch = dispatchImmediately
162+
dispatchImmediately = true
163+
try {
164+
return runBlocking(this, block)
165+
} finally {
166+
dispatchImmediately = oldDispatch
167+
}
168+
}
169+
170+
private fun CoroutineContext?.checkArguments(): Pair<CoroutineContext, ContinuationInterceptor> {
171+
var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher()
172+
173+
val dispatcher = safeContext[ContinuationInterceptor].run {
174+
this?.let {
175+
if (this !is DelayController) {
176+
throw IllegalArgumentException("Dispatcher must implement DelayController")
177+
}
178+
}
179+
this ?: TestCoroutineDispatcher()
180+
}
181+
182+
val exceptionHandler = safeContext[CoroutineExceptionHandler].run {
183+
this?.let {
184+
if (this !is ExceptionCaptor) {
185+
throw IllegalArgumentException("coroutineExceptionHandler must implement ExceptionCaptor")
186+
}
187+
}
188+
this ?: TestCoroutineExceptionHandler()
189+
}
190+
191+
val job = safeContext[Job] ?: SupervisorJob()
192+
193+
safeContext = safeContext + dispatcher + exceptionHandler + job
194+
return Pair(safeContext, dispatcher)
195+
}

0 commit comments

Comments
 (0)