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.
diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
index 3f63364..ac9edb9 100644
--- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
+++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
@@ -22,10 +22,10 @@
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
}
@@ -66,6 +66,7 @@
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;
@@ -117,6 +118,7 @@
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;
diff --git a/kotlinx-coroutines-test/build.gradle.kts b/kotlinx-coroutines-test/build.gradle.kts
index 98d3e9f..c968fc4 100644
--- a/kotlinx-coroutines-test/build.gradle.kts
+++ b/kotlinx-coroutines-test/build.gradle.kts
@@ -1,9 +1,9 @@
-import org.jetbrains.kotlin.gradle.plugin.mpp.*
-
/*
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/
+import org.jetbrains.kotlin.gradle.plugin.mpp.*
+
val experimentalAnnotations = listOf(
"kotlin.Experimental",
"kotlinx.coroutines.ExperimentalCoroutinesApi",
@@ -19,4 +19,12 @@
binaryOptions["memoryModel"] = "experimental"
}
}
+
+ sourceSets {
+ jvmTest {
+ dependencies {
+ implementation(project(":kotlinx-coroutines-debug"))
+ }
+ }
+ }
}
diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt
index e9d8fec..de16967 100644
--- a/kotlinx-coroutines-test/common/src/TestBuilders.kt
+++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt
@@ -7,11 +7,14 @@
package kotlinx.coroutines.test
import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
import kotlinx.coroutines.selects.*
import kotlin.coroutines.*
import kotlin.jvm.*
import kotlin.time.*
import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.internal.*
/**
* A test result.
@@ -118,6 +121,18 @@
*
* If the created coroutine completes with an exception, then this exception will be thrown at the end of the test.
*
+ * #### Timing out
+ *
+ * There's a built-in timeout of 10 seconds for the test body. If the test body doesn't complete within this time,
+ * then the test fails with an [AssertionError]. The timeout can be changed by setting the [timeout] parameter.
+ *
+ * The test finishes by the timeout procedure cancelling the test body. If the code inside the test body does not
+ * respond to cancellation, we will not be able to make the test execution stop, in which case, the test will hang
+ * despite our best efforts to terminate it.
+ *
+ * On the JVM, if `DebugProbes` from the `kotlinx-coroutines-debug` module are installed, the current dump of the
+ * coroutines' stack is printed to the console on timeout.
+ *
* #### Reported exceptions
*
* Unhandled exceptions will be thrown at the end of the test.
@@ -131,12 +146,6 @@
* Otherwise, the test will be failed (which, on JVM and Native, means that [runTest] itself will throw
* [AssertionError], whereas on JS, the `Promise` will fail with it).
*
- * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
- * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
- * for [dispatchTimeout] (by default, 60 seconds) from the moment when [TestCoroutineScheduler] becomes
- * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
- * task during that time, the timer gets reset.
- *
* ### Configuration
*
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
@@ -148,12 +157,13 @@
@ExperimentalCoroutinesApi
public fun runTest(
context: CoroutineContext = EmptyCoroutineContext,
- dispatchTimeout: Duration = DEFAULT_DISPATCH_TIMEOUT,
+ timeout: Duration = DEFAULT_TIMEOUT,
testBody: suspend TestScope.() -> Unit
): TestResult {
- if (context[RunningInRunTest] != null)
- throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
- return TestScope(context + RunningInRunTest).runTest(dispatchTimeout, testBody)
+ check(context[RunningInRunTest] == null) {
+ "Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details."
+ }
+ return TestScope(context + RunningInRunTest).runTest(timeout, testBody)
}
/**
@@ -269,46 +279,128 @@
*
* @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details.
*/
-@ExperimentalCoroutinesApi
+@Deprecated(
+ "Define a total timeout for the whole test instead of using dispatchTimeoutMs. " +
+ "Warning: the proposed replacement is not identical as it uses 'dispatchTimeoutMs' as the timeout for the whole test!",
+ ReplaceWith("runTest(context, timeout = dispatchTimeoutMs.milliseconds, testBody)",
+ "kotlin.time.Duration.Companion.milliseconds"),
+ DeprecationLevel.WARNING
+)
public fun runTest(
context: CoroutineContext = EmptyCoroutineContext,
dispatchTimeoutMs: Long,
testBody: suspend TestScope.() -> Unit
-): TestResult = runTest(
- context = context,
- dispatchTimeout = dispatchTimeoutMs.milliseconds,
- testBody = testBody
-)
+): TestResult {
+ if (context[RunningInRunTest] != null)
+ throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
+ @Suppress("DEPRECATION")
+ return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs = dispatchTimeoutMs, testBody)
+}
/**
- * Performs [runTest] on an existing [TestScope].
+ * Performs [runTest] on an existing [TestScope]. See the documentation for [runTest] for details.
*/
@ExperimentalCoroutinesApi
public fun TestScope.runTest(
- dispatchTimeout: Duration,
+ timeout: Duration = DEFAULT_TIMEOUT,
testBody: suspend TestScope.() -> Unit
-): TestResult = asSpecificImplementation().let {
- it.enter()
+): TestResult = asSpecificImplementation().let { scope ->
+ scope.enter()
createTestResult {
- runTestCoroutine(it, dispatchTimeout, TestScopeImpl::tryGetCompletionCause, testBody) {
+ /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on JS. */
+ scope.start(CoroutineStart.UNDISPATCHED, scope) {
+ /* we're using `UNDISPATCHED` to avoid the event loop, but we do want to set up the timeout machinery
+ before any code executes, so we have to park here. */
+ yield()
+ testBody()
+ }
+ var timeoutError: Throwable? = null
+ var cancellationException: CancellationException? = null
+ val workRunner = launch(CoroutineName("kotlinx.coroutines.test runner")) {
+ while (true) {
+ val executedSomething = testScheduler.tryRunNextTaskUnless { !isActive }
+ if (executedSomething) {
+ /** yield to check for cancellation. On JS, we can't use [ensureActive] here, as the cancellation
+ * procedure needs a chance to run concurrently. */
+ yield()
+ } else {
+ // waiting for the next task to be scheduled, or for the test runner to be cancelled
+ testScheduler.receiveDispatchEvent()
+ }
+ }
+ }
+ try {
+ withTimeout(timeout) {
+ coroutineContext.job.invokeOnCompletion(onCancelling = true) { exception ->
+ if (exception is TimeoutCancellationException) {
+ dumpCoroutines()
+ val activeChildren = scope.children.filter(Job::isActive).toList()
+ val completionCause = if (scope.isCancelled) scope.tryGetCompletionCause() else null
+ var message = "After waiting for $timeout"
+ if (completionCause == null)
+ message += ", the test coroutine is not completing"
+ if (activeChildren.isNotEmpty())
+ message += ", there were active child jobs: $activeChildren"
+ if (completionCause != null && activeChildren.isEmpty()) {
+ message += if (scope.isCompleted)
+ ", the test coroutine completed"
+ else
+ ", the test coroutine was not completed"
+ }
+ timeoutError = UncompletedCoroutinesError(message)
+ cancellationException = CancellationException("The test timed out")
+ (scope as Job).cancel(cancellationException!!)
+ }
+ }
+ scope.join()
+ workRunner.cancelAndJoin()
+ }
+ } catch (_: TimeoutCancellationException) {
+ scope.join()
+ val completion = scope.getCompletionExceptionOrNull()
+ if (completion != null && completion !== cancellationException) {
+ timeoutError!!.addSuppressed(completion)
+ }
+ workRunner.cancelAndJoin()
+ } finally {
backgroundScope.cancel()
testScheduler.advanceUntilIdleOr { false }
- it.leave()
+ val uncaughtExceptions = scope.leave()
+ throwAll(timeoutError ?: scope.getCompletionExceptionOrNull(), uncaughtExceptions)
}
}
}
/**
* Performs [runTest] on an existing [TestScope].
+ *
+ * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due
+ * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait
+ * for [dispatchTimeoutMs] from the moment when [TestCoroutineScheduler] becomes
+ * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a
+ * task during that time, the timer gets reset.
*/
-@ExperimentalCoroutinesApi
-public fun TestScope.runTest(
- dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,
- testBody: suspend TestScope.() -> Unit
-): TestResult = runTest(
- dispatchTimeout = dispatchTimeoutMs.milliseconds,
- testBody = testBody
+@Deprecated(
+ "Define a total timeout for the whole test instead of using dispatchTimeoutMs. " +
+ "Warning: the proposed replacement is not identical as it uses 'dispatchTimeoutMs' as the timeout for the whole test!",
+ ReplaceWith("this.runTest(timeout = dispatchTimeoutMs.milliseconds, testBody)",
+ "kotlin.time.Duration.Companion.milliseconds"),
+ DeprecationLevel.WARNING
)
+public fun TestScope.runTest(
+ dispatchTimeoutMs: Long,
+ testBody: suspend TestScope.() -> Unit
+): TestResult = asSpecificImplementation().let {
+ it.enter()
+ @Suppress("DEPRECATION")
+ createTestResult {
+ runTestCoroutineLegacy(it, dispatchTimeoutMs.milliseconds, TestScopeImpl::tryGetCompletionCause, testBody) {
+ backgroundScope.cancel()
+ testScheduler.advanceUntilIdleOr { false }
+ it.legacyLeave()
+ }
+ }
+}
/**
* Runs [testProcedure], creating a [TestResult].
@@ -327,18 +419,23 @@
/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by
* a [TestCoroutineScheduler]. */
internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
-internal val DEFAULT_DISPATCH_TIMEOUT = DEFAULT_DISPATCH_TIMEOUT_MS.milliseconds
+
+/**
+ * The default timeout to use when running a test.
+ */
+internal val DEFAULT_TIMEOUT = 10.seconds
/**
* Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most
- * [dispatchTimeout], and performing the [cleanup] procedure at the end.
+ * [dispatchTimeout] and performing the [cleanup] procedure at the end.
*
* [tryGetCompletionCause] is the [JobSupport.completionCause], which is passed explicitly because it is protected.
*
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
* return a list of uncaught exceptions that should be reported at the end of the test.
*/
-internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutine(
+@Deprecated("Used for support of legacy behavior")
+internal suspend fun <T : AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutineLegacy(
coroutine: T,
dispatchTimeout: Duration,
tryGetCompletionCause: T.() -> Throwable?,
@@ -351,6 +448,8 @@
testBody()
}
/**
+ * This is the legacy behavior, kept for now for compatibility only.
+ *
* The general procedure here is as follows:
* 1. Try running the work that the scheduler knows about, both background and foreground.
*
@@ -376,16 +475,22 @@
scheduler.advanceUntilIdle()
if (coroutine.isCompleted) {
/* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no
- non-trivial dispatches. */
+ non-trivial dispatches. */
completed = true
continue
}
// in case progress depends on some background work, we need to keep spinning it.
val backgroundWorkRunner = launch(CoroutineName("background work runner")) {
while (true) {
- scheduler.tryRunNextTaskUnless { !isActive }
- // yield so that the `select` below has a chance to check if its conditions are fulfilled
- yield()
+ val executedSomething = scheduler.tryRunNextTaskUnless { !isActive }
+ if (executedSomething) {
+ // yield so that the `select` below has a chance to finish successfully or time out
+ yield()
+ } else {
+ // no more tasks, we should suspend until there are some more.
+ // this doesn't interfere with the `select` below, because different channels are used.
+ scheduler.receiveDispatchEvent()
+ }
}
}
try {
@@ -394,11 +499,11 @@
// observe that someone completed the test coroutine and leave without waiting for the timeout
completed = true
}
- scheduler.onDispatchEvent {
+ scheduler.onDispatchEventForeground {
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
}
onTimeout(dispatchTimeout) {
- handleTimeout(coroutine, dispatchTimeout, tryGetCompletionCause, cleanup)
+ throw handleTimeout(coroutine, dispatchTimeout, tryGetCompletionCause, cleanup)
}
}
} finally {
@@ -412,21 +517,20 @@
// it's normal that some jobs are not completed if the test body has failed, won't clutter the output
emptyList()
}
- (listOf(exception) + exceptions).throwAll()
+ throwAll(exception, exceptions)
}
- cleanup().throwAll()
+ throwAll(null, cleanup())
}
/**
- * Invoked on timeout in [runTest]. Almost always just builds a nice [UncompletedCoroutinesError] and throws it.
- * However, sometimes it detects that the coroutine completed, in which case it returns normally.
+ * Invoked on timeout in [runTest]. Just builds a nice [UncompletedCoroutinesError] and returns it.
*/
-private inline fun<T: AbstractCoroutine<Unit>> handleTimeout(
+private inline fun <T : AbstractCoroutine<Unit>> handleTimeout(
coroutine: T,
dispatchTimeout: Duration,
tryGetCompletionCause: T.() -> Throwable?,
cleanup: () -> List<Throwable>,
-) {
+): AssertionError {
val uncaughtExceptions = try {
cleanup()
} catch (e: UncompletedCoroutinesError) {
@@ -441,20 +545,29 @@
if (activeChildren.isNotEmpty())
message += ", there were active child jobs: $activeChildren"
if (completionCause != null && activeChildren.isEmpty()) {
- if (coroutine.isCompleted)
- return
- // TODO: can this really ever happen?
- message += ", the test coroutine was not completed"
+ message += if (coroutine.isCompleted)
+ ", the test coroutine completed"
+ else
+ ", the test coroutine was not completed"
}
val error = UncompletedCoroutinesError(message)
completionCause?.let { cause -> error.addSuppressed(cause) }
uncaughtExceptions.forEach { error.addSuppressed(it) }
- throw error
+ return error
}
-internal fun List<Throwable>.throwAll() {
- firstOrNull()?.apply {
- drop(1).forEach { addSuppressed(it) }
- throw this
+internal fun throwAll(head: Throwable?, other: List<Throwable>) {
+ if (head != null) {
+ other.forEach { head.addSuppressed(it) }
+ throw head
+ } else {
+ with(other) {
+ firstOrNull()?.apply {
+ drop(1).forEach { addSuppressed(it) }
+ throw this
+ }
+ }
}
}
+
+internal expect fun dumpCoroutines()
diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
index 5f7198c..cdb669c 100644
--- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
+++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt
@@ -13,6 +13,7 @@
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.
@@ -49,6 +50,9 @@
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)
@@ -73,8 +77,8 @@
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 [onDispatchEvent] could consume the
+ * token sent here before there's actually anything in the event queue. */
sendDispatchEvent(context)
DisposableHandle {
synchronized(lock) {
@@ -150,13 +154,22 @@
* * 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) {
- 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 IllegalArgumentException if passed a negative [delay][delayTime].
+ */
+ @ExperimentalCoroutinesApi
+ 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
@@ -191,16 +204,27 @@
* [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)
}
/**
+ * Waits for a notification about a dispatch event.
+ */
+ internal suspend fun receiveDispatchEvent() = dispatchEvents.receive()
+
+ /**
* Consumes the knowledge that a dispatch event happened recently.
*/
internal val onDispatchEvent: SelectClause1<Unit> get() = dispatchEvents.onReceive
/**
+ * Consumes the knowledge that a foreground work dispatch event happened recently.
+ */
+ internal val onDispatchEventForeground: SelectClause1<Unit> get() = dispatchEventsForeground.onReceive
+
+ /**
* Returns the [TimeSource] representation of the virtual time of this scheduler.
*/
@ExperimentalCoroutinesApi
diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt
index 15d48a2..a301ff9 100644
--- a/kotlinx-coroutines-test/common/src/TestScope.kt
+++ b/kotlinx-coroutines-test/common/src/TestScope.kt
@@ -52,9 +52,9 @@
* 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.
@@ -124,6 +124,16 @@
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].
+ * @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
*/
@@ -230,8 +240,15 @@
}
}
+ /** 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
diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt
index 0315543..183eb8c 100644
--- a/kotlinx-coroutines-test/common/test/RunTestTest.kt
+++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt
@@ -9,6 +9,8 @@
import kotlinx.coroutines.flow.*
import kotlin.coroutines.*
import kotlin.test.*
+import kotlin.time.*
+import kotlin.time.Duration.Companion.milliseconds
class RunTestTest {
@@ -52,7 +54,7 @@
/** 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)
@@ -71,8 +73,13 @@
/** 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) {
@@ -83,6 +90,48 @@
}
}
+ /**
+ * 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
@@ -119,26 +168,33 @@
} 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)
}
@@ -153,13 +209,13 @@
} 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)
@@ -324,7 +380,7 @@
}
}
- /** 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()
@@ -349,7 +405,7 @@
* 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) }
}
}
diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt
index 9e9c93e..280d668 100644
--- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt
+++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt
@@ -20,7 +20,7 @@
@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`. */
diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt
index d050e9c..7203dbd 100644
--- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt
+++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt
@@ -8,6 +8,7 @@
import kotlin.test.*
import kotlin.time.*
import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Duration.Companion.milliseconds
class TestCoroutineSchedulerTest {
/** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */
@@ -28,7 +29,7 @@
delay(15)
entered = true
}
- testScheduler.advanceTimeBy(15)
+ testScheduler.advanceTimeBy(15.milliseconds)
assertFalse(entered)
testScheduler.runCurrent()
assertTrue(entered)
@@ -39,7 +40,7 @@
fun testAdvanceTimeByWithNegativeDelay() {
val scheduler = TestCoroutineScheduler()
assertFailsWith<IllegalArgumentException> {
- scheduler.advanceTimeBy(-1)
+ scheduler.advanceTimeBy((-1).milliseconds)
}
}
@@ -65,7 +66,7 @@
assertEquals(Long.MAX_VALUE - 1, currentTime)
enteredNearInfinity = true
}
- testScheduler.advanceTimeBy(Long.MAX_VALUE)
+ testScheduler.advanceTimeBy(Duration.INFINITE)
assertFalse(enteredInfinity)
assertTrue(enteredNearInfinity)
assertEquals(Long.MAX_VALUE, currentTime)
@@ -95,10 +96,10 @@
}
assertEquals(1, stage)
assertEquals(0, currentTime)
- advanceTimeBy(2_000)
+ advanceTimeBy(2.seconds)
assertEquals(3, stage)
assertEquals(2_000, currentTime)
- advanceTimeBy(2)
+ advanceTimeBy(2.milliseconds)
assertEquals(4, stage)
assertEquals(2_002, currentTime)
}
@@ -120,11 +121,11 @@
delay(1)
stage += 10
}
- testScheduler.advanceTimeBy(1)
+ testScheduler.advanceTimeBy(1.milliseconds)
assertEquals(0, stage)
runCurrent()
assertEquals(2, stage)
- testScheduler.advanceTimeBy(1)
+ testScheduler.advanceTimeBy(1.milliseconds)
assertEquals(2, stage)
runCurrent()
assertEquals(22, stage)
@@ -143,10 +144,10 @@
delay(SLOW)
stage = 3
}
- scheduler.advanceTimeBy(SLOW)
+ scheduler.advanceTimeBy(SLOW.milliseconds)
stage = 2
}
- scheduler.advanceTimeBy(SLOW)
+ scheduler.advanceTimeBy(SLOW.milliseconds)
assertEquals(1, stage)
scheduler.runCurrent()
assertEquals(2, stage)
@@ -249,7 +250,7 @@
}
}
advanceUntilIdle()
- asSpecificImplementation().leave().throwAll()
+ throwAll(null, asSpecificImplementation().legacyLeave())
if (timesOut)
assertTrue(caughtException)
else
diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt
index d46e5a2..a21e916 100644
--- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt
+++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt
@@ -9,6 +9,7 @@
import kotlinx.coroutines.flow.*
import kotlin.coroutines.*
import kotlin.test.*
+import kotlin.time.Duration.Companion.milliseconds
class TestScopeTest {
/** Tests failing to create a [TestScope] with incorrect contexts. */
@@ -95,7 +96,7 @@
}
assertFalse(result)
scope.asSpecificImplementation().enter()
- assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().leave() }
+ assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().legacyLeave() }
assertFalse(result)
}
@@ -111,7 +112,7 @@
}
assertFalse(result)
scope.asSpecificImplementation().enter()
- assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().leave() }
+ assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().legacyLeave() }
assertFalse(result)
}
@@ -128,7 +129,7 @@
job.cancel()
assertFalse(result)
scope.asSpecificImplementation().enter()
- assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().leave() }
+ assertFailsWith<UncompletedCoroutinesError> { scope.asSpecificImplementation().legacyLeave() }
assertFalse(result)
}
@@ -162,7 +163,7 @@
launch(SupervisorJob()) { throw TestException("y") }
launch(SupervisorJob()) { throw TestException("z") }
runCurrent()
- val e = asSpecificImplementation().leave()
+ val e = asSpecificImplementation().legacyLeave()
assertEquals(3, e.size)
assertEquals("x", e[0].message)
assertEquals("y", e[1].message)
@@ -249,7 +250,7 @@
assertEquals(1, j)
}
job.join()
- advanceTimeBy(199) // should work the same for the background tasks
+ advanceTimeBy(199.milliseconds) // should work the same for the background tasks
assertEquals(2, i)
assertEquals(4, j)
advanceUntilIdle() // once again, should do nothing
@@ -377,7 +378,7 @@
}
}) {
- runTest(dispatchTimeoutMs = 100) {
+ runTest(timeout = 100.milliseconds) {
backgroundScope.launch {
while (true) {
yield()
@@ -407,7 +408,7 @@
}
}) {
- runTest(UnconfinedTestDispatcher(), dispatchTimeoutMs = 100) {
+ runTest(UnconfinedTestDispatcher(), timeout = 100.milliseconds) {
/**
* Having a coroutine like this will still cause the test to hang:
backgroundScope.launch {
diff --git a/kotlinx-coroutines-test/js/src/TestBuilders.kt b/kotlinx-coroutines-test/js/src/TestBuilders.kt
index 9da91ff..97c9da0 100644
--- a/kotlinx-coroutines-test/js/src/TestBuilders.kt
+++ b/kotlinx-coroutines-test/js/src/TestBuilders.kt
@@ -13,3 +13,5 @@
GlobalScope.promise {
testProcedure()
}
+
+internal actual fun dumpCoroutines() { }
diff --git a/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt
index 06fbe81..0521fd2 100644
--- a/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt
+++ b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt
@@ -4,6 +4,7 @@
package kotlinx.coroutines.test
import kotlinx.coroutines.*
+import kotlinx.coroutines.debug.internal.*
@Suppress("ACTUAL_WITHOUT_EXPECT")
public actual typealias TestResult = Unit
@@ -13,3 +14,16 @@
testProcedure()
}
}
+
+internal actual fun dumpCoroutines() {
+ @Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+ if (DebugProbesImpl.isInstalled) {
+ DebugProbesImpl.install()
+ try {
+ DebugProbesImpl.dumpCoroutines(System.err)
+ System.err.flush()
+ } finally {
+ DebugProbesImpl.uninstall()
+ }
+ }
+}
diff --git a/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt
index c0d6c17..c7ef9cc 100644
--- a/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt
+++ b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt
@@ -50,10 +50,12 @@
* then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively.
* @param testBody The code of the unit-test.
*/
-@Deprecated("Use `runTest` instead to support completing from other dispatchers. " +
- "Please see the migration guide for details: " +
- "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
- level = DeprecationLevel.WARNING)
+@Deprecated(
+ "Use `runTest` instead to support completing from other dispatchers. " +
+ "Please see the migration guide for details: " +
+ "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
+ level = DeprecationLevel.WARNING
+)
// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
public fun runBlockingTest(
context: CoroutineContext = EmptyCoroutineContext,
@@ -91,20 +93,20 @@
val throwable = try {
scope.getCompletionExceptionOrNull()
} catch (e: IllegalStateException) {
- null // the deferred was not completed yet; `scope.leave()` should complain then about unfinished jobs
+ null // the deferred was not completed yet; `scope.legacyLeave()` should complain then about unfinished jobs
}
scope.backgroundScope.cancel()
scope.testScheduler.advanceUntilIdleOr { false }
throwable?.let {
val exceptions = try {
- scope.leave()
+ scope.legacyLeave()
} catch (e: UncompletedCoroutinesError) {
listOf()
}
- (listOf(it) + exceptions).throwAll()
+ throwAll(it, exceptions)
return
}
- scope.leave().throwAll()
+ throwAll(null, scope.legacyLeave())
val jobs = completeContext.activeJobs() - startJobs
if (jobs.isNotEmpty())
throw UncompletedCoroutinesError("Some jobs were not completed at the end of the test: $jobs")
@@ -118,10 +120,12 @@
* [migration guide](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md)
* for an instruction on how to update the code for the new API.
*/
-@Deprecated("Use `runTest` instead to support completing from other dispatchers. " +
- "Please see the migration guide for details: " +
- "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
- level = DeprecationLevel.WARNING)
+@Deprecated(
+ "Use `runTest` instead to support completing from other dispatchers. " +
+ "Please see the migration guide for details: " +
+ "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
+ level = DeprecationLevel.WARNING
+)
// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
runBlockingTest(coroutineContext, block)
@@ -142,10 +146,12 @@
* [migration guide](https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md)
* for an instruction on how to update the code for the new API.
*/
-@Deprecated("Use `runTest` instead to support completing from other dispatchers. " +
- "Please see the migration guide for details: " +
- "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
- level = DeprecationLevel.WARNING)
+@Deprecated(
+ "Use `runTest` instead to support completing from other dispatchers. " +
+ "Please see the migration guide for details: " +
+ "https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md",
+ level = DeprecationLevel.WARNING
+)
// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
runBlockingTest(this, block)
@@ -165,7 +171,12 @@
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
return createTestResult {
- runTestCoroutine(testScope, dispatchTimeoutMs.milliseconds, TestBodyCoroutine::tryGetCompletionCause, testBody) {
+ runTestCoroutineLegacy(
+ testScope,
+ dispatchTimeoutMs.milliseconds,
+ TestBodyCoroutine::tryGetCompletionCause,
+ testBody
+ ) {
try {
testScope.cleanup()
emptyList()
diff --git a/kotlinx-coroutines-test/jvm/test/DumpOnTimeoutTest.kt b/kotlinx-coroutines-test/jvm/test/DumpOnTimeoutTest.kt
new file mode 100644
index 0000000..814e5f0
--- /dev/null
+++ b/kotlinx-coroutines-test/jvm/test/DumpOnTimeoutTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2016-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
+ */
+
+package kotlinx.coroutines.test
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.debug.*
+import org.junit.Test
+import java.io.*
+import kotlin.test.*
+import kotlin.time.Duration.Companion.milliseconds
+
+class DumpOnTimeoutTest {
+ /**
+ * Tests that the dump on timeout contains the correct stacktrace.
+ */
+ @Test
+ fun testDumpOnTimeout() {
+ val oldErr = System.err
+ val baos = ByteArrayOutputStream()
+ try {
+ System.setErr(PrintStream(baos, true))
+ DebugProbes.withDebugProbes {
+ try {
+ runTest(timeout = 100.milliseconds) {
+ uniquelyNamedFunction()
+ }
+ throw IllegalStateException("unreachable")
+ } catch (e: UncompletedCoroutinesError) {
+ // do nothing
+ }
+ }
+ baos.toString().let {
+ assertTrue(it.contains("uniquelyNamedFunction"), "Actual trace:\n$it")
+ }
+ } finally {
+ System.setErr(oldErr)
+ }
+ }
+
+ fun CoroutineScope.uniquelyNamedFunction() {
+ while (true) {
+ ensureActive()
+ Thread.sleep(10)
+ }
+ }
+}
diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt
index 90a16d0..2ac577c 100644
--- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt
+++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt
@@ -99,14 +99,24 @@
}
}
- /** Tests that [StandardTestDispatcher] is confined to the thread that interacts with the scheduler. */
+ /** Tests that [StandardTestDispatcher] is not executed in-place but confined to the thread in which the
+ * virtual time control happens. */
@Test
- fun testStandardTestDispatcherIsConfined() = runTest {
+ fun testStandardTestDispatcherIsConfined(): Unit = runBlocking {
+ val scheduler = TestCoroutineScheduler()
val initialThread = Thread.currentThread()
- withContext(Dispatchers.IO) {
- val ioThread = Thread.currentThread()
- assertNotSame(initialThread, ioThread)
+ val job = launch(StandardTestDispatcher(scheduler)) {
+ assertEquals(initialThread, Thread.currentThread())
+ withContext(Dispatchers.IO) {
+ val ioThread = Thread.currentThread()
+ assertNotSame(initialThread, ioThread)
+ }
+ assertEquals(initialThread, Thread.currentThread())
}
- assertEquals(initialThread, Thread.currentThread())
+ scheduler.advanceUntilIdle()
+ while (job.isActive) {
+ scheduler.receiveDispatchEvent()
+ scheduler.advanceUntilIdle()
+ }
}
}
diff --git a/kotlinx-coroutines-test/native/src/TestBuilders.kt b/kotlinx-coroutines-test/native/src/TestBuilders.kt
index a959901..607dec6 100644
--- a/kotlinx-coroutines-test/native/src/TestBuilders.kt
+++ b/kotlinx-coroutines-test/native/src/TestBuilders.kt
@@ -4,6 +4,7 @@
package kotlinx.coroutines.test
import kotlinx.coroutines.*
+import kotlin.native.concurrent.*
@Suppress("ACTUAL_WITHOUT_EXPECT")
public actual typealias TestResult = Unit
@@ -13,3 +14,5 @@
testProcedure()
}
}
+
+internal actual fun dumpCoroutines() { }