Skip to content

Commit 3b22c27

Browse files
authored
Allow specifying the timeout for runTest (#3603)
Deprecate `dispatchTimeoutMs`, as this is a confusing implementation detail that made it to the final API. We use the fact that the `runTest(Duration)` overload was never published, so we can reuse it to have the `Duration` mean the whole-test timeout in a backward-compatible manner.
1 parent 7f4b80c commit 3b22c27

15 files changed

+431
-121
lines changed

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ public final class kotlinx/coroutines/test/TestBuildersKt {
2222
public static final fun runTest (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
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
25-
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2625
public static final fun runTest-8Mi8wO0 (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
2726
public static final fun runTest-8Mi8wO0 (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
2827
public static synthetic fun runTest-8Mi8wO0$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
28+
public static synthetic fun runTest-8Mi8wO0$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
2929
public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
3030
public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
3131
}
@@ -66,6 +66,7 @@ public final class kotlinx/coroutines/test/TestCoroutineScheduler : kotlin/corou
6666
public static final field Key Lkotlinx/coroutines/test/TestCoroutineScheduler$Key;
6767
public fun <init> ()V
6868
public final fun advanceTimeBy (J)V
69+
public final fun advanceTimeBy-LRDsOJo (J)V
6970
public final fun advanceUntilIdle ()V
7071
public final fun getCurrentTime ()J
7172
public final fun getTimeSource ()Lkotlin/time/TimeSource;
@@ -117,6 +118,7 @@ public final class kotlinx/coroutines/test/TestScopeKt {
117118
public static final fun TestScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestScope;
118119
public static synthetic fun TestScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestScope;
119120
public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestScope;J)V
121+
public static final fun advanceTimeBy-HG0u8IE (Lkotlinx/coroutines/test/TestScope;J)V
120122
public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestScope;)V
121123
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestScope;)J
122124
public static final fun getTestTimeSource (Lkotlinx/coroutines/test/TestScope;)Lkotlin/time/TimeSource;

kotlinx-coroutines-test/build.gradle.kts

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import org.jetbrains.kotlin.gradle.plugin.mpp.*
2-
31
/*
42
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
53
*/
64

5+
import org.jetbrains.kotlin.gradle.plugin.mpp.*
6+
77
val experimentalAnnotations = listOf(
88
"kotlin.Experimental",
99
"kotlinx.coroutines.ExperimentalCoroutinesApi",
@@ -19,4 +19,12 @@ kotlin {
1919
binaryOptions["memoryModel"] = "experimental"
2020
}
2121
}
22+
23+
sourceSets {
24+
jvmTest {
25+
dependencies {
26+
implementation(project(":kotlinx-coroutines-debug"))
27+
}
28+
}
29+
}
2230
}

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

+165-52
Large diffs are not rendered by default.

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

+31-7
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import kotlinx.coroutines.selects.*
1313
import kotlin.coroutines.*
1414
import kotlin.jvm.*
1515
import kotlin.time.*
16+
import kotlin.time.Duration.Companion.milliseconds
1617

1718
/**
1819
* This is a scheduler for coroutines used in tests, providing the delay-skipping behavior.
@@ -49,6 +50,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
4950
get() = synchronized(lock) { field }
5051
private set
5152

53+
/** A channel for notifying about the fact that a foreground work dispatch recently happened. */
54+
private val dispatchEventsForeground: Channel<Unit> = Channel(CONFLATED)
55+
5256
/** A channel for notifying about the fact that a dispatch recently happened. */
5357
private val dispatchEvents: Channel<Unit> = Channel(CONFLATED)
5458

@@ -73,8 +77,8 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
7377
val time = addClamping(currentTime, timeDeltaMillis)
7478
val event = TestDispatchEvent(dispatcher, count, time, marker as Any, isForeground) { isCancelled(marker) }
7579
events.addLast(event)
76-
/** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's
77-
* actually anything in the event queue. */
80+
/** can't be moved above: otherwise, [onDispatchEventForeground] or [onDispatchEvent] could consume the
81+
* token sent here before there's actually anything in the event queue. */
7882
sendDispatchEvent(context)
7983
DisposableHandle {
8084
synchronized(lock) {
@@ -150,13 +154,22 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
150154
* * Overflowing the target time used to lead to nothing being done, but will now run the tasks scheduled at up to
151155
* (but not including) [Long.MAX_VALUE].
152156
*
153-
* @throws IllegalStateException if passed a negative [delay][delayTimeMillis].
157+
* @throws IllegalArgumentException if passed a negative [delay][delayTimeMillis].
158+
*/
159+
@ExperimentalCoroutinesApi
160+
public fun advanceTimeBy(delayTimeMillis: Long): Unit = advanceTimeBy(delayTimeMillis.milliseconds)
161+
162+
/**
163+
* Moves the virtual clock of this dispatcher forward by [the specified amount][delayTime], running the
164+
* scheduled tasks in the meantime.
165+
*
166+
* @throws IllegalArgumentException if passed a negative [delay][delayTime].
154167
*/
155168
@ExperimentalCoroutinesApi
156-
public fun advanceTimeBy(delayTimeMillis: Long) {
157-
require(delayTimeMillis >= 0) { "Can not advance time by a negative delay: $delayTimeMillis" }
169+
public fun advanceTimeBy(delayTime: Duration) {
170+
require(!delayTime.isNegative()) { "Can not advance time by a negative delay: $delayTime" }
158171
val startingTime = currentTime
159-
val targetTime = addClamping(startingTime, delayTimeMillis)
172+
val targetTime = addClamping(startingTime, delayTime.inWholeMilliseconds)
160173
while (true) {
161174
val event = synchronized(lock) {
162175
val timeMark = currentTime
@@ -191,15 +204,26 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
191204
* [context] is the context in which the task will be dispatched.
192205
*/
193206
internal fun sendDispatchEvent(context: CoroutineContext) {
207+
dispatchEvents.trySend(Unit)
194208
if (context[BackgroundWork] !== BackgroundWork)
195-
dispatchEvents.trySend(Unit)
209+
dispatchEventsForeground.trySend(Unit)
196210
}
197211

212+
/**
213+
* Waits for a notification about a dispatch event.
214+
*/
215+
internal suspend fun receiveDispatchEvent() = dispatchEvents.receive()
216+
198217
/**
199218
* Consumes the knowledge that a dispatch event happened recently.
200219
*/
201220
internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
202221

222+
/**
223+
* Consumes the knowledge that a foreground work dispatch event happened recently.
224+
*/
225+
internal val onDispatchEventForeground: SelectClause1<Unit> get() = dispatchEventsForeground.onReceive
226+
203227
/**
204228
* Returns the [TimeSource] representation of the virtual time of this scheduler.
205229
*/

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

+21-4
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ public sealed interface TestScope : CoroutineScope {
5252
* A scope for background work.
5353
*
5454
* This scope is automatically cancelled when the test finishes.
55-
* Additionally, while the coroutines in this scope are run as usual when
56-
* using [advanceTimeBy] and [runCurrent], [advanceUntilIdle] will stop advancing the virtual time
57-
* once only the coroutines in this scope are left unprocessed.
55+
* The coroutines in this scope are run as usual when using [advanceTimeBy] and [runCurrent].
56+
* [advanceUntilIdle], on the other hand, will stop advancing the virtual time once only the coroutines in this
57+
* scope are left unprocessed.
5858
*
5959
* Failures in coroutines in this scope do not terminate the test.
6060
* Instead, they are reported at the end of the test.
@@ -123,6 +123,16 @@ public fun TestScope.runCurrent(): Unit = testScheduler.runCurrent()
123123
@ExperimentalCoroutinesApi
124124
public fun TestScope.advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis)
125125

126+
/**
127+
* Moves the virtual clock of this dispatcher forward by [the specified amount][delayTime], running the
128+
* scheduled tasks in the meantime.
129+
*
130+
* @throws IllegalStateException if passed a negative [delay][delayTime].
131+
* @see TestCoroutineScheduler.advanceTimeBy
132+
*/
133+
@ExperimentalCoroutinesApi
134+
public fun TestScope.advanceTimeBy(delayTime: Duration): Unit = testScheduler.advanceTimeBy(delayTime)
135+
126136
/**
127137
* The [test scheduler][TestScope.testScheduler] as a [TimeSource].
128138
* @see TestCoroutineScheduler.timeSource
@@ -230,8 +240,15 @@ internal class TestScopeImpl(context: CoroutineContext) :
230240
}
231241
}
232242

243+
/** Called at the end of the test. May only be called once. Returns the list of caught unhandled exceptions. */
244+
fun leave(): List<Throwable> = synchronized(lock) {
245+
check(entered && !finished)
246+
finished = true
247+
uncaughtExceptions
248+
}
249+
233250
/** Called at the end of the test. May only be called once. */
234-
fun leave(): List<Throwable> {
251+
fun legacyLeave(): List<Throwable> {
235252
val exceptions = synchronized(lock) {
236253
check(entered && !finished)
237254
finished = true

kotlinx-coroutines-test/common/test/RunTestTest.kt

+70-14
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import kotlinx.coroutines.internal.*
99
import kotlinx.coroutines.flow.*
1010
import kotlin.coroutines.*
1111
import kotlin.test.*
12+
import kotlin.time.*
13+
import kotlin.time.Duration.Companion.milliseconds
1214

1315
class RunTestTest {
1416

@@ -52,7 +54,7 @@ class RunTestTest {
5254

5355
/** Tests that even the dispatch timeout of `0` is fine if all the dispatches go through the same scheduler. */
5456
@Test
55-
fun testRunTestWithZeroTimeoutWithControlledDispatches() = runTest(dispatchTimeoutMs = 0) {
57+
fun testRunTestWithZeroDispatchTimeoutWithControlledDispatches() = runTest(dispatchTimeoutMs = 0) {
5658
// below is some arbitrary concurrent code where all dispatches go through the same scheduler.
5759
launch {
5860
delay(2000)
@@ -71,8 +73,13 @@ class RunTestTest {
7173

7274
/** Tests that too low of a dispatch timeout causes crashes. */
7375
@Test
74-
fun testRunTestWithSmallTimeout() = testResultMap({ fn ->
75-
assertFailsWith<UncompletedCoroutinesError> { fn() }
76+
fun testRunTestWithSmallDispatchTimeout() = testResultMap({ fn ->
77+
try {
78+
fn()
79+
fail("shouldn't be reached")
80+
} catch (e: Throwable) {
81+
assertIs<UncompletedCoroutinesError>(e)
82+
}
7683
}) {
7784
runTest(dispatchTimeoutMs = 100) {
7885
withContext(Dispatchers.Default) {
@@ -83,6 +90,48 @@ class RunTestTest {
8390
}
8491
}
8592

93+
/**
94+
* Tests that [runTest] times out after the specified time.
95+
*/
96+
@Test
97+
fun testRunTestWithSmallTimeout() = testResultMap({ fn ->
98+
try {
99+
fn()
100+
fail("shouldn't be reached")
101+
} catch (e: Throwable) {
102+
assertIs<UncompletedCoroutinesError>(e)
103+
}
104+
}) {
105+
runTest(timeout = 100.milliseconds) {
106+
withContext(Dispatchers.Default) {
107+
delay(10000)
108+
3
109+
}
110+
fail("shouldn't be reached")
111+
}
112+
}
113+
114+
/** Tests that [runTest] times out after the specified time, even if the test framework always knows the test is
115+
* still doing something. */
116+
@Test
117+
fun testRunTestWithSmallTimeoutAndManyDispatches() = testResultMap({ fn ->
118+
try {
119+
fn()
120+
fail("shouldn't be reached")
121+
} catch (e: Throwable) {
122+
assertIs<UncompletedCoroutinesError>(e)
123+
}
124+
}) {
125+
runTest(timeout = 100.milliseconds) {
126+
while (true) {
127+
withContext(Dispatchers.Default) {
128+
delay(10)
129+
3
130+
}
131+
}
132+
}
133+
}
134+
86135
/** Tests that, on timeout, the names of the active coroutines are listed,
87136
* whereas the names of the completed ones are not. */
88137
@Test
@@ -119,26 +168,33 @@ class RunTestTest {
119168
} catch (e: UncompletedCoroutinesError) {
120169
@Suppress("INVISIBLE_MEMBER")
121170
val suppressed = unwrap(e).suppressedExceptions
122-
assertEquals(1, suppressed.size)
171+
assertEquals(1, suppressed.size, "$suppressed")
123172
assertIs<TestException>(suppressed[0]).also {
124173
assertEquals("A", it.message)
125174
}
126175
}
127176
}) {
128-
runTest(dispatchTimeoutMs = 10) {
129-
launch {
130-
withContext(NonCancellable) {
131-
awaitCancellation()
177+
runTest(timeout = 10.milliseconds) {
178+
launch(start = CoroutineStart.UNDISPATCHED) {
179+
withContext(NonCancellable + Dispatchers.Default) {
180+
delay(100.milliseconds)
132181
}
133182
}
134-
yield()
135183
throw TestException("A")
136184
}
137185
}
138186

139187
/** Tests that real delays can be accounted for with a large enough dispatch timeout. */
140188
@Test
141-
fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) {
189+
fun testRunTestWithLargeDispatchTimeout() = runTest(dispatchTimeoutMs = 5000) {
190+
withContext(Dispatchers.Default) {
191+
delay(50)
192+
}
193+
}
194+
195+
/** Tests that delays can be accounted for with a large enough timeout. */
196+
@Test
197+
fun testRunTestWithLargeTimeout() = runTest(timeout = 5000.milliseconds) {
142198
withContext(Dispatchers.Default) {
143199
delay(50)
144200
}
@@ -153,13 +209,13 @@ class RunTestTest {
153209
} catch (e: UncompletedCoroutinesError) {
154210
@Suppress("INVISIBLE_MEMBER")
155211
val suppressed = unwrap(e).suppressedExceptions
156-
assertEquals(1, suppressed.size)
212+
assertEquals(1, suppressed.size, "$suppressed")
157213
assertIs<TestException>(suppressed[0]).also {
158214
assertEquals("A", it.message)
159215
}
160216
}
161217
}) {
162-
runTest(dispatchTimeoutMs = 1) {
218+
runTest(timeout = 1.milliseconds) {
163219
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
164220
withContext(Dispatchers.Default) {
165221
delay(10000)
@@ -324,7 +380,7 @@ class RunTestTest {
324380
}
325381
}
326382

327-
/** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */
383+
/** Tests that [TestScope.runTest] does not inherit the exception handler and works. */
328384
@Test
329385
fun testScopeRunTestExceptionHandler(): TestResult {
330386
val scope = TestScope()
@@ -349,7 +405,7 @@ class RunTestTest {
349405
* The test will hang if this is not the case.
350406
*/
351407
@Test
352-
fun testCoroutineCompletingWithoutDispatch() = runTest(dispatchTimeoutMs = Long.MAX_VALUE) {
408+
fun testCoroutineCompletingWithoutDispatch() = runTest(timeout = Duration.INFINITE) {
353409
launch(Dispatchers.Default) { delay(100) }
354410
}
355411
}

kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class StandardTestDispatcherTest: OrderedExecutionTestBase() {
2020
@AfterTest
2121
fun cleanup() {
2222
scope.runCurrent()
23-
assertEquals(listOf(), scope.asSpecificImplementation().leave())
23+
assertEquals(listOf(), scope.asSpecificImplementation().legacyLeave())
2424
}
2525

2626
/** Tests that the [StandardTestDispatcher] follows an execution order similar to `runBlocking`. */

0 commit comments

Comments
 (0)