Skip to content

Commit 02bf356

Browse files
authored
Add overloads for runTest that accept a Duration (#3483)
1 parent 8724e63 commit 02bf356

File tree

3 files changed

+159
-16
lines changed

3 files changed

+159
-16
lines changed

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

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public final class kotlinx/coroutines/test/TestBuildersKt {
2323
public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2424
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2525
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
26+
public static final fun runTest-8Mi8wO0 (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
27+
public static final fun runTest-8Mi8wO0 (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
28+
public static synthetic fun runTest-8Mi8wO0$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2629
public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
2730
public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2831
}

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

+154-15
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import kotlinx.coroutines.*
1010
import kotlinx.coroutines.selects.*
1111
import kotlin.coroutines.*
1212
import kotlin.jvm.*
13+
import kotlin.time.*
14+
import kotlin.time.Duration.Companion.milliseconds
1315

1416
/**
1517
* A test result.
@@ -41,9 +43,9 @@ public expect class TestResult
4143
* @Test
4244
* fun exampleTest() = runTest {
4345
* val deferred = async {
44-
* delay(1_000)
46+
* delay(1.seconds)
4547
* async {
46-
* delay(1_000)
48+
* delay(1.seconds)
4749
* }.await()
4850
* }
4951
*
@@ -99,9 +101,9 @@ public expect class TestResult
99101
* fun exampleTest() = runTest {
100102
* val elapsed = TimeSource.Monotonic.measureTime {
101103
* val deferred = async {
102-
* delay(1_000) // will be skipped
104+
* delay(1.seconds) // will be skipped
103105
* withContext(Dispatchers.Default) {
104-
* delay(5_000) // Dispatchers.Default doesn't know about TestCoroutineScheduler
106+
* delay(5.seconds) // Dispatchers.Default doesn't know about TestCoroutineScheduler
105107
* }
106108
* }
107109
* deferred.await()
@@ -131,7 +133,7 @@ public expect class TestResult
131133
*
132134
* In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
133135
* to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
134-
* for [dispatchTimeoutMs] milliseconds (by default, 60 seconds) from the moment when [TestCoroutineScheduler] becomes
136+
* for [dispatchTimeout] (by default, 60 seconds) from the moment when [TestCoroutineScheduler] becomes
135137
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
136138
* task during that time, the timer gets reset.
137139
*
@@ -146,32 +148,168 @@ public expect class TestResult
146148
@ExperimentalCoroutinesApi
147149
public fun runTest(
148150
context: CoroutineContext = EmptyCoroutineContext,
149-
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
151+
dispatchTimeout: Duration = DEFAULT_DISPATCH_TIMEOUT,
150152
testBody: suspend TestScope.() -> Unit
151153
): TestResult {
152154
if (context[RunningInRunTest] != null)
153155
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
154-
return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs, testBody)
156+
return TestScope(context + RunningInRunTest).runTest(dispatchTimeout, testBody)
155157
}
156158

159+
/**
160+
* Executes [testBody] as a test in a new coroutine, returning [TestResult].
161+
*
162+
* On JVM and Native, this function behaves similarly to `runBlocking`, with the difference that the code that it runs
163+
* will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary.
164+
* On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior.
165+
*
166+
* ```
167+
* @Test
168+
* fun exampleTest() = runTest {
169+
* val deferred = async {
170+
* delay(1.seconds)
171+
* async {
172+
* delay(1.seconds)
173+
* }.await()
174+
* }
175+
*
176+
* deferred.await() // result available immediately
177+
* }
178+
* ```
179+
*
180+
* The platform difference entails that, in order to use this function correctly in common code, one must always
181+
* immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See
182+
* [TestResult] for details on this.
183+
*
184+
* The test is run in a single thread, unless other [CoroutineDispatcher] are used for child coroutines.
185+
* Because of this, child coroutines are not executed in parallel to the test body.
186+
* In order for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the
187+
* test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]).
188+
*
189+
* ```
190+
* @Test
191+
* fun exampleWaitingForAsyncTasks1() = runTest {
192+
* // 1
193+
* val job = launch {
194+
* // 3
195+
* }
196+
* // 2
197+
* job.join() // the main test coroutine suspends here, so the child is executed
198+
* // 4
199+
* }
200+
*
201+
* @Test
202+
* fun exampleWaitingForAsyncTasks2() = runTest {
203+
* // 1
204+
* launch {
205+
* // 3
206+
* }
207+
* // 2
208+
* advanceUntilIdle() // runs the tasks until their queue is empty
209+
* // 4
210+
* }
211+
* ```
212+
*
213+
* ### Task scheduling
214+
*
215+
* Delay-skipping is achieved by using virtual time.
216+
* If [Dispatchers.Main] is set to a [TestDispatcher] via [Dispatchers.setMain] before the test,
217+
* then its [TestCoroutineScheduler] is used;
218+
* otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control
219+
* the virtual time, advancing it, running the tasks scheduled at a specific time etc.
220+
* Some convenience methods are available on [TestScope] to control the scheduler.
221+
*
222+
* Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped:
223+
* ```
224+
* @Test
225+
* fun exampleTest() = runTest {
226+
* val elapsed = TimeSource.Monotonic.measureTime {
227+
* val deferred = async {
228+
* delay(1.seconds) // will be skipped
229+
* withContext(Dispatchers.Default) {
230+
* delay(5.seconds) // Dispatchers.Default doesn't know about TestCoroutineScheduler
231+
* }
232+
* }
233+
* deferred.await()
234+
* }
235+
* println(elapsed) // about five seconds
236+
* }
237+
* ```
238+
*
239+
* ### Failures
240+
*
241+
* #### Test body failures
242+
*
243+
* If the created coroutine completes with an exception, then this exception will be thrown at the end of the test.
244+
*
245+
* #### Reported exceptions
246+
*
247+
* Unhandled exceptions will be thrown at the end of the test.
248+
* If the uncaught exceptions happen after the test finishes, the error is propagated in a platform-specific manner.
249+
* If the test coroutine completes with an exception, the unhandled exceptions are suppressed by it.
250+
*
251+
* #### Uncompleted coroutines
252+
*
253+
* This method requires that, after the test coroutine has completed, all the other coroutines launched inside
254+
* [testBody] also complete, or are cancelled.
255+
* Otherwise, the test will be failed (which, on JVM and Native, means that [runTest] itself will throw
256+
* [AssertionError], whereas on JS, the `Promise` will fail with it).
257+
*
258+
* In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
259+
* to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
260+
* for [dispatchTimeoutMs] from the moment when [TestCoroutineScheduler] becomes
261+
* idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
262+
* task during that time, the timer gets reset.
263+
*
264+
* ### Configuration
265+
*
266+
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
267+
* scope created for the test, [context] also can be used to change how the test is executed.
268+
* See the [TestScope] constructor function documentation for details.
269+
*
270+
* @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details.
271+
*/
272+
@ExperimentalCoroutinesApi
273+
public fun runTest(
274+
context: CoroutineContext = EmptyCoroutineContext,
275+
dispatchTimeoutMs: Long,
276+
testBody: suspend TestScope.() -> Unit
277+
): TestResult = runTest(
278+
context = context,
279+
dispatchTimeout = dispatchTimeoutMs.milliseconds,
280+
testBody = testBody
281+
)
282+
157283
/**
158284
* Performs [runTest] on an existing [TestScope].
159285
*/
160286
@ExperimentalCoroutinesApi
161287
public fun TestScope.runTest(
162-
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
288+
dispatchTimeout: Duration,
163289
testBody: suspend TestScope.() -> Unit
164290
): TestResult = asSpecificImplementation().let {
165291
it.enter()
166292
createTestResult {
167-
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) {
293+
runTestCoroutine(it, dispatchTimeout, TestScopeImpl::tryGetCompletionCause, testBody) {
168294
backgroundScope.cancel()
169295
testScheduler.advanceUntilIdleOr { false }
170296
it.leave()
171297
}
172298
}
173299
}
174300

301+
/**
302+
* Performs [runTest] on an existing [TestScope].
303+
*/
304+
@ExperimentalCoroutinesApi
305+
public fun TestScope.runTest(
306+
dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
307+
testBody: suspend TestScope.() -> Unit
308+
): TestResult = runTest(
309+
dispatchTimeout = dispatchTimeoutMs.milliseconds,
310+
testBody = testBody
311+
)
312+
175313
/**
176314
* Runs [testProcedure], creating a [TestResult].
177315
*/
@@ -189,10 +327,11 @@ internal object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, Corou
189327
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
190328
* a [TestCoroutineScheduler]. */
191329
internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
330+
internal val DEFAULT_DISPATCH_TIMEOUT = DEFAULT_DISPATCH_TIMEOUT_MS.milliseconds
192331

193332
/**
194333
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
195-
* [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end.
334+
* [dispatchTimeout], and performing the [cleanup] procedure at the end.
196335
*
197336
* [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
198337
*
@@ -201,7 +340,7 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
201340
*/
202341
internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutine(
203342
coroutine: T,
204-
dispatchTimeoutMs: Long,
343+
dispatchTimeout: Duration,
205344
tryGetCompletionCause: T.() -> Throwable?,
206345
testBody: suspend T.() -> Unit,
207346
cleanup: () -> List<Throwable>,
@@ -258,8 +397,8 @@ internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutin
258397
scheduler.onDispatchEvent {
259398
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
260399
}
261-
onTimeout(dispatchTimeoutMs) {
262-
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
400+
onTimeout(dispatchTimeout) {
401+
handleTimeout(coroutine, dispatchTimeout, tryGetCompletionCause, cleanup)
263402
}
264403
}
265404
} finally {
@@ -284,7 +423,7 @@ internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutin
284423
*/
285424
private inline fun<T: AbstractCoroutine<Unit>> handleTimeout(
286425
coroutine: T,
287-
dispatchTimeoutMs: Long,
426+
dispatchTimeout: Duration,
288427
tryGetCompletionCause: T.() -> Throwable?,
289428
cleanup: () -> List<Throwable>,
290429
) {
@@ -296,7 +435,7 @@ private inline fun<T: AbstractCoroutine<Unit>> handleTimeout(
296435
}
297436
val activeChildren = coroutine.children.filter { it.isActive }.toList()
298437
val completionCause = if (coroutine.isCancelled) coroutine.tryGetCompletionCause() else null
299-
var message = "After waiting for $dispatchTimeoutMs ms"
438+
var message = "After waiting for $dispatchTimeout"
300439
if (completionCause == null)
301440
message += ", the test coroutine is not completing"
302441
if (activeChildren.isNotEmpty())

kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package kotlinx.coroutines.test
1010
import kotlinx.coroutines.*
1111
import kotlin.coroutines.*
1212
import kotlin.jvm.*
13+
import kotlin.time.Duration.Companion.milliseconds
1314

1415
/**
1516
* Executes a [testBody] inside an immediate execution dispatcher.
@@ -164,7 +165,7 @@ public fun runTestWithLegacyScope(
164165
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
165166
val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
166167
return createTestResult {
167-
runTestCoroutine(testScope, dispatchTimeoutMs, TestBodyCoroutine::tryGetCompletionCause, testBody) {
168+
runTestCoroutine(testScope, dispatchTimeoutMs.milliseconds, TestBodyCoroutine::tryGetCompletionCause, testBody) {
168169
try {
169170
testScope.cleanup()
170171
emptyList()

0 commit comments

Comments
 (0)