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
264 changes: 180 additions & 84 deletions kotlinx-coroutines-test/common/src/TestBuilders.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ private class UnconfinedTestDispatcherImpl(
*
* @see UnconfinedTestDispatcher for a dispatcher that is not confined to any particular thread.
*/
@ExperimentalCoroutinesApi
@Suppress("FunctionName")
public fun StandardTestDispatcher(
scheduler: TestCoroutineScheduler? = null,
Expand Down
38 changes: 26 additions & 12 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 All @@ -26,7 +27,6 @@ import kotlin.time.*
* virtual time as needed (via [advanceUntilIdle]), or run the tasks that are scheduled to run as soon as possible but
* haven't yet been dispatched (via [runCurrent]).
*/
@ExperimentalCoroutinesApi
public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCoroutineScheduler),
CoroutineContext.Element {

Expand All @@ -49,6 +49,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 +76,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 @@ -109,7 +112,6 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
* milliseconds by which the execution of this method has advanced the virtual time. If you want to recreate that
* functionality, query [currentTime] before and after the execution to achieve the same result.
*/
@ExperimentalCoroutinesApi
public fun advanceUntilIdle(): Unit = advanceUntilIdleOr { events.none(TestDispatchEvent<*>::isForeground) }

/**
Expand All @@ -125,7 +127,6 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
/**
* Runs the tasks that are scheduled to execute at this moment of virtual time.
*/
@ExperimentalCoroutinesApi
public fun runCurrent() {
val timeMark = synchronized(lock) { currentTime }
while (true) {
Expand Down Expand Up @@ -153,10 +154,18 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
* @throws IllegalStateException if passed a negative [delay][delayTimeMillis].
*/
@ExperimentalCoroutinesApi
public fun advanceTimeBy(delayTimeMillis: Long) {
require(delayTimeMillis >= 0) { "Can not advance time by a negative delay: $delayTimeMillis" }
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 IllegalStateException if passed a negative [delay][delayTime].
dkhalanskyjb marked this conversation as resolved.
Show resolved Hide resolved
*/
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,19 +200,24 @@ 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.
*/
@ExperimentalCoroutinesApi
@ExperimentalTime
public val timeSource: TimeSource = object : AbstractLongTimeSource(DurationUnit.MILLISECONDS) {
override fun read(): Long = currentTime
Expand Down
2 changes: 0 additions & 2 deletions kotlinx-coroutines-test/common/src/TestDispatcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@ import kotlin.jvm.*
* * [UnconfinedTestDispatcher] is a dispatcher that behaves like [Dispatchers.Unconfined] while allowing to control
* the virtual time.
*/
@ExperimentalCoroutinesApi
public abstract class TestDispatcher internal constructor() : CoroutineDispatcher(), Delay {
/** The scheduler that this dispatcher is linked to. */
@ExperimentalCoroutinesApi
public abstract val scheduler: TestCoroutineScheduler

/** Notifies the dispatcher that it should process a single event marked with [marker] happening at time [time]. */
Expand Down
20 changes: 13 additions & 7 deletions kotlinx-coroutines-test/common/src/TestScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,19 @@ import kotlin.time.*
* paused by default, like [StandardTestDispatcher].
* * No access to the list of unhandled exceptions.
*/
@ExperimentalCoroutinesApi
public sealed interface TestScope : CoroutineScope {
/**
* The delay-skipping scheduler used by the test dispatchers running the code in this scope.
*/
@ExperimentalCoroutinesApi
public val testScheduler: TestCoroutineScheduler

/**
* 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 @@ -82,7 +80,6 @@ public sealed interface TestScope : CoroutineScope {
* }
* ```
*/
@ExperimentalCoroutinesApi
public val backgroundScope: CoroutineScope
}

Expand Down Expand Up @@ -123,6 +120,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 @@ -156,7 +163,6 @@ public val TestScope.testTimeSource: TimeSource get() = testScheduler.timeSource
* @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an
* [UncaughtExceptionCaptor].
*/
@ExperimentalCoroutinesApi
@Suppress("FunctionName")
public fun TestScope(context: CoroutineContext = EmptyCoroutineContext): TestScope {
val ctxWithDispatcher = context.withDelaySkipping()
Expand Down
81 changes: 69 additions & 12 deletions kotlinx-coroutines-test/common/test/RunTestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import kotlinx.coroutines.internal.*
import kotlinx.coroutines.flow.*
import kotlin.coroutines.*
import kotlin.test.*
import kotlin.time.*
import kotlin.time.Duration.Companion.milliseconds

@OptIn(ExperimentalTime::class)
class RunTestTest {

/** Tests that [withContext] that sends work to other threads works in [runTest]. */
Expand Down Expand Up @@ -52,7 +55,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 +74,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 +91,48 @@ 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) {
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 @@ -125,20 +175,27 @@ class RunTestTest {
}
}
}) {
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 @@ -159,7 +216,7 @@ class RunTestTest {
}
}
}) {
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) }
}
}