Skip to content

Commit 0df38d9

Browse files
committed
Implement 'runTest' that waits for asynchronous completion
1 parent f90ba4c commit 0df38d9

File tree

10 files changed

+268
-1
lines changed

10 files changed

+268
-1
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ 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 synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
1719
}
1820

1921
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

+139-1
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
/**
@@ -42,9 +43,10 @@ import kotlin.coroutines.*
4243
* @param testBody The code of the unit-test.
4344
*/
4445
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
46+
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
4547
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
4648
val scope = TestCoroutineScope(context)
47-
val scheduler = scope.coroutineContext[TestCoroutineScheduler]!!
49+
val scheduler = scope.testScheduler
4850
val deferred = scope.async {
4951
scope.testBody()
5052
}
@@ -69,3 +71,139 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.
6971
@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
7072
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
7173
runBlockingTest(this, block)
74+
75+
/**
76+
* A test result.
77+
*
78+
* * On JVM and Native, this resolves to [Unit], representing the fact that tests are run in a blocking manner on these
79+
* platforms: a call to a function returning a [TestResult] will simply execute the test inside it.
80+
* * On JS, this is a `Promise`, which reflects the fact that the test-running function does not wait for a test to
81+
* finish. The JS test frameworks typically support returning `Promise` from a test and will correctly handle it.
82+
*
83+
* Because of the behavior on JS, extra care must be taken when writing multiplatform tests to avoid losing test errors:
84+
* * Don't do anything after running the functions returning a [TestResult]. On JS, this code will execute *before* the
85+
* test finishes.
86+
* * As a corollary, don't run functions returning a [TestResult] more than once per test. The only valid thing to do
87+
* with a [TestResult] is to immediately `return` it from a test.
88+
* * Don't nest functions returning a [TestResult].
89+
*/
90+
@Suppress("NO_ACTUAL_FOR_EXPECT")
91+
public expect class TestResult
92+
93+
/**
94+
* Executes [testBody] as a test, 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+
* ### Delay-skipping
119+
*
120+
* Delay-skipping is achieved by using virtual time. [TestCoroutineScheduler] is automatically created (if it wasn't
121+
* passed in some way in [context]) and can be used to control the virtual time, advancing it, running the tasks
122+
* scheduled at a specific time etc. Some convenience methods are available on [TestCoroutineScope] to control the
123+
* scheduler.
124+
*
125+
* Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped:
126+
* ```
127+
* @Test
128+
* fun exampleTest() = runTest {
129+
* val elapsed = TimeSource.Monotonic.measureTime {
130+
* val deferred = async {
131+
* delay(1_000) // will be skipped
132+
* withContext(Dispatchers.Default) {
133+
* delay(5_000) // Dispatchers.Default don't know about TestCoroutineScheduler
134+
* }
135+
* }
136+
* deferred.await()
137+
* }
138+
* println(elapsed) // about five seconds
139+
* }
140+
* ```
141+
*
142+
* ### Failures
143+
*
144+
* This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test
145+
* will be failed (which, on JVM and Native, means that [runTest] itself will throw [UncompletedCoroutinesError],
146+
* whereas on JS, the `Promise` will fail with it).
147+
*
148+
* In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
149+
* to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
150+
* for [dispatchTimeoutMs] milliseconds (by default, 10 seconds) from the moment when [TestCoroutineScheduler] becomes
151+
* idle before throwing [UncompletedCoroutinesError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
152+
* task during that time, the timer gets reset.
153+
*
154+
* Unhandled exceptions thrown by coroutines in the test will be rethrown at the end of the test.
155+
*
156+
* ### Configuration
157+
*
158+
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
159+
* scope created for the test, [context] also can be used to change how the test is executed.
160+
* See the [TestCoroutineScope] constructor documentation for details.
161+
*
162+
* @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for
163+
* details.
164+
*/
165+
public fun runTest(
166+
context: CoroutineContext = EmptyCoroutineContext,
167+
dispatchTimeoutMs: Long = 10_000,
168+
testBody: suspend TestCoroutineScope.() -> Unit
169+
): TestResult = createTestResult {
170+
val testScope = TestCoroutineScope(context)
171+
val scheduler = testScope.testScheduler
172+
val deferred = testScope.async {
173+
testScope.testBody()
174+
}
175+
var completed = false
176+
while (!completed) {
177+
scheduler.advanceUntilIdle()
178+
if (deferred.isCompleted) {
179+
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
180+
non-trivial dispatches. */
181+
completed = true
182+
continue
183+
}
184+
try {
185+
withTimeout(dispatchTimeoutMs) {
186+
select<Unit> {
187+
deferred.onAwait {
188+
completed = true
189+
}
190+
scheduler.onDispatchEvent {
191+
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
192+
}
193+
}
194+
}
195+
} catch (e: TimeoutCancellationException) {
196+
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
197+
}
198+
}
199+
deferred.getCompletionExceptionOrNull()?.let {
200+
throw it
201+
}
202+
testScope.cleanupTestCoroutines()
203+
}
204+
205+
/**
206+
* Runs [testProcedure], creating a [TestResult].
207+
*/
208+
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
209+
internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult

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

