Skip to content

Commit eec936f

Browse files
Implement TestScope (#3015)
Co-authored-by: Vsevolod Tolstopyatov <[email protected]>
1 parent 3e91bcf commit eec936f

16 files changed

+1208
-244
lines changed

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

+20-2
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ public final class kotlinx/coroutines/test/TestBuildersKt {
1313
public static final fun runBlockingTest (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V
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
16+
public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestScope;Lkotlin/jvm/functions/Function2;)V
1617
public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
18+
public static final fun runBlockingTestOnTestScope (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V
19+
public static synthetic fun runBlockingTestOnTestScope$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
1720
public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
1821
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
22+
public static final fun runTest (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
2023
public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2124
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
25+
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
26+
public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
27+
public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2328
}
2429

2530
public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController {
@@ -98,6 +103,19 @@ public final class kotlinx/coroutines/test/TestDispatchers {
98103
public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V
99104
}
100105

106+
public abstract interface class kotlinx/coroutines/test/TestScope : kotlinx/coroutines/CoroutineScope {
107+
public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
108+
}
109+
110+
public final class kotlinx/coroutines/test/TestScopeKt {
111+
public static final fun TestScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestScope;
112+
public static synthetic fun TestScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestScope;
113+
public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestScope;J)V
114+
public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestScope;)V
115+
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestScope;)J
116+
public static final fun runCurrent (Lkotlinx/coroutines/test/TestScope;)V
117+
}
118+
101119
public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor {
102120
public abstract fun cleanupTestCoroutines ()V
103121
public abstract fun getUncaughtExceptions ()Ljava/util/List;
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,15 @@
11
/*
22
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
4+
@file:JvmName("TestBuildersKt")
5+
@file:JvmMultifileClass
46

57
package kotlinx.coroutines.test
68

79
import kotlinx.coroutines.*
810
import kotlinx.coroutines.selects.*
911
import kotlin.coroutines.*
10-
11-
/**
12-
* Executes a [testBody] inside an immediate execution dispatcher.
13-
*
14-
* This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks.
15-
* You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take
16-
* extra time.
17-
*
18-
* ```
19-
* @Test
20-
* fun exampleTest() = runBlockingTest {
21-
* val deferred = async {
22-
* delay(1_000)
23-
* async {
24-
* delay(1_000)
25-
* }.await()
26-
* }
27-
*
28-
* deferred.await() // result available immediately
29-
* }
30-
*
31-
* ```
32-
*
33-
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test
34-
* conditions.
35-
*
36-
* Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test.
37-
*
38-
* @throws AssertionError If the [testBody] does not complete (or cancel) all coroutines that it launches
39-
* (including coroutines suspended on join/await).
40-
*
41-
* @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler],
42-
* then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively.
43-
* @param testBody The code of the unit-test.
44-
*/
45-
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
46-
public fun runBlockingTest(
47-
context: CoroutineContext = EmptyCoroutineContext,
48-
testBody: suspend TestCoroutineScope.() -> Unit
49-
) {
50-
val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
51-
val scheduler = scope.testScheduler
52-
val deferred = scope.async {
53-
scope.testBody()
54-
}
55-
scheduler.advanceUntilIdle()
56-
deferred.getCompletionExceptionOrNull()?.let {
57-
throw it
58-
}
59-
scope.cleanupTestCoroutines()
60-
}
61-
62-
/**
63-
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope].
64-
*/
65-
// todo: need documentation on how this extension is supposed to be used
66-
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
67-
public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
68-
runBlockingTest(coroutineContext, block)
69-
70-
/**
71-
* Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher].
72-
*/
73-
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
74-
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
75-
runBlockingTest(this, block)
12+
import kotlin.jvm.*
7613

7714
/**
7815
* A test result.
@@ -96,7 +33,7 @@ public expect class TestResult
9633
/**
9734
* Executes [testBody] as a test in a new coroutine, returning [TestResult].
9835
*
99-
* On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs
36+
* On JVM and Native, this function behaves similarly to `runBlocking`, with the difference that the code that it runs
10037
* will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary.
10138
* On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior.
10239
*
@@ -154,7 +91,7 @@ public expect class TestResult
15491
* then its [TestCoroutineScheduler] is used;
15592
* otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control
15693
* the virtual time, advancing it, running the tasks scheduled at a specific time etc.
157-
* Some convenience methods are available on [TestCoroutineScope] to control the scheduler.
94+
* Some convenience methods are available on [TestScope] to control the scheduler.
15895
*
15996
* Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped:
16097
* ```
@@ -202,105 +139,43 @@ public expect class TestResult
202139
*
203140
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
204141
* scope created for the test, [context] also can be used to change how the test is executed.
205-
* See the [createTestCoroutineScope] documentation for details.
142+
* See the [TestScope] constructor function documentation for details.
206143
*
207-
* @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details.
144+
* @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details.
208145
*/
209146
@ExperimentalCoroutinesApi
210147
public fun runTest(
211148
context: CoroutineContext = EmptyCoroutineContext,
212149
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
213-
testBody: suspend TestCoroutineScope.() -> Unit
150+
testBody: suspend TestScope.() -> Unit
214151
): TestResult {
215152
if (context[RunningInRunTest] != null)
216153
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
217-
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest))
218-
val scheduler = testScope.testScheduler
219-
return createTestResult {
220-
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
221-
* [TestCoroutineDispatcher], because the event loop is not started. */
222-
testScope.start(CoroutineStart.UNDISPATCHED, testScope) {
223-
testBody()
224-
}
225-
var completed = false
226-
while (!completed) {
227-
scheduler.advanceUntilIdle()
228-
if (testScope.isCompleted) {
229-
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
230-
non-trivial dispatches. */
231-
completed = true
232-
continue
233-
}
234-
select<Unit> {
235-
testScope.onJoin {
236-
completed = true
237-
}
238-
scheduler.onDispatchEvent {
239-
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
240-
}
241-
onTimeout(dispatchTimeoutMs) {
242-
try {
243-
testScope.cleanup()
244-
} catch (e: UncompletedCoroutinesError) {
245-
// we expect these and will instead throw a more informative exception just below.
246-
}
247-
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
248-
}
249-
}
250-
}
251-
testScope.getCompletionExceptionOrNull()?.let {
252-
try {
253-
testScope.cleanup()
254-
} catch (e: UncompletedCoroutinesError) {
255-
// it's normal that some jobs are not completed if the test body has failed, won't clutter the output
256-
} catch (e: Throwable) {
257-
it.addSuppressed(e)
258-
}
259-
throw it
260-
}
261-
testScope.cleanup()
262-
}
154+
return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs, testBody)
263155
}
264156

