diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 53aa355c5b..153e4bbfc5 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -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 } @@ -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 ()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; @@ -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; diff --git a/kotlinx-coroutines-test/build.gradle.kts b/kotlinx-coroutines-test/build.gradle.kts index 98d3e9fa74..c968fc4991 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 @@ kotlin { 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 e9d8fec0d3..de16967a41 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 @@ public expect class TestResult * * 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 @@ public expect class TestResult * 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 @@ public expect class TestResult @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 @@ public fun runTest( * * @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 +@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 = DEFAULT_DISPATCH_TIMEOUT_MS, + dispatchTimeoutMs: Long, testBody: suspend TestScope.() -> Unit -): TestResult = runTest( - dispatchTimeout = dispatchTimeoutMs.milliseconds, - testBody = testBody -) +): 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 @@ internal object RunningInRunTest : CoroutineContext.Key, Corou /** 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 > CoroutineScope.runTestCoroutine( +@Deprecated("Used for support of legacy behavior") +internal suspend fun > CoroutineScope.runTestCoroutineLegacy( coroutine: T, dispatchTimeout: Duration, tryGetCompletionCause: T.() -> Throwable?, @@ -351,6 +448,8 @@ internal suspend fun > CoroutineScope.runTestCoroutin 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 @@ internal suspend fun > CoroutineScope.runTestCoroutin 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 @@ internal suspend fun > CoroutineScope.runTestCoroutin // 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 @@ internal suspend fun > CoroutineScope.runTestCoroutin // 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> handleTimeout( +private inline fun > handleTimeout( coroutine: T, dispatchTimeout: Duration, tryGetCompletionCause: T.() -> Throwable?, cleanup: () -> List, -) { +): AssertionError { val uncaughtExceptions = try { cleanup() } catch (e: UncompletedCoroutinesError) { @@ -441,20 +545,29 @@ private inline fun> handleTimeout( 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.throwAll() { - firstOrNull()?.apply { - drop(1).forEach { addSuppressed(it) } - throw this +internal fun throwAll(head: Throwable?, other: List) { + 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 5f7198cfff..cdb669c0b3 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -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. @@ -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 = Channel(CONFLATED) + /** A channel for notifying about the fact that a dispatch recently happened. */ private val dispatchEvents: Channel = Channel(CONFLATED) @@ -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 [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 @@ 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 @@ -191,15 +204,26 @@ 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) } + /** + * 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 get() = dispatchEvents.onReceive + /** + * Consumes the knowledge that a foreground work dispatch event happened recently. + */ + internal val onDispatchEventForeground: SelectClause1 get() = dispatchEventsForeground.onReceive + /** * Returns the [TimeSource] representation of the virtual time of this scheduler. */ diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index 15d48a2ae2..a301ff966b 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -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. @@ -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]. + * @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 @@ 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 = synchronized(lock) { + check(entered && !finished) + finished = true + uncaughtExceptions + } + /** Called at the end of the test. May only be called once. */ - fun leave(): List { + fun legacyLeave(): List { 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 0315543d54..183eb8cb3a 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -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 { @@ -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) @@ -71,8 +73,13 @@ class RunTestTest { /** Tests that too low of a dispatch timeout causes crashes. */ @Test - fun testRunTestWithSmallTimeout() = testResultMap({ fn -> - assertFailsWith { fn() } + fun testRunTestWithSmallDispatchTimeout() = testResultMap({ fn -> + try { + fn() + fail("shouldn't be reached") + } catch (e: Throwable) { + assertIs(e) + } }) { runTest(dispatchTimeoutMs = 100) { withContext(Dispatchers.Default) { @@ -83,6 +90,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(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(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 @@ class RunTestTest { } catch (e: UncompletedCoroutinesError) { @Suppress("INVISIBLE_MEMBER") val suppressed = unwrap(e).suppressedExceptions - assertEquals(1, suppressed.size) + assertEquals(1, suppressed.size, "$suppressed") assertIs(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 @@ class RunTestTest { } catch (e: UncompletedCoroutinesError) { @Suppress("INVISIBLE_MEMBER") val suppressed = unwrap(e).suppressedExceptions - assertEquals(1, suppressed.size) + assertEquals(1, suppressed.size, "$suppressed") assertIs(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 @@ 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() @@ -349,7 +405,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) } } } diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt index 9e9c93e10e..280d668588 100644 --- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -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`. */ diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index d050e9c8c0..7203dbd270 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.* 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 @@ class TestCoroutineSchedulerTest { delay(15) entered = true } - testScheduler.advanceTimeBy(15) + testScheduler.advanceTimeBy(15.milliseconds) assertFalse(entered) testScheduler.runCurrent() assertTrue(entered) @@ -39,7 +40,7 @@ class TestCoroutineSchedulerTest { fun testAdvanceTimeByWithNegativeDelay() { val scheduler = TestCoroutineScheduler() assertFailsWith { - scheduler.advanceTimeBy(-1) + scheduler.advanceTimeBy((-1).milliseconds) } } @@ -65,7 +66,7 @@ class TestCoroutineSchedulerTest { 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 @@ class TestCoroutineSchedulerTest { } 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 @@ class TestCoroutineSchedulerTest { 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 @@ class TestCoroutineSchedulerTest { 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 @@ class TestCoroutineSchedulerTest { } } 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 45f7f3ef80..9ab6a6140b 100644 --- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.channels.* 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 @@ class TestScopeTest { } assertFalse(result) scope.asSpecificImplementation().enter() - assertFailsWith { scope.asSpecificImplementation().leave() } + assertFailsWith { scope.asSpecificImplementation().legacyLeave() } assertFalse(result) } @@ -111,7 +112,7 @@ class TestScopeTest { } assertFalse(result) scope.asSpecificImplementation().enter() - assertFailsWith { scope.asSpecificImplementation().leave() } + assertFailsWith { scope.asSpecificImplementation().legacyLeave() } assertFalse(result) } @@ -128,7 +129,7 @@ class TestScopeTest { job.cancel() assertFalse(result) scope.asSpecificImplementation().enter() - assertFailsWith { scope.asSpecificImplementation().leave() } + assertFailsWith { scope.asSpecificImplementation().legacyLeave() } assertFalse(result) } @@ -162,7 +163,7 @@ class TestScopeTest { 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 @@ class TestScopeTest { 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 @@ class TestScopeTest { } }) { - runTest(dispatchTimeoutMs = 100) { + runTest(timeout = 100.milliseconds) { backgroundScope.launch { while (true) { yield() @@ -407,7 +408,7 @@ class TestScopeTest { } }) { - 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 9da91ffc39..97c9da0eee 100644 --- a/kotlinx-coroutines-test/js/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/js/src/TestBuilders.kt @@ -13,3 +13,5 @@ internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> 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 06fbe81064..0521fd22ae 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 @@ internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> 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 c0d6c17cb6..c7ef9cc8e7 100644 --- a/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt @@ -50,10 +50,12 @@ import kotlin.time.Duration.Companion.milliseconds * 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 @@ public fun runBlockingTestOnTestScope( 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 @@ public fun runBlockingTestOnTestScope( * [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 @@ public fun TestScope.runBlockingTest(block: suspend TestScope.() -> Unit): Unit * [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 @@ public fun runTestWithLegacyScope( 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 0000000000..814e5f0667 --- /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 90a16d0622..2ac577c41b 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -99,14 +99,24 @@ class MultithreadingTest { } } - /** 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()) + } + scheduler.advanceUntilIdle() + while (job.isActive) { + scheduler.receiveDispatchEvent() + scheduler.advanceUntilIdle() } - assertEquals(initialThread, Thread.currentThread()) } } diff --git a/kotlinx-coroutines-test/native/src/TestBuilders.kt b/kotlinx-coroutines-test/native/src/TestBuilders.kt index a959901919..607dec6a73 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 @@ internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> testProcedure() } } + +internal actual fun dumpCoroutines() { }