+19
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

@@ -43,6 +46,9 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti
4346
public var currentTime: Long = 0
4447
private set
4548

49+
/** A channel for notifying about the fact that a dispatch recently happened. */
50+
private val dispatchEvents: Channel<Unit> = Channel(CONFLATED)
51+
4652
/**
4753
* Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds later
4854
* via [TestDispatcher.processEvent], which will be called with the provided [marker] object.
@@ -56,6 +62,7 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti
5662
isCancelled : (T) -> Boolean
5763
): DisposableHandle {
5864
require(timeDeltaMillis >= 0) { "Attempted scheduling an event earlier in time (with the time delta $timeDeltaMillis)" }
65+
sendDispatchEvent()
5966
val count = count.getAndIncrement()
6067
return synchronized(lock) {
6168
val time = addClamping(currentTime, timeDeltaMillis)
@@ -170,6 +177,18 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti
170177
return presentEvents.all { it.isCancelled() }
171178
}
172179
}
180+
181+
/**
182+
* Notifies this scheduler about a dispatch event.
183+
*/
184+
internal fun sendDispatchEvent() {
185+
dispatchEvents.trySend(Unit)
186+
}
187+
188+
/**
189+
* Consumes the knowledge that a dispatch event happened recently.
190+
*/
191+
internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
173192
}
174193

175194
// Some error-throwing functions for pretty stack traces
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.test
6+
7+
import kotlinx.coroutines.*
8+
import kotlin.coroutines.*
9+
import kotlin.test.*
10+
import kotlin.time.*
11+
12+
class TestRunTest {
13+
14+
@Test
15+
fun testWithContextDispatching() = runTest {
16+
var counter = 0
17+
withContext(Dispatchers.Default) {
18+
counter += 1
19+
}
20+
assertEquals(counter, 1)
21+
}
22+
23+
@Test
24+
fun testJoiningForkedJob() = runTest {
25+
var counter = 0
26+
val job = GlobalScope.launch {
27+
counter += 1
28+
}
29+
job.join()
30+
assertEquals(counter, 1)
31+
}
32+
33+
@Test
34+
fun testSuspendCancellableCoroutine() = runTest {
35+
val answer = suspendCoroutine<Int> {
36+
it.resume(42)
37+
}
38+
assertEquals(42, answer)
39+
}
40+
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.test
6+
import kotlinx.coroutines.*
7+
import kotlin.js.*
8+
9+
@Suppress("ACTUAL_WITHOUT_EXPECT", "ACTUAL_TYPE_ALIAS_TO_CLASS_WITH_DECLARATION_SITE_VARIANCE")
10+
public actual typealias TestResult = Promise<Unit>
11+
12+
internal actual fun createTestResult(testProcedure: suspend () -> Unit): TestResult =
13+
GlobalScope.promise {
14+
testProcedure()
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
package kotlinx.coroutines.test
5+
6+
import kotlinx.coroutines.*
7+
import kotlin.test.*
8+
9+
class PromiseTest {
10+
@Test
11+
fun testCompletionFromPromise() = runTest {
12+
var promiseEntered = false
13+
val p = promise {
14+
delay(1)
15+
promiseEntered = true
16+
}
17+
delay(2)
18+
p.await()
19+
assertTrue(promiseEntered)
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
package kotlinx.coroutines.test
5+
6+
import kotlinx.coroutines.*
7+
8+
@Suppress("ACTUAL_WITHOUT_EXPECT")
9+
public actual typealias TestResult = Unit
10+
11+
internal actual fun createTestResult(testProcedure: suspend () -> Unit) {
12+
runBlocking {
13+
testProcedure()
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.test
6+
import kotlinx.coroutines.*
7+
8+
@Suppress("ACTUAL_WITHOUT_EXPECT")
9+
public actual typealias TestResult = Unit
10+
11+
internal actual fun createTestResult(testProcedure: suspend () -> Unit) {
12+
runBlocking {
13+
testProcedure()
14+
}
15+
}

0 commit comments

Comments
 (0)