265157
/**
266-
* Runs [testProcedure], creating a [TestResult].
267-
*/
268-
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
269-
internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult
270-
271-
/**
272-
* Runs a test in a [TestCoroutineScope] based on this one.
273-
*
274-
* Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the
275-
* [block] will be different from this one, but will use its [Job] as a parent.
276-
*
277-
* Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned
278-
* immediately from the test body. See the docs for [TestResult] for details.
158+
* Performs [runTest] on an existing [TestScope].
279159
*/
280160
@ExperimentalCoroutinesApi
281-
public fun TestCoroutineScope.runTest(
161+
public fun TestScope.runTest(
282162
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
283-
block: suspend TestCoroutineScope.() -> Unit
284-
): TestResult =
285-
runTest(coroutineContext, dispatchTimeoutMs, block)
163+
testBody: suspend TestScope.() -> Unit
164+
): TestResult = asSpecificImplementation().let {
165+
it.enter()
166+
createTestResult {
167+
runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() }
168+
}
169+
}
286170

287171
/**
288-
* Run a test using this [TestDispatcher].
289-
*
290-
* A convenience function that calls [runTest] with the given arguments.
291-
*
292-
* Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned
293-
* immediately from the test body. See the docs for [TestResult] for details.
172+
* Runs [testProcedure], creating a [TestResult].
294173
*/
295-
@ExperimentalCoroutinesApi
296-
public fun TestDispatcher.runTest(
297-
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
298-
block: suspend TestCoroutineScope.() -> Unit
299-
): TestResult =
300-
runTest(this, dispatchTimeoutMs, block)
174+
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
175+
internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult
301176

