Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow specifying the timeout for runTest #3603

Merged
merged 11 commits into from
Feb 21, 2023
4 changes: 3 additions & 1 deletion kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ public final class kotlinx/coroutines/test/TestBuildersKt {
public static final fun runTest (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static final fun runTest-8Mi8wO0 (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
public static final fun runTest-8Mi8wO0 (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V
public static synthetic fun runTest-8Mi8wO0$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static synthetic fun runTest-8Mi8wO0$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V
public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V
}
Expand Down Expand Up @@ -66,6 +66,7 @@ public final class kotlinx/coroutines/test/TestCoroutineScheduler : kotlin/corou
public static final field Key Lkotlinx/coroutines/test/TestCoroutineScheduler$Key;
public fun <init> ()V
public final fun advanceTimeBy (J)V
public final fun advanceTimeBy-LRDsOJo (J)V
public final fun advanceUntilIdle ()V
public final fun getCurrentTime ()J
public final fun getTimeSource ()Lkotlin/time/TimeSource;
Expand Down Expand Up @@ -116,6 +117,7 @@ public final class kotlinx/coroutines/test/TestScopeKt {
public static final fun TestScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestScope;
public static synthetic fun TestScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestScope;
public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestScope;J)V
public static final fun advanceTimeBy-HG0u8IE (Lkotlinx/coroutines/test/TestScope;J)V
public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestScope;)V
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestScope;)J
public static final fun getTestTimeSource (Lkotlinx/coroutines/test/TestScope;)Lkotlin/time/TimeSource;
Expand Down
221 changes: 173 additions & 48 deletions kotlinx-coroutines-test/common/src/TestBuilders.kt

Large diffs are not rendered by default.

37 changes: 28 additions & 9 deletions kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import kotlinx.coroutines.selects.*
import kotlin.coroutines.*
import kotlin.jvm.*
import kotlin.time.*
import kotlin.time.Duration.Companion.milliseconds

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

/** A channel for notifying about the fact that a foreground work dispatch recently happened. */
private val dispatchEventsForeground: Channel<Unit> = Channel(CONFLATED)

/** A channel for notifying about the fact that a dispatch recently happened. */
private val dispatchEvents: Channel<Unit> = Channel(CONFLATED)

Expand All @@ -73,8 +77,8 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
val time = addClamping(currentTime, timeDeltaMillis)
val event = TestDispatchEvent(dispatcher, count, time, marker as Any, isForeground) { isCancelled(marker) }
events.addLast(event)
/** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's
* actually anything in the event queue. */
/** can't be moved above: otherwise, [onDispatchEventForeground] or [receiveDispatchEvent] could consume the
* token sent here before there's actually anything in the event queue. */
sendDispatchEvent(context)
DisposableHandle {
synchronized(lock) {
Expand Down Expand Up @@ -150,13 +154,22 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
* * Overflowing the target time used to lead to nothing being done, but will now run the tasks scheduled at up to
* (but not including) [Long.MAX_VALUE].
*
* @throws IllegalStateException if passed a negative [delay][delayTimeMillis].
* @throws IllegalArgumentException if passed a negative [delay][delayTimeMillis].
*/
@ExperimentalCoroutinesApi
public fun advanceTimeBy(delayTimeMillis: Long): Unit = advanceTimeBy(delayTimeMillis.milliseconds)

/**
* Moves the virtual clock of this dispatcher forward by [the specified amount][delayTime], running the
* scheduled tasks in the meantime.
*
* @throws IllegalArgumentException if passed a negative [delay][delayTime].
*/
@ExperimentalCoroutinesApi
public fun advanceTimeBy(delayTimeMillis: Long) {
require(delayTimeMillis >= 0) { "Can not advance time by a negative delay: $delayTimeMillis" }
public fun advanceTimeBy(delayTime: Duration) {
require(!delayTime.isNegative()) { "Can not advance time by a negative delay: $delayTime" }
val startingTime = currentTime
val targetTime = addClamping(startingTime, delayTimeMillis)
val targetTime = addClamping(startingTime, delayTime.inWholeMilliseconds)
while (true) {
val event = synchronized(lock) {
val timeMark = currentTime
Expand Down Expand Up @@ -191,14 +204,20 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
* [context] is the context in which the task will be dispatched.
*/
internal fun sendDispatchEvent(context: CoroutineContext) {
dispatchEvents.trySend(Unit)
if (context[BackgroundWork] !== BackgroundWork)
dispatchEvents.trySend(Unit)
dispatchEventsForeground.trySend(Unit)
}

/**
* Consumes the knowledge that a dispatch event happened recently.
* Waits for a notification about a dispatch event.
*/
internal suspend fun receiveDispatchEvent() = dispatchEvents.receive()

/**
* Consumes the knowledge that a foreground work dispatch event happened recently.
*/
internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
internal val onDispatchEventForeground: SelectClause1<Unit> get() = dispatchEventsForeground.onReceive

/**
* Returns the [TimeSource] representation of the virtual time of this scheduler.
Expand Down
25 changes: 21 additions & 4 deletions kotlinx-coroutines-test/common/src/TestScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ public sealed interface TestScope : CoroutineScope {
* A scope for background work.
*
* This scope is automatically cancelled when the test finishes.
* Additionally, while the coroutines in this scope are run as usual when
* using [advanceTimeBy] and [runCurrent], [advanceUntilIdle] will stop advancing the virtual time
* once only the coroutines in this scope are left unprocessed.
* The coroutines in this scope are run as usual when using [advanceTimeBy] and [runCurrent].
* [advanceUntilIdle], on the other hand, will stop advancing the virtual time once only the coroutines in this
* scope are left unprocessed.
*
* Failures in coroutines in this scope do not terminate the test.
* Instead, they are reported at the end of the test.
Expand Down Expand Up @@ -123,6 +123,16 @@ public fun TestScope.runCurrent(): Unit = testScheduler.runCurrent()
@ExperimentalCoroutinesApi
public fun TestScope.advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis)

/**
* Moves the virtual clock of this dispatcher forward by [the specified amount][delayTime], running the
* scheduled tasks in the meantime.
*
* @throws IllegalStateException if passed a negative [delay][delayTime].
dkhalanskyjb marked this conversation as resolved.
Show resolved Hide resolved
* @see TestCoroutineScheduler.advanceTimeBy
*/
@ExperimentalCoroutinesApi
public fun TestScope.advanceTimeBy(delayTime: Duration): Unit = testScheduler.advanceTimeBy(delayTime)

/**
* The [test scheduler][TestScope.testScheduler] as a [TimeSource].
* @see TestCoroutineScheduler.timeSource
Expand Down Expand Up @@ -230,8 +240,15 @@ internal class TestScopeImpl(context: CoroutineContext) :
}
}

/** Called at the end of the test. May only be called once. Returns the list of caught unhandled exceptions. */
fun leave(): List<Throwable> = synchronized(lock) {
check(entered && !finished)
finished = true
uncaughtExceptions
}

/** Called at the end of the test. May only be called once. */
fun leave(): List<Throwable> {
fun legacyLeave(): List<Throwable> {
val exceptions = synchronized(lock) {
check(entered && !finished)
finished = true
Expand Down
85 changes: 71 additions & 14 deletions kotlinx-coroutines-test/common/test/RunTestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import kotlinx.coroutines.internal.*
import kotlinx.coroutines.flow.*
import kotlin.coroutines.*
import kotlin.test.*
import kotlin.time.*
import kotlin.time.Duration.Companion.milliseconds

class RunTestTest {

Expand Down Expand Up @@ -52,7 +54,7 @@ class RunTestTest {

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

/** Tests that too low of a dispatch timeout causes crashes. */
@Test
fun testRunTestWithSmallTimeout() = testResultMap({ fn ->
assertFailsWith<UncompletedCoroutinesError> { fn() }
fun testRunTestWithSmallDispatchTimeout() = testResultMap({ fn ->
try {
fn()
fail("shouldn't be reached")
} catch (e: Throwable) {
assertIs<UncompletedCoroutinesError>(e)
}
}) {
runTest(dispatchTimeoutMs = 100) {
withContext(Dispatchers.Default) {
Expand All @@ -83,6 +90,49 @@ class RunTestTest {
}
}

/**
* Tests that [runTest] times out after the specified time.
*/
@Test
fun testRunTestWithSmallTimeout() = testResultMap({ fn ->
try {
fn()
fail("shouldn't be reached")
} catch (e: Throwable) {
e.printStackTrace()
assertIs<UncompletedCoroutinesError>(e)
}
}) {
runTest(timeout = 100.milliseconds) {
withContext(Dispatchers.Default) {
delay(10000)
3
}
fail("shouldn't be reached")
}
}

/** Tests that [runTest] times out after the specified time, even if the test framework always knows the test is
* still doing something. */
@Test
fun testRunTestWithSmallTimeoutAndManyDispatches() = testResultMap({ fn ->
try {
fn()
fail("shouldn't be reached")
} catch (e: Throwable) {
assertIs<UncompletedCoroutinesError>(e)
}
}) {
runTest(timeout = 100.milliseconds) {
while (true) {
withContext(Dispatchers.Default) {
delay(10)
3
}
}
}
}

/** Tests that, on timeout, the names of the active coroutines are listed,
* whereas the names of the completed ones are not. */
@Test
Expand Down Expand Up @@ -119,26 +169,33 @@ class RunTestTest {
} catch (e: UncompletedCoroutinesError) {
@Suppress("INVISIBLE_MEMBER")
val suppressed = unwrap(e).suppressedExceptions
assertEquals(1, suppressed.size)
assertEquals(1, suppressed.size, "$suppressed")
assertIs<TestException>(suppressed[0]).also {
assertEquals("A", it.message)
}
}
}) {
runTest(dispatchTimeoutMs = 10) {
launch {
withContext(NonCancellable) {
awaitCancellation()
runTest(timeout = 10.milliseconds) {
launch(start = CoroutineStart.UNDISPATCHED) {
withContext(NonCancellable + Dispatchers.Default) {
delay(100.milliseconds)
}
}
yield()
throw TestException("A")
}
}

/** Tests that real delays can be accounted for with a large enough dispatch timeout. */
@Test
fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) {
fun testRunTestWithLargeDispatchTimeout() = runTest(dispatchTimeoutMs = 5000) {
withContext(Dispatchers.Default) {
delay(50)
}
}

/** Tests that delays can be accounted for with a large enough timeout. */
@Test
fun testRunTestWithLargeTimeout() = runTest(timeout = 5000.milliseconds) {
withContext(Dispatchers.Default) {
delay(50)
}
Expand All @@ -153,13 +210,13 @@ class RunTestTest {
} catch (e: UncompletedCoroutinesError) {
@Suppress("INVISIBLE_MEMBER")
val suppressed = unwrap(e).suppressedExceptions
assertEquals(1, suppressed.size)
assertEquals(1, suppressed.size, "$suppressed")
assertIs<TestException>(suppressed[0]).also {
assertEquals("A", it.message)
}
}
}) {
runTest(dispatchTimeoutMs = 1) {
runTest(timeout = 1.milliseconds) {
coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, TestException("A"))
withContext(Dispatchers.Default) {
delay(10000)
Expand Down Expand Up @@ -324,7 +381,7 @@ class RunTestTest {
}
}

/** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */
/** Tests that [TestScope.runTest] does not inherit the exception handler and works. */
@Test
fun testScopeRunTestExceptionHandler(): TestResult {
val scope = TestScope()
Expand All @@ -349,7 +406,7 @@ class RunTestTest {
* The test will hang if this is not the case.
*/
@Test
fun testCoroutineCompletingWithoutDispatch() = runTest(dispatchTimeoutMs = Long.MAX_VALUE) {
fun testCoroutineCompletingWithoutDispatch() = runTest(timeout = Duration.INFINITE) {
launch(Dispatchers.Default) { delay(100) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class StandardTestDispatcherTest: OrderedExecutionTestBase() {
@AfterTest
fun cleanup() {
scope.runCurrent()
assertEquals(listOf(), scope.asSpecificImplementation().leave())
assertEquals(listOf(), scope.asSpecificImplementation().legacyLeave())
}

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