Skip to content

Commit 888d7a0

Browse files
committed
Implement 'runTest' that waits for asynchronous completion (#2978)
Implement a multiplatform runTest as an initial implementation of #1996. Fixes #1204 Fixes #1222 Fixes #1395 Fixes #1881 Fixes #1910 Fixes #1772
1 parent b8c9965 commit 888d7a0

21 files changed

+817
-63
lines changed

kotlinx-coroutines-test/api/kotlinx-coroutines-test.api

+6
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ public final class kotlinx/coroutines/test/TestBuildersKt {
1414
public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineDispatcher;Lkotlin/jvm/functions/Function2;)V
1515
public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V
1616
public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
17+
public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
18+
public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V
19+
public static final fun runTest (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;)V
20+
public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
21+
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
22+
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
1723
}
1824

1925
public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController {

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

+253-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.selects.*
89
import kotlin.coroutines.*
910

1011
/**
@@ -41,10 +42,10 @@ import kotlin.coroutines.*
4142
* then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively.
4243
* @param testBody The code of the unit-test.
4344
*/
44-
@ExperimentalCoroutinesApi
45+
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
4546
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
46-
val scope = TestCoroutineScope(context)
47-
val scheduler = scope.coroutineContext[TestCoroutineScheduler]!!
47+
val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
48+
val scheduler = scope.testScheduler
4849
val deferred = scope.async {
4950
scope.testBody()
5051
}
@@ -59,13 +60,260 @@ public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, te
5960
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope].
6061
*/
6162
// todo: need documentation on how this extension is supposed to be used
62-
@ExperimentalCoroutinesApi
63+
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
6364
public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
6465
runBlockingTest(coroutineContext, block)
6566

6667
/**
6768
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher].
6869
*/
69-
@ExperimentalCoroutinesApi
70+
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
7071
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
7172
runBlockingTest(this, block)
73+
74+
/**
75+
* A test result.
76+
*
77+
* * On JVM and Native, this resolves to [Unit], representing the fact that tests are run in a blocking manner on these
78+
* platforms: a call to a function returning a [TestResult] will simply execute the test inside it.
79+
* * On JS, this is a `Promise`, which reflects the fact that the test-running function does not wait for a test to
80+
* finish. The JS test frameworks typically support returning `Promise` from a test and will correctly handle it.
81+
*
82+
* Because of the behavior on JS, extra care must be taken when writing multiplatform tests to avoid losing test errors:
83+
* * Don't do anything after running the functions returning a [TestResult]. On JS, this code will execute *before* the
84+
* test finishes.
85+
* * As a corollary, don't run functions returning a [TestResult] more than once per test. The only valid thing to do
86+
* with a [TestResult] is to immediately `return` it from a test.
87+
* * Don't nest functions returning a [TestResult].
88+
*/
89+
@Suppress("NO_ACTUAL_FOR_EXPECT")
90+
@ExperimentalCoroutinesApi
91+
public expect class TestResult
92+
93+
/**
94+
* Executes [testBody] as a test in a new coroutine, returning [TestResult].
95+
*
96+
* On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs
97+
* will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary.
98+
* On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior.
99+
*
100+
* ```
101+
* @Test
102+
* fun exampleTest() = runTest {
103+
* val deferred = async {
104+
* delay(1_000)
105+
* async {
106+
* delay(1_000)
107+
* }.await()
108+
* }
109+
*
110+
* deferred.await() // result available immediately
111+
* }
112+
* ```
113+
*
114+
* The platform difference entails that, in order to use this function correctly in common code, one must always
115+
* immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See
116+
* [TestResult] for details on this.
117+
*
118+
* The test is run in a single thread, unless other [ContinuationInterceptor] are used for child coroutines.
119+
* Because of this, child coroutines are not executed in parallel to the test body.
120+
* In order to for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the
121+
* test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]).
122+
*
123+
* ```
124+
* @Test
125+
* fun exampleWaitingForAsyncTasks1() = runTest {
126+
* // 1
127+
* val job = launch {
128+
* // 3
129+
* }
130+
* // 2
131+
* job.join() // the main test coroutine suspends here, so the child is executed
132+
* // 4
133+
* }
134+
*
135+
* @Test
136+
* fun exampleWaitingForAsyncTasks2() = runTest {
137+
* // 1
138+
* launch {
139+
* // 3
140+
* }
141+
* // 2
142+
* advanceUntilIdle() // runs the tasks until their queue is empty
143+
* // 4
144+
* }
145+
* ```
146+
*
147+
* ### Task scheduling
148+
*
149+
* Delay-skipping is achieved by using virtual time. [TestCoroutineScheduler] is automatically created (if it wasn't
150+
* passed in some way in [context]) and can be used to control the virtual time, advancing it, running the tasks
151+
* scheduled at a specific time etc. Some convenience methods are available on [TestCoroutineScope] to control the
152+
* scheduler.
153+
*
154+
* Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped:
155+
* ```
156+
* @Test
157+
* fun exampleTest() = runTest {
158+
* val elapsed = TimeSource.Monotonic.measureTime {
159+
* val deferred = async {
160+
* delay(1_000) // will be skipped
161+
* withContext(Dispatchers.Default) {
162+
* delay(5_000) // Dispatchers.Default doesn't know about TestCoroutineScheduler
163+
* }
164+
* }
165+
* deferred.await()
166+
* }
167+
* println(elapsed) // about five seconds
168+
* }
169+
* ```
170+
*
171+
* ### Failures
172+
*
173+
* #### Test body failures
174+
*
175+
* If the created coroutine completes with an exception, then this exception will be thrown at the end of the test.
176+
*
177+
* #### Reported exceptions
178+
*
179+
* Unhandled exceptions will be thrown at the end of the test.
180+
* If the uncaught exceptions happen after the test finishes, the error is propagated in a platform-specific manner.
181+
* If the test coroutine completes with an exception, the unhandled exceptions are suppressed by it.
182+
*
183+
* #### Uncompleted coroutines
184+
*
185+
* This method requires that, after the test coroutine has completed, all the other coroutines launched inside
186+
* [testBody] also complete, or are cancelled.
187+
* Otherwise, the test will be failed (which, on JVM and Native, means that [runTest] itself will throw
188+
* [AssertionError], whereas on JS, the `Promise` will fail with it).
189+
*
190+
* In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
191+
* to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
192+
* for [dispatchTimeoutMs] milliseconds (by default, 60 seconds) from the moment when [TestCoroutineScheduler] becomes
193+
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
194+
* task during that time, the timer gets reset.
195+
*
196+
* ### Configuration
197+
*
198+
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
199+
* scope created for the test, [context] also can be used to change how the test is executed.
200+
* See the [TestCoroutineScope] constructor documentation for details.
201+
*
202+
* @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for
203+
* details.
204+
*/
205+
@ExperimentalCoroutinesApi
206+
public fun runTest(
207+
context: CoroutineContext = EmptyCoroutineContext,
208+
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
209+
testBody: suspend TestCoroutineScope.() -> Unit
210+
): TestResult {
211+
if (context[RunningInRunTest] != null)
212+
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
213+
val testScope = TestBodyCoroutine<Unit>(TestCoroutineScope(context + RunningInRunTest))
214+
val scheduler = testScope.testScheduler
215+
return createTestResult {
216+
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
217+
* [TestCoroutineDispatcher], because the event loop is not started. */
218+
testScope.start(CoroutineStart.DEFAULT, testScope) {
219+
testBody()
220+
}
221+
var completed = false
222+
while (!completed) {
223+
scheduler.advanceUntilIdle()
224+
if (testScope.isCompleted) {
225+
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
226+
non-trivial dispatches. */
227+
completed = true
228+
continue
229+
}
230+
select<Unit> {
231+
testScope.onJoin {
232+
completed = true
233+
}
234+
scheduler.onDispatchEvent {
235+
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
236+
}
237+
onTimeout(dispatchTimeoutMs) {
238+
try {
239+
testScope.cleanupTestCoroutines()
240+
} catch (e: UncompletedCoroutinesError) {
241+
// we expect these and will instead throw a more informative exception just below.
242+
}
243+
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
244+
}
245+
}
246+
}
247+
testScope.getCompletionExceptionOrNull()?.let {
248+
try {
249+
testScope.cleanupTestCoroutines()
250+
} catch (e: UncompletedCoroutinesError) {
251+
// it's normal that some jobs are not completed if the test body has failed, won't clutter the output
252+
} catch (e: Throwable) {
253+
it.addSuppressed(e)
254+
}
255+
throw it
256+
}
257+
testScope.cleanupTestCoroutines()
258+
}
259+
}
260+
261+
/**
262+
* Runs [testProcedure], creating a [TestResult].
263+
*/
264+
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
265+
internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult
266+
267+
/**
268+
* Runs a test in a [TestCoroutineScope] based on this one.
269+
*
270+
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run
271+
* [block] will be different from this one, but will use its [Job] as a parent.
272+
*
273+
* Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned
274+
* immediately from the test body. See the docs for [TestResult] for details.
275+
*/
276+
@ExperimentalCoroutinesApi
277+
public fun TestCoroutineScope.runTest(
278+
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
279+
block: suspend TestCoroutineScope.() -> Unit
280+
): TestResult =
281+
runTest(coroutineContext, dispatchTimeoutMs, block)
282+
283+
/**
284+
* Run a test using this [TestDispatcher].
285+
*
286+
* A convenience function that calls [runTest] with the given arguments.
287+
*
288+
* Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned
289+
* immediately from the test body. See the docs for [TestResult] for details.
290+
*/
291+
@ExperimentalCoroutinesApi
292+
public fun TestDispatcher.runTest(
293+
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
294+
block: suspend TestCoroutineScope.() -> Unit
295+
): TestResult =
296+
runTest(this, dispatchTimeoutMs, block)
297+
298+
/** A coroutine context element indicating that the coroutine is running inside `runTest`. */
299+
private object RunningInRunTest: CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
300+
override val key: CoroutineContext.Key<*>
301+
get() = this
302+
303+
override fun toString(): String = "RunningInRunTest"
304+
}
305+
306+
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
307+
* a [TestCoroutineScheduler]. */
308+
private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
309+
310+
private class TestBodyCoroutine<T>(
311+
private val testScope: TestCoroutineScope,
312+
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope,
313+
UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor
314+
{
315+
override val testScheduler get() = testScope.testScheduler
316+
317+
override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines()
318+
319+
}

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

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin
4444
override fun dispatch(context: CoroutineContext, block: Runnable) {
4545
checkSchedulerInContext(scheduler, context)
4646
if (dispatchImmediately) {
47+
scheduler.sendDispatchEvent()
4748
block.run()
4849
} else {
4950
post(block)

kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt

+38-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ package kotlinx.coroutines.test
66

77
import kotlinx.atomicfu.*
88
import kotlinx.coroutines.*
9+
import kotlinx.coroutines.channels.*
10+
import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
911
import kotlinx.coroutines.internal.*
12+
import kotlinx.coroutines.selects.*
1013
import kotlin.coroutines.*
1114
import kotlin.jvm.*
1215

@@ -46,6 +49,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
4649
get() = synchronized(lock) { field }
4750
private set
4851

52+
/** A channel for notifying about the fact that a dispatch recently happened. */
53+
private val dispatchEvents: Channel<Unit> = Channel(CONFLATED)
54+
4955
/**
5056
* Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds
5157
* later via [TestDispatcher.processEvent], which will be called with the provided [marker] object.
@@ -64,6 +70,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
6470
val time = addClamping(currentTime, timeDeltaMillis)
6571
val event = TestDispatchEvent(dispatcher, count, time, marker as Any) { isCancelled(marker) }
6672
events.addLast(event)
73+
/** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's
74+
* actually anything in the event queue. */
75+
sendDispatchEvent()
6776
DisposableHandle {
6877
synchronized(lock) {
6978
events.remove(event)
@@ -72,6 +81,21 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
7281
}
7382
}
7483

84+
/**
85+
* Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening.
86+
*/
87+
private fun tryRunNextTask(): Boolean {
88+
val event = synchronized(lock) {
89+
val event = events.removeFirstOrNull() ?: return false
90+
if (currentTime > event.time)
91+
currentTimeAheadOfEvents()
92+
currentTime = event.time
93+
event
94+
}
95+
event.dispatcher.processEvent(event.time, event.marker)
96+
return true
97+
}
98+
7599
/**
76100
* Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more
77101
* tasks associated with the dispatchers linked to this scheduler.
@@ -82,15 +106,8 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
82106
*/
83107
@ExperimentalCoroutinesApi
84108
public fun advanceUntilIdle() {
85-
while (!events.isEmpty) {
86-
val event = synchronized(lock) {
87-
val event = events.removeFirstOrNull() ?: return
88-
if (currentTime > event.time)
89-
currentTimeAheadOfEvents()
90-
currentTime = event.time
91-
event
92-
}
93-
event.dispatcher.processEvent(event.time, event.marker)
109+
while (!synchronized(lock) { events.isEmpty }) {
110+
tryRunNextTask()
94111
}
95112
}
96113

@@ -162,6 +179,18 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
162179
return presentEvents.all { it.isCancelled() }
163180
}
164181
}
182+
183+
/**
184+
* Notifies this scheduler about a dispatch event.
185+
*/
186+
internal fun sendDispatchEvent() {
187+
dispatchEvents.trySend(Unit)
188+
}
189+
190+
/**
191+
* Consumes the knowledge that a dispatch event happened recently.
192+
*/
193+
internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
165194
}
166195

167196
// Some error-throwing functions for pretty stack traces

0 commit comments

Comments
 (0)