302177
/** A coroutine context element indicating that the coroutine is running inside `runTest`. */
303-
private object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
178+
internal object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
304179
override val key: CoroutineContext.Key<*>
305180
get() = this
306181

@@ -309,24 +184,69 @@ private object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, Corout
309184

310185
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
311186
* a [TestCoroutineScheduler]. */
312-
private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
313-
314-
private class TestBodyCoroutine<T>(
315-
private val testScope: TestCoroutineScope,
316-
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {
317-
318-
override val testScheduler get() = testScope.testScheduler
187+
internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
319188

320-
@Deprecated(
321-
"This deprecation is to prevent accidentally calling `cleanupTestCoroutines` in our own code.",
322-
ReplaceWith("this.cleanup()"),
323-
DeprecationLevel.ERROR
324-
)
325-
override fun cleanupTestCoroutines() =
326-
throw UnsupportedOperationException(
327-
"Calling `cleanupTestCoroutines` inside `runTest` is prohibited: " +
328-
"it will be called at the end of the test in any case."
329-
)
189+
/**
190+
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
191+
* [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end.
192+
*
193+
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
194+
* return a list of uncaught exceptions that should be reported at the end of the test.
195+
*/
196+
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
197+
coroutine: T,
198+
dispatchTimeoutMs: Long,
199+
testBody: suspend T.() -> Unit,
200+
cleanup: () -> List<Throwable>,
201+
) {
202+
val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!!
203+
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
204+
* [TestCoroutineDispatcher], because the event loop is not started. */
205+
coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) {
206+
testBody()
207+
}
208+
var completed = false
209+
while (!completed) {
210+
scheduler.advanceUntilIdle()
211+
if (coroutine.isCompleted) {
212+
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
213+
non-trivial dispatches. */
214+
completed = true
215+
continue
216+
}
217+
select<Unit> {
218+
coroutine.onJoin {
219+
completed = true
220+
}
221+
scheduler.onDispatchEvent {
222+
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
223+
}
224+
onTimeout(dispatchTimeoutMs) {
225+
try {
226+
cleanup()
227+
} catch (e: UncompletedCoroutinesError) {
228+
// we expect these and will instead throw a more informative exception just below.
229+
emptyList()
230+
}.throwAll()
231+
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
232+
}
233+
}
234+
}
235+
coroutine.getCompletionExceptionOrNull()?.let { exception ->
236+
val exceptions = try {
237+
cleanup()
238+
} catch (e: UncompletedCoroutinesError) {
239+
// it's normal that some jobs are not completed if the test body has failed, won't clutter the output
240+
emptyList()
241+
}
242+
(listOf(exception) + exceptions).throwAll()
243+
}
244+
cleanup().throwAll()
245+
}
330246

331-
fun cleanup() = testScope.cleanupTestCoroutines()
247+
internal fun List<Throwable>.throwAll() {
248+
firstOrNull()?.apply {
249+
drop(1).forEach { addSuppressed(it) }
250+
throw this
251+
}
332252
}

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,11 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
169169
/**
170170
* Checks that the only tasks remaining in the scheduler are cancelled.
171171
*/
172-
// TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap]
173-
internal fun isIdle(): Boolean {
172+
internal fun isIdle(strict: Boolean = true): Boolean {
174173
synchronized(lock) {
174+
if (strict)
175+
return events.isEmpty
176+
// TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap]
175177
val presentEvents = mutableListOf<TestDispatchEvent<*>>()
176178
while (true) {
177179
presentEvents += events.removeFirstOrNull() ?: break

0 commit comments

Comments
 (0)