From 1e267942374104aed6bb9bf073f7ed833ac4e88f Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 11 Oct 2021 18:06:55 +0300 Subject: [PATCH 01/22] Implement a scheduler for test coroutine dispatchers --- .../jvm/src/scheduling/CoroutineScheduler.kt | 2 - .../src/CoroutinesBlockHoundIntegration.kt | 1 - .../api/kotlinx-coroutines-test.api | 39 +++- .../common/src/DelayController.kt | 55 ++++- .../common/src/TestBuilders.kt | 37 ++- .../common/src/TestCoroutineDispatcher.kt | 154 +++---------- .../common/src/TestCoroutineScheduler.kt | 211 ++++++++++++++++++ .../common/src/TestCoroutineScope.kt | 102 +++++++-- .../common/src/TestDispatcher.kt | 18 ++ .../common/src/TestDispatchers.kt | 7 +- .../common/test/TestBuildersTest.kt | 1 + .../test/TestCoroutineDispatcherTest.kt | 30 +-- .../common/test/TestCoroutineSchedulerTest.kt | 20 ++ .../common/test/TestDispatchersTest.kt | 2 +- .../common/test/TestRunBlockingTest.kt | 8 +- .../jvm/test/MultithreadingTest.kt | 2 +- 16 files changed, 492 insertions(+), 197 deletions(-) create mode 100644 kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt create mode 100644 kotlinx-coroutines-test/common/src/TestDispatcher.kt create mode 100644 kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt diff --git a/kotlinx-coroutines-core/jvm/src/scheduling/CoroutineScheduler.kt b/kotlinx-coroutines-core/jvm/src/scheduling/CoroutineScheduler.kt index 84d9d9f8df..41f759ce8a 100644 --- a/kotlinx-coroutines-core/jvm/src/scheduling/CoroutineScheduler.kt +++ b/kotlinx-coroutines-core/jvm/src/scheduling/CoroutineScheduler.kt @@ -968,7 +968,6 @@ internal class CoroutineScheduler( * Checks if the thread is part of a thread pool that supports coroutines. * This function is needed for integration with BlockHound. */ -@Suppress("UNUSED") @JvmName("isSchedulerWorker") internal fun isSchedulerWorker(thread: Thread) = thread is CoroutineScheduler.Worker @@ -976,7 +975,6 @@ internal fun isSchedulerWorker(thread: Thread) = thread is CoroutineScheduler.Wo * Checks if the thread is running a CPU-bound task. * This function is needed for integration with BlockHound. */ -@Suppress("UNUSED") @JvmName("mayNotBlock") internal fun mayNotBlock(thread: Thread) = thread is CoroutineScheduler.Worker && thread.state == CoroutineScheduler.WorkerState.CPU_ACQUIRED diff --git a/kotlinx-coroutines-debug/src/CoroutinesBlockHoundIntegration.kt b/kotlinx-coroutines-debug/src/CoroutinesBlockHoundIntegration.kt index e7fe1e6c34..9cafffb038 100644 --- a/kotlinx-coroutines-debug/src/CoroutinesBlockHoundIntegration.kt +++ b/kotlinx-coroutines-debug/src/CoroutinesBlockHoundIntegration.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.scheduling.* import reactor.blockhound.* import reactor.blockhound.integration.* -@Suppress("UNUSED") public class CoroutinesBlockHoundIntegration : BlockHoundIntegration { override fun applyTo(builder: BlockHound.Builder): Unit = with(builder) { diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 707ee43df2..b14d5a1574 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -16,8 +16,10 @@ public final class kotlinx/coroutines/test/TestBuildersKt { public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } -public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/DelayController { +public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController { public fun ()V + public fun (Lkotlinx/coroutines/test/TestCoroutineScheduler;)V + public synthetic fun (Lkotlinx/coroutines/test/TestCoroutineScheduler;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun advanceTimeBy (J)J public fun advanceUntilIdle ()J public fun cleanupTestCoroutines ()V @@ -25,6 +27,7 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor public fun dispatch (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V public fun dispatchYield (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V public fun getCurrentTime ()J + public fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle; public fun pauseDispatcher ()V public fun pauseDispatcher (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -41,8 +44,35 @@ public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotli public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V } -public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/DelayController, kotlinx/coroutines/test/UncaughtExceptionCaptor { +public final class kotlinx/coroutines/test/TestCoroutineScheduler : kotlin/coroutines/AbstractCoroutineContextElement, kotlin/coroutines/CoroutineContext$Element { + public static final field Key Lkotlinx/coroutines/test/TestCoroutineScheduler$Key; + public fun ()V + public final fun advanceTimeBy (J)V + public final fun advanceUntilIdle ()V + public final fun getCurrentTime ()J + public final fun runCurrent ()V +} + +public final class kotlinx/coroutines/test/TestCoroutineScheduler$Key : kotlin/coroutines/CoroutineContext$Key { +} + +public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/UncaughtExceptionCaptor { + public abstract fun advanceTimeBy (J)V + public abstract fun advanceUntilIdle ()V public abstract fun cleanupTestCoroutines ()V + public abstract fun getCurrentTime ()J + public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; + public abstract fun pauseDispatcher ()V + public abstract fun pauseDispatcher (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun resumeDispatcher ()V + public abstract fun runCurrent ()V +} + +public final class kotlinx/coroutines/test/TestCoroutineScope$DefaultImpls { + public static fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V + public static fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V + public static fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J + public static fun runCurrent (Lkotlinx/coroutines/test/TestCoroutineScope;)V } public final class kotlinx/coroutines/test/TestCoroutineScopeKt { @@ -50,6 +80,11 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt { public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope; } +public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher { + public fun ()V + public abstract fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; +} + public final class kotlinx/coroutines/test/TestDispatchers { public static final fun resetMain (Lkotlinx/coroutines/Dispatchers;)V public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/DelayController.kt index a4ab8c4aba..d2c64a04ee 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/DelayController.kt @@ -10,9 +10,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi /** * Control the virtual clock time of a [CoroutineDispatcher]. * - * Testing libraries may expose this interface to tests instead of [TestCoroutineDispatcher]. + * Testing libraries may expose this interface to the tests instead of [TestCoroutineDispatcher]. */ @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@Deprecated("Use `TestCoroutineScheduler` to control virtual time.", + level = DeprecationLevel.WARNING) public interface DelayController { /** * Returns the current virtual clock-time as it is known to this Dispatcher. @@ -127,3 +129,54 @@ public interface DelayController { // todo: maybe convert into non-public class in 1.3.0 (need use-cases for a public exception type) @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 public class UncompletedCoroutinesError(message: String): AssertionError(message) + +internal interface SchedulerAsDelayController: DelayController { + public val scheduler: TestCoroutineScheduler + + /** @suppress */ + @Deprecated("This property delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + ReplaceWith("this.scheduler.currentTime"), + level = DeprecationLevel.WARNING) + override val currentTime: Long get() = scheduler.currentTime + + + /** @suppress */ + @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + ReplaceWith("this.scheduler.advanceTimeBy(delayTimeMillis)"), + level = DeprecationLevel.WARNING) + override fun advanceTimeBy(delayTimeMillis: Long): Long { + val oldTime = scheduler.currentTime + scheduler.advanceTimeBy(delayTimeMillis) + scheduler.runCurrent() + return scheduler.currentTime - oldTime + } + + /** @suppress */ + @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + ReplaceWith("this.scheduler.advanceUntilIdle()"), + level = DeprecationLevel.WARNING) + override fun advanceUntilIdle(): Long { + val oldTime = scheduler.currentTime + scheduler.advanceUntilIdle() + return scheduler.currentTime - oldTime + } + + /** @suppress */ + @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + ReplaceWith("this.scheduler.runCurrent()"), + level = DeprecationLevel.WARNING) + override fun runCurrent(): Unit = scheduler.runCurrent() + + /** @suppress */ + @ExperimentalCoroutinesApi + override fun cleanupTestCoroutines() { + // process any pending cancellations or completions, but don't advance time + scheduler.runCurrent() + if (!scheduler.isIdle()) { + throw UncompletedCoroutinesError( + "Unfinished coroutines during teardown. Ensure all coroutines are" + + " completed or cancelled by your test." + ) + } + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index dde9ac7b12..2063e465e5 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -43,13 +43,13 @@ import kotlin.coroutines.* */ @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { - val (safeContext, dispatcher) = context.checkArguments() + val (safeContext, dispatcher) = context.checkTestScopeArguments() val startingJobs = safeContext.activeJobs() val scope = TestCoroutineScope(safeContext) val deferred = scope.async { scope.testBody() } - dispatcher.advanceUntilIdle() + dispatcher.scheduler.advanceUntilIdle() deferred.getCompletionExceptionOrNull()?.let { throw it } @@ -79,17 +79,32 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope. public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) -private fun CoroutineContext.checkArguments(): Pair { +internal fun CoroutineContext.checkTestScopeArguments(): Pair { + val scheduler: TestCoroutineScheduler val dispatcher = when (val dispatcher = get(ContinuationInterceptor)) { - is DelayController -> dispatcher - null -> TestCoroutineDispatcher() - else -> throw IllegalArgumentException("Dispatcher must implement DelayController: $dispatcher") + is TestDispatcher -> { + val ctxScheduler = get(TestCoroutineScheduler) + if (ctxScheduler == null) { + scheduler = dispatcher.scheduler + } else { + require(dispatcher.scheduler === ctxScheduler) { + "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + + "another scheduler were passed." + } + scheduler = ctxScheduler + } + dispatcher + } + null -> { + scheduler = TestCoroutineScheduler() + TestCoroutineDispatcher(scheduler) + } + else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") } - val exceptionHandler = when (val handler = get(CoroutineExceptionHandler)) { - is UncaughtExceptionCaptor -> handler - null -> TestCoroutineExceptionHandler() - else -> throw IllegalArgumentException("coroutineExceptionHandler must implement UncaughtExceptionCaptor: $handler") + val exceptionHandler = get(CoroutineExceptionHandler).run { + this?.let { require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $this" } } + this ?: TestCoroutineExceptionHandler() } val job = get(Job) ?: SupervisorJob() - return Pair(this + dispatcher + exceptionHandler + job, dispatcher) + return Pair(this + scheduler + dispatcher + exceptionHandler + job, dispatcher) } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index 55b92cd6b7..dee02af25f 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -4,20 +4,17 @@ package kotlinx.coroutines.test -import kotlinx.atomicfu.* import kotlinx.coroutines.* -import kotlinx.coroutines.internal.* import kotlin.coroutines.* import kotlin.jvm.* -import kotlin.math.* /** * [CoroutineDispatcher] that performs both immediate and lazy execution of coroutines in tests - * and implements [DelayController] to control its virtual clock. + * and uses a [TestCoroutineScheduler] to control its virtual clock. * * By default, [TestCoroutineDispatcher] is immediate. That means any tasks scheduled to be run without delay are * immediately executed. If they were scheduled with a delay, the virtual clock-time must be advanced via one of the - * methods on [DelayController]. + * methods on the dispatcher's [scheduler]. * * When switched to lazy execution using [pauseDispatcher] any coroutines started via [launch] or [async] will * not execute until a call to [DelayController.runCurrent] or the virtual clock-time has been advanced via one of the @@ -26,27 +23,31 @@ import kotlin.math.* * @see DelayController */ @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 -public class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayController { +public class TestCoroutineDispatcher( + /** + * The scheduler that this dispatcher is linked to. + */ + public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler() +): + TestDispatcher(), Delay, SchedulerAsDelayController +{ private var dispatchImmediately = true set(value) { field = value if (value) { // there may already be tasks from setup code we need to run - advanceUntilIdle() + scheduler.advanceUntilIdle() } } - // The ordered queue for the runnable tasks. - private val queue = ThreadSafeHeap() - - // The per-scheduler global order counter. - private val _counter = atomic(0L) - - // Storing time in nanoseconds internally. - private val _time = atomic(0L) + override fun processEvent(time: Long, marker: Any) { + require(marker is Runnable) + marker.run() + } /** @suppress */ override fun dispatch(context: CoroutineContext, block: Runnable) { + checkSchedulerInContext(scheduler, context) if (dispatchImmediately) { block.run() } else { @@ -57,83 +58,28 @@ public class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayControl /** @suppress */ @InternalCoroutinesApi override fun dispatchYield(context: CoroutineContext, block: Runnable) { + checkSchedulerInContext(scheduler, context) post(block) } /** @suppress */ override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { - postDelayed(CancellableContinuationRunnable(continuation) { resumeUndispatched(Unit) }, timeMillis) + checkSchedulerInContext(scheduler, continuation.context) + val timedRunnable = CancellableContinuationRunnable(continuation, this) + scheduler.registerEvent(this, timeMillis, timedRunnable, ::cancellableRunnableIsCancelled) } /** @suppress */ override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle { - val node = postDelayed(block, timeMillis) - return DisposableHandle { queue.remove(node) } + checkSchedulerInContext(scheduler, context) + return scheduler.registerEvent(this, timeMillis, block) { false } } /** @suppress */ - override fun toString(): String { - return "TestCoroutineDispatcher[currentTime=${currentTime}ms, queued=${queue.size}]" - } + override fun toString(): String = "TestCoroutineDispatcher[scheduler=$scheduler]" private fun post(block: Runnable) = - queue.addLast(TimedRunnable(block, _counter.getAndIncrement())) - - private fun postDelayed(block: Runnable, delayTime: Long) = - TimedRunnable(block, _counter.getAndIncrement(), safePlus(currentTime, delayTime)) - .also { - queue.addLast(it) - } - - private fun safePlus(currentTime: Long, delayTime: Long): Long { - check(delayTime >= 0) - val result = currentTime + delayTime - if (result < currentTime) return Long.MAX_VALUE // clam on overflow - return result - } - - private fun doActionsUntil(targetTime: Long) { - while (true) { - val current = queue.removeFirstIf { it.time <= targetTime } ?: break - // If the scheduled time is 0 (immediate) use current virtual time - if (current.time != 0L) _time.value = current.time - current.run() - } - } - - /** @suppress */ - override val currentTime: Long get() = _time.value - - /** @suppress */ - override fun advanceTimeBy(delayTimeMillis: Long): Long { - val oldTime = currentTime - advanceUntilTime(oldTime + delayTimeMillis) - return currentTime - oldTime - } - - /** - * Moves the CoroutineContext's clock-time to a particular moment in time. - * - * @param targetTime The point in time to which to move the CoroutineContext's clock (milliseconds). - */ - private fun advanceUntilTime(targetTime: Long) { - doActionsUntil(targetTime) - _time.update { currentValue -> max(currentValue, targetTime) } - } - - /** @suppress */ - override fun advanceUntilIdle(): Long { - val oldTime = currentTime - while(!queue.isEmpty) { - runCurrent() - val next = queue.peek() ?: break - advanceUntilTime(next.time) - } - return currentTime - oldTime - } - - /** @suppress */ - override fun runCurrent(): Unit = doActionsUntil(currentTime) + scheduler.registerEvent(this, 0, block) { false } /** @suppress */ override suspend fun pauseDispatcher(block: suspend () -> Unit) { @@ -155,58 +101,18 @@ public class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, DelayControl override fun resumeDispatcher() { dispatchImmediately = true } - - /** @suppress */ - override fun cleanupTestCoroutines() { - // process any pending cancellations or completions, but don't advance time - doActionsUntil(currentTime) - - // run through all pending tasks, ignore any submitted coroutines that are not active - val pendingTasks = mutableListOf() - while (true) { - pendingTasks += queue.removeFirstOrNull() ?: break - } - val activeDelays = pendingTasks - .mapNotNull { it.runnable as? CancellableContinuationRunnable<*> } - .filter { it.continuation.isActive } - - val activeTimeouts = pendingTasks.filter { it.runnable !is CancellableContinuationRunnable<*> } - if (activeDelays.isNotEmpty() || activeTimeouts.isNotEmpty()) { - throw UncompletedCoroutinesError( - "Unfinished coroutines during teardown. Ensure all coroutines are" + - " completed or cancelled by your test." - ) - } - } } /** * This class exists to allow cleanup code to avoid throwing for cancelled continuations scheduled * in the future. */ -private class CancellableContinuationRunnable( - @JvmField val continuation: CancellableContinuation, - private val block: CancellableContinuation.() -> Unit +private class CancellableContinuationRunnable( + @JvmField val continuation: CancellableContinuation, + private val dispatcher: CoroutineDispatcher ) : Runnable { - override fun run() = continuation.block() + override fun run() = with(dispatcher) { with(continuation) { resumeUndispatched(Unit) } } } -/** - * A Runnable for our event loop that represents a task to perform at a time. - */ -private class TimedRunnable( - @JvmField val runnable: Runnable, - private val count: Long = 0, - @JvmField val time: Long = 0 -) : Comparable, Runnable by runnable, ThreadSafeHeapNode { - override var heap: ThreadSafeHeap<*>? = null - override var index: Int = 0 - - override fun compareTo(other: TimedRunnable) = if (time == other.time) { - count.compareTo(other.count) - } else { - time.compareTo(other.time) - } - - override fun toString() = "TimedRunnable(time=$time, run=$runnable)" -} +private fun cancellableRunnableIsCancelled(runnable: CancellableContinuationRunnable): Boolean = + !runnable.continuation.isActive \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt new file mode 100644 index 0000000000..df6022cbba --- /dev/null +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -0,0 +1,211 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.atomicfu.* +import kotlinx.coroutines.* +import kotlinx.coroutines.internal.* +import kotlin.coroutines.* +import kotlin.jvm.* + +/** + * This is a scheduler for coroutines used in tests, providing the delay-skipping behavior. + * + * [Test dispatchers][TestCoroutineDispatcher] are parameterized with a scheduler. Several dispatchers can share the + * same scheduler, in which case * their knowledge about the virtual time will be synchronized. When the dispatchers + * require scheduling an event at a * later point in time, they notify the scheduler, which will establish the order of + * the tasks. + * + * The scheduler can be queried to advance the time (via [advanceTimeBy]), run all the scheduled tasks advancing the + * 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 +// TODO: maybe make this a `TimeSource`? +public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCoroutineScheduler), CoroutineContext.Element { + + public companion object Key: CoroutineContext.Key + + /** This heap stores the knowledge about which dispatchers are interested in which moments of virtual time. */ + // TODO: all the synchronization is done via a separate lock, so a non-thread-safe priority queue can be used. + private val events = ThreadSafeHeap>() + + /** Establishes that [currentTime] can't exceed the time of the earliest event in [events]. */ + private val lock = SynchronizedObject() + + /** This counter establishes some order on the events that happen at the same virtual time. */ + private val count = atomic(0L) + + /** The current virtual time. */ + public var currentTime: Long = 0 + private set + + /** + * Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds later + * via [TestDispatcher.processEvent], which will be called with the provided [marker] object. + * + * Returns the handler which can be used to cancel the registration. + */ + internal fun registerEvent( + dispatcher: TestDispatcher, + timeDeltaMillis: Long, + marker: T, + isCancelled : (T) -> Boolean + ): DisposableHandle { + require(timeDeltaMillis >= 0) { "Attempted scheduling an event earlier in time (with the time delta $timeDeltaMillis)" } + val count = count.getAndIncrement() + return synchronized(lock) { + val time = addClamping(currentTime, timeDeltaMillis) + val event = TestDispatchEvent(dispatcher, count, time, marker as Any) { isCancelled(marker) } + events.addLast(event) + DisposableHandle { + synchronized(lock) { + events.remove(event) + } + } + } + } + + /** + * Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more + * tasks associated with the dispatchers linked to this scheduler. + * + * A breaking change from [TestCoroutineDispatcher.advanceTimeBy] is that it no longer returns the total amount of + * 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() { + while (!events.isEmpty) { + val event = synchronized(lock) { + val event = events.removeFirstOrNull() ?: return + if (currentTime > event.time) + currentTimeAheadOfEvents() + currentTime = event.time + event + } + event.dispatcher.processEvent(event.time, event.marker) + } + } + + /** + * Runs the tasks that are scheduled to execute at this moment of virtual time. + */ + @ExperimentalCoroutinesApi + public fun runCurrent() { + while (true) { + val event = synchronized(lock) { + val timeMark = currentTime + val event = events.peek() ?: return + when { + timeMark > event.time -> currentTimeAheadOfEvents() + timeMark < event.time -> return + else -> { + val event2 = events.removeFirstOrNull() + if (event !== event2) concurrentModificationUnderLock() + event2 + } + } + } + event.dispatcher.processEvent(event.time, event.marker) + } + } + + /** + * Moves the virtual clock of this dispatcher forward by [the specified amount][delayTimeMillis], running the + * scheduled tasks in the meantime. + * + * Breaking changes from [TestCoroutineDispatcher.advanceTimeBy]: + * * Intentionally doesn't return a `Long` value, as its use cases are unclear. We may restore it in the future; + * please describe your use cases at [the issue tracker](https://github.com/Kotlin/kotlinx.coroutines/issues/). + * For now, it's possible to query [currentTime] before and after execution of this method, to the same effect. + * * It doesn't run the tasks that are scheduled at exactly [currentTime] + [delayTimeMillis]. For example, + * advancing the time by one millisecond used to run the tasks at the current millisecond *and* the next + * millisecond, but now will stop just before executing any task starting at the next millisecond. + * * 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]. + */ + @ExperimentalCoroutinesApi + public fun advanceTimeBy(delayTimeMillis: Long) { + require(delayTimeMillis >= 0) { "" } + val startingTime = currentTime + val targetTime = addClamping(startingTime, delayTimeMillis) + while (true) { + val event = synchronized(lock) { + val timeMark = currentTime + val event = events.peek() + when { + event == null || targetTime <= event.time -> { + currentTime = targetTime + return + } + timeMark > event.time -> currentTimeAheadOfEvents() + else -> { + val event2 = events.removeFirstOrNull() + if (event !== event2) concurrentModificationUnderLock() + currentTime = event.time + event2 + } + } + } + event.dispatcher.processEvent(event.time, event.marker) + } + } + + /** + * Checks that the only tasks remaining in the scheduler are cancelled. + */ + // TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap] + internal fun isIdle(): Boolean { + synchronized(lock) { + val presentEvents = mutableListOf>() + while (true) { + presentEvents += events.removeFirstOrNull() ?: break + } + return presentEvents.all { it.isCancelled() } + } + } +} + +// Some error-throwing functions for pretty stack traces +private fun currentTimeAheadOfEvents(): Nothing = invalidSchedulerState() +private fun concurrentModificationUnderLock(): Nothing = invalidSchedulerState() + +private fun invalidSchedulerState(): Nothing = + throw IllegalStateException("The test scheduler entered an invalid state. Please report this at https://github.com/Kotlin/kotlinx.coroutines/issues.") + +/** [ThreadSafeHeap] node representing a scheduled task, ordered by the planned execution time. */ +private class TestDispatchEvent( + @JvmField val dispatcher: TestDispatcher, + private val count: Long, + @JvmField val time: Long, + @JvmField val marker: T, + val isCancelled: () -> Boolean +) : Comparable>, ThreadSafeHeapNode { + override var heap: ThreadSafeHeap<*>? = null + override var index: Int = 0 + + override fun compareTo(other: TestDispatchEvent<*>) = if (time == other.time) { + count.compareTo(other.count) + } else { + time.compareTo(other.time) + } + + override fun toString() = "TestDispatchEvent(time=$time, dispatcher=$dispatcher)" +} + +// works with positive `a`, `b` +private fun addClamping(a: Long, b: Long): Long = (a + b).let { if (it >= 0) it else Long.MAX_VALUE } + +internal fun checkSchedulerInContext(scheduler: TestCoroutineScheduler, context: CoroutineContext) { + context[TestCoroutineScheduler]?.let { + check(it === scheduler) { + "Detected use of different schedulers. If you need to use several test coroutine dispatchers, " + + "create one `TestCoroutineScheduler` and pass it to each of them." + } + } +} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index da29cd22b4..18234e47fe 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -11,7 +11,7 @@ import kotlin.coroutines.* * A scope which provides detailed control over the execution of coroutines for tests. */ @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 -public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, DelayController { +public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { /** * Call after the test completes. * Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines]. @@ -20,20 +20,93 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, De * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended * coroutines. */ - public override fun cleanupTestCoroutines() + public fun cleanupTestCoroutines() + + /** + * The delay-skipping scheduler used by the test dispatchers running the code in this scope. + */ + @ExperimentalCoroutinesApi + public val testScheduler: TestCoroutineScheduler + + /** + * The current virtual time on [testScheduler]. + * @see TestCoroutineScheduler.currentTime + */ + @ExperimentalCoroutinesApi + public val currentTime: Long + get() = testScheduler.currentTime + + /** + * Advances the [testScheduler] by [delayTimeMillis]. + * + * Historical note: this method used to also run the tasks scheduled on the next millisecond after the delay; this + * behavior is no longer present, so call [runCurrent] afterwards if you need those tasks to run. + * + * @see TestCoroutineScheduler.advanceTimeBy + */ + @ExperimentalCoroutinesApi + public fun advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis) + + /** + * Advances the [testScheduler] to the point where there are no tasks remaining. + * @see TestCoroutineScheduler.advanceUntilIdle + */ + @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + public fun advanceUntilIdle(): Unit = testScheduler.advanceUntilIdle() + + /** + * Run any tasks that are pending at the current virtual time. + * @see TestCoroutineScheduler.runCurrent + */ + @ExperimentalCoroutinesApi + public fun runCurrent(): Unit = testScheduler.runCurrent() + + @ExperimentalCoroutinesApi + @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", level = DeprecationLevel.WARNING) + public suspend fun pauseDispatcher(block: suspend () -> Unit) + + @ExperimentalCoroutinesApi + @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", level = DeprecationLevel.WARNING) + public fun pauseDispatcher() + + @ExperimentalCoroutinesApi + @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", level = DeprecationLevel.WARNING) + public fun resumeDispatcher() } private class TestCoroutineScopeImpl ( - override val coroutineContext: CoroutineContext + override val coroutineContext: CoroutineContext, + override val testScheduler: TestCoroutineScheduler ): TestCoroutineScope, - UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor, - DelayController by coroutineContext.delayController + UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor { override fun cleanupTestCoroutines() { coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() - coroutineContext.delayController.cleanupTestCoroutines() + coroutineContext.delayController?.cleanupTestCoroutines() + } + + @ExperimentalCoroutinesApi + override suspend fun pauseDispatcher(block: suspend () -> Unit) { + delayControllerForPausing.pauseDispatcher(block) } + + @ExperimentalCoroutinesApi + override fun pauseDispatcher() { + delayControllerForPausing.pauseDispatcher() + } + + @ExperimentalCoroutinesApi + override fun resumeDispatcher() { + delayControllerForPausing.resumeDispatcher() + } + + private val delayControllerForPausing: DelayController + get() = coroutineContext.delayController + ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") } /** @@ -46,12 +119,8 @@ private class TestCoroutineScopeImpl ( */ @Suppress("FunctionName") @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 -public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { - var safeContext = context - if (context[ContinuationInterceptor] == null) safeContext += TestCoroutineDispatcher() - if (context[CoroutineExceptionHandler] == null) safeContext += TestCoroutineExceptionHandler() - return TestCoroutineScopeImpl(safeContext) -} +public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope = + context.checkTestScopeArguments().let { TestCoroutineScopeImpl(it.first, it.second.scheduler) } private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor get() { @@ -62,11 +131,8 @@ private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCa ) } -private inline val CoroutineContext.delayController: DelayController +private inline val CoroutineContext.delayController: DelayController? get() { val handler = this[ContinuationInterceptor] - return handler as? DelayController ?: throw IllegalArgumentException( - "TestCoroutineScope requires a DelayController such as TestCoroutineDispatcher as " + - "the ContinuationInterceptor (Dispatcher)" - ) - } + return handler as? DelayController + } \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestDispatcher.kt b/kotlinx-coroutines-test/common/src/TestDispatcher.kt new file mode 100644 index 0000000000..2b982be38a --- /dev/null +++ b/kotlinx-coroutines-test/common/src/TestDispatcher.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* + +/** + * A test dispatcher that can interface with a [TestCoroutineScheduler]. + */ +public abstract class TestDispatcher: CoroutineDispatcher() { + /** The scheduler that this dispatcher is linked to. */ + public abstract val scheduler: TestCoroutineScheduler + + /** Notifies the dispatcher that it should process a single event marked with [marker] happening at time [time]. */ + internal abstract fun processEvent(time: Long, marker: Any) +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestDispatchers.kt b/kotlinx-coroutines-test/common/src/TestDispatchers.kt index f8896d7278..508608e173 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatchers.kt @@ -11,7 +11,7 @@ import kotlin.jvm.* /** * Sets the given [dispatcher] as an underlying dispatcher of [Dispatchers.Main]. - * All subsequent usages of [Dispatchers.Main] will use given [dispatcher] under the hood. + * All subsequent usages of [Dispatchers.Main] will use the given [dispatcher] under the hood. * * It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist. */ @@ -23,8 +23,9 @@ public fun Dispatchers.setMain(dispatcher: CoroutineDispatcher) { /** * Resets state of the [Dispatchers.Main] to the original main dispatcher. - * For example, in Android Main thread dispatcher will be set as [Dispatchers.Main]. - * Used to clean up all possible dependencies, should be used in tear down (`@After`) methods. + * + * For example, in Android, the Main thread dispatcher will be set as [Dispatchers.Main]. + * This method undoes a dependency injection performed for tests, and so should be used in tear down (`@After`) methods. * * It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist. */ diff --git a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt index a3167e5876..c1773bb993 100644 --- a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt @@ -94,6 +94,7 @@ class TestBuildersTest { } scope.advanceTimeBy(1_000) + scope.runCurrent() scope.launch { assertRunsFast { assertEquals(3, deferred.getCompleted()) diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt index baf946f2e1..f14b72632c 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt @@ -9,35 +9,7 @@ import kotlin.test.* class TestCoroutineDispatcherTest { @Test - fun whenStringCalled_itReturnsString() { - val subject = TestCoroutineDispatcher() - assertEquals("TestCoroutineDispatcher[currentTime=0ms, queued=0]", subject.toString()) - } - - @Test - fun whenStringCalled_itReturnsCurrentTime() { - val subject = TestCoroutineDispatcher() - subject.advanceTimeBy(1000) - assertEquals("TestCoroutineDispatcher[currentTime=1000ms, queued=0]", subject.toString()) - } - - @Test - fun whenStringCalled_itShowsQueuedJobs() { - val subject = TestCoroutineDispatcher() - val scope = TestCoroutineScope(subject) - scope.pauseDispatcher() - scope.launch { - delay(1_000) - } - assertEquals("TestCoroutineDispatcher[currentTime=0ms, queued=1]", subject.toString()) - scope.advanceTimeBy(50) - assertEquals("TestCoroutineDispatcher[currentTime=50ms, queued=1]", subject.toString()) - scope.advanceUntilIdle() - assertEquals("TestCoroutineDispatcher[currentTime=1000ms, queued=0]", subject.toString()) - } - - @Test - fun whenDispatcherPaused_doesntAutoProgressCurrent() { + fun whenDispatcherPaused_doesNotAutoProgressCurrent() { val subject = TestCoroutineDispatcher() subject.pauseDispatcher() val scope = CoroutineScope(subject) diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt new file mode 100644 index 0000000000..d313ef57b6 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2016-2021 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 kotlin.test.* + +class TestCoroutineSchedulerTest { + @Test + fun contextPropagation() { + runBlockingTest { + assertFailsWith { + withContext(TestCoroutineDispatcher()) { + } + } + } + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index bf391ea6e2..4ffbfc2c37 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -1,10 +1,10 @@ /* * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +package kotlinx.coroutines.test import kotlinx.atomicfu.* import kotlinx.coroutines.* -import kotlinx.coroutines.test.* import kotlin.coroutines.* import kotlin.test.* diff --git a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt index c93b50811f..fc453876d4 100644 --- a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt +++ b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt @@ -128,7 +128,6 @@ class TestRunBlockingTest { @Test fun whenUsingTimeout_inAsync_doesNotTriggerWhenNotDelayed() = runBlockingTest { - val testScope = this val deferred = async { withTimeout(SLOW) { delay(0) @@ -195,7 +194,7 @@ class TestRunBlockingTest { assertRunsFast { job.join() - throw job.getCancellationException().cause ?: assertFails { "expected exception" } + throw job.getCancellationException().cause ?: AssertionError("expected exception") } } } @@ -239,7 +238,7 @@ class TestRunBlockingTest { delay(SLOW) executed = true } - advanceTimeBy(SLOW) + advanceTimeBy(SLOW + 1) assertTrue(executed) } @@ -343,6 +342,7 @@ class TestRunBlockingTest { runCurrent() assertEquals(1, mutable) advanceTimeBy(SLOW) + runCurrent() assertEquals(2, mutable) } } @@ -366,7 +366,7 @@ class TestRunBlockingTest { runBlockingTest { val expectedError = TestException("hello") - val job = launch { + launch { throw expectedError } diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index d06f2a35c6..7dc99406a1 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -10,7 +10,7 @@ import kotlin.test.* class MultithreadingTest : TestBase() { @Test - fun incorrectlyCalledRunblocking_doesNotHaveSameInterceptor() = runBlockingTest { + fun incorrectlyCalledRunBlocking_doesNotHaveSameInterceptor() = runBlockingTest { // this code is an error as a production test, please do not use this as an example // this test exists to document this error condition, if it's possible to make this code work please update From e03d2fe27a6b8a2fe92a697d8b978595826eda79 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 13 Oct 2021 13:37:03 +0300 Subject: [PATCH 02/22] Improve deprecations --- .../api/kotlinx-coroutines-test.api | 19 +-- .../common/src/DelayController.kt | 2 +- .../common/src/TestCoroutineDispatcher.kt | 9 +- .../common/src/TestCoroutineScope.kt | 157 ++++++++++-------- .../common/test/TestBuildersTest.kt | 1 - .../common/test/TestCoroutineSchedulerTest.kt | 5 +- .../common/test/TestRunBlockingTest.kt | 3 +- 7 files changed, 104 insertions(+), 92 deletions(-) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index b14d5a1574..efc154ec48 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -57,27 +57,24 @@ public final class kotlinx/coroutines/test/TestCoroutineScheduler$Key : kotlin/c } public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/UncaughtExceptionCaptor { - public abstract fun advanceTimeBy (J)V - public abstract fun advanceUntilIdle ()V public abstract fun cleanupTestCoroutines ()V - public abstract fun getCurrentTime ()J public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; - public abstract fun pauseDispatcher ()V - public abstract fun pauseDispatcher (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun resumeDispatcher ()V - public abstract fun runCurrent ()V } public final class kotlinx/coroutines/test/TestCoroutineScope$DefaultImpls { - public static fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V - public static fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V - public static fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J - public static fun runCurrent (Lkotlinx/coroutines/test/TestCoroutineScope;)V + public static fun getTestScheduler (Lkotlinx/coroutines/test/TestCoroutineScope;)Lkotlinx/coroutines/test/TestCoroutineScheduler; } public final class kotlinx/coroutines/test/TestCoroutineScopeKt { public static final fun TestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope; public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope; + public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V + public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V + public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J + public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V + public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun resumeDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V + public static final fun runCurrent (Lkotlinx/coroutines/test/TestCoroutineScope;)V } public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher { diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/DelayController.kt index d2c64a04ee..693a44da55 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/DelayController.kt @@ -142,7 +142,7 @@ internal interface SchedulerAsDelayController: DelayController { /** @suppress */ @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", - ReplaceWith("this.scheduler.advanceTimeBy(delayTimeMillis)"), + ReplaceWith("this.scheduler.apply { advanceTimeBy(delayTimeMillis); runCurrent() }"), level = DeprecationLevel.WARNING) override fun advanceTimeBy(delayTimeMillis: Long): Long { val oldTime = scheduler.currentTime diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index dee02af25f..ee1e5dd246 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -23,12 +23,7 @@ import kotlin.jvm.* * @see DelayController */ @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 -public class TestCoroutineDispatcher( - /** - * The scheduler that this dispatcher is linked to. - */ - public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler() -): +public class TestCoroutineDispatcher(public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()): TestDispatcher(), Delay, SchedulerAsDelayController { private var dispatchImmediately = true @@ -41,7 +36,7 @@ public class TestCoroutineDispatcher( } override fun processEvent(time: Long, marker: Any) { - require(marker is Runnable) + check(marker is Runnable) marker.run() } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 18234e47fe..58746cac61 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -27,54 +27,8 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { */ @ExperimentalCoroutinesApi public val testScheduler: TestCoroutineScheduler - - /** - * The current virtual time on [testScheduler]. - * @see TestCoroutineScheduler.currentTime - */ - @ExperimentalCoroutinesApi - public val currentTime: Long - get() = testScheduler.currentTime - - /** - * Advances the [testScheduler] by [delayTimeMillis]. - * - * Historical note: this method used to also run the tasks scheduled on the next millisecond after the delay; this - * behavior is no longer present, so call [runCurrent] afterwards if you need those tasks to run. - * - * @see TestCoroutineScheduler.advanceTimeBy - */ - @ExperimentalCoroutinesApi - public fun advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis) - - /** - * Advances the [testScheduler] to the point where there are no tasks remaining. - * @see TestCoroutineScheduler.advanceUntilIdle - */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 - public fun advanceUntilIdle(): Unit = testScheduler.advanceUntilIdle() - - /** - * Run any tasks that are pending at the current virtual time. - * @see TestCoroutineScheduler.runCurrent - */ - @ExperimentalCoroutinesApi - public fun runCurrent(): Unit = testScheduler.runCurrent() - - @ExperimentalCoroutinesApi - @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", level = DeprecationLevel.WARNING) - public suspend fun pauseDispatcher(block: suspend () -> Unit) - - @ExperimentalCoroutinesApi - @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", level = DeprecationLevel.WARNING) - public fun pauseDispatcher() - - @ExperimentalCoroutinesApi - @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", level = DeprecationLevel.WARNING) - public fun resumeDispatcher() + get() = coroutineContext[TestCoroutineScheduler] + ?: throw UnsupportedOperationException("This scope does not have a TestCoroutineScheduler linked to it") } private class TestCoroutineScopeImpl ( @@ -88,25 +42,6 @@ private class TestCoroutineScopeImpl ( coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() coroutineContext.delayController?.cleanupTestCoroutines() } - - @ExperimentalCoroutinesApi - override suspend fun pauseDispatcher(block: suspend () -> Unit) { - delayControllerForPausing.pauseDispatcher(block) - } - - @ExperimentalCoroutinesApi - override fun pauseDispatcher() { - delayControllerForPausing.pauseDispatcher() - } - - @ExperimentalCoroutinesApi - override fun resumeDispatcher() { - delayControllerForPausing.resumeDispatcher() - } - - private val delayControllerForPausing: DelayController - get() = coroutineContext.delayController - ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") } /** @@ -135,4 +70,90 @@ private inline val CoroutineContext.delayController: DelayController? get() { val handler = this[ContinuationInterceptor] return handler as? DelayController - } \ No newline at end of file + } + + +/** + * The current virtual time on [testScheduler][TestCoroutineScope.testScheduler]. + * @see TestCoroutineScheduler.currentTime + */ +@ExperimentalCoroutinesApi +public val TestCoroutineScope.currentTime: Long + get() = coroutineContext.delayController?.currentTime ?: testScheduler.currentTime + +/** + * Advances the [testScheduler][TestCoroutineScope.testScheduler] by [delayTimeMillis] and runs the tasks up to that + * moment (inclusive). + * + * @see TestCoroutineScheduler.advanceTimeBy + */ +@ExperimentalCoroutinesApi +@Deprecated("The name of this function is misleading: it not only advances the time, but also runs the tasks " + + "scheduled *at* the ending moment.", + ReplaceWith("this.testScheduler.apply { advanceTimeBy(delayTimeMillis); runCurrent() }"), + DeprecationLevel.WARNING) +public fun TestCoroutineScope.advanceTimeBy(delayTimeMillis: Long): Unit = + when (val controller = coroutineContext.delayController) { + null -> { + testScheduler.advanceTimeBy(delayTimeMillis) + testScheduler.runCurrent() + } + else -> { + controller.advanceTimeBy(delayTimeMillis) + Unit + } + } + +/** + * Advances the [testScheduler][TestCoroutineScope.testScheduler] to the point where there are no tasks remaining. + * @see TestCoroutineScheduler.advanceUntilIdle + */ +@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +public fun TestCoroutineScope.advanceUntilIdle(): Unit { + coroutineContext.delayController?.advanceUntilIdle() ?: testScheduler.advanceUntilIdle() +} + +/** + * Run any tasks that are pending at the current virtual time, according to + * the [testScheduler][TestCoroutineScope.testScheduler]. + * + * @see TestCoroutineScheduler.runCurrent + */ +@ExperimentalCoroutinesApi +public fun TestCoroutineScope.runCurrent(): Unit { + coroutineContext.delayController?.runCurrent() ?: testScheduler.runCurrent() +} + +@ExperimentalCoroutinesApi +@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", + ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher(block)", + "kotlin.coroutines.ContinuationInterceptor"), + DeprecationLevel.WARNING) +public suspend fun TestCoroutineScope.pauseDispatcher(block: suspend () -> Unit) { + delayControllerForPausing.pauseDispatcher(block) +} + +@ExperimentalCoroutinesApi +@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", + ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher()", + "kotlin.coroutines.ContinuationInterceptor"), +level = DeprecationLevel.WARNING) +public fun TestCoroutineScope.pauseDispatcher() { + delayControllerForPausing.pauseDispatcher() +} + +@ExperimentalCoroutinesApi +@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", + ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher()", + "kotlin.coroutines.ContinuationInterceptor"), + level = DeprecationLevel.WARNING) +public fun TestCoroutineScope.resumeDispatcher() { + delayControllerForPausing.resumeDispatcher() +} + +private val TestCoroutineScope.delayControllerForPausing: DelayController + get() = coroutineContext.delayController + ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt index c1773bb993..a3167e5876 100644 --- a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt @@ -94,7 +94,6 @@ class TestBuildersTest { } scope.advanceTimeBy(1_000) - scope.runCurrent() scope.launch { assertRunsFast { assertEquals(3, deferred.getCompleted()) diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index d313ef57b6..20d6654696 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -9,7 +9,8 @@ import kotlin.test.* class TestCoroutineSchedulerTest { @Test - fun contextPropagation() { + /** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */ + fun testContextElement() { runBlockingTest { assertFailsWith { withContext(TestCoroutineDispatcher()) { @@ -17,4 +18,4 @@ class TestCoroutineSchedulerTest { } } } -} \ No newline at end of file +} diff --git a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt index fc453876d4..041f58a3c5 100644 --- a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt +++ b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt @@ -238,7 +238,7 @@ class TestRunBlockingTest { delay(SLOW) executed = true } - advanceTimeBy(SLOW + 1) + advanceTimeBy(SLOW) assertTrue(executed) } @@ -342,7 +342,6 @@ class TestRunBlockingTest { runCurrent() assertEquals(1, mutable) advanceTimeBy(SLOW) - runCurrent() assertEquals(2, mutable) } } From 46b38e1412301290a47c240fae420f98fee604ca Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 13 Oct 2021 14:53:37 +0300 Subject: [PATCH 03/22] Add some tests --- .../common/test/TestCoroutineSchedulerTest.kt | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index 20d6654696..0862c1ff93 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.* import kotlin.test.* class TestCoroutineSchedulerTest { - @Test /** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */ + @Test fun testContextElement() { runBlockingTest { assertFailsWith { @@ -18,4 +18,65 @@ class TestCoroutineSchedulerTest { } } } + + /** Tests that, as opposed to [DelayController.advanceTimeBy] or [TestCoroutineScope.advanceTimeBy], + * [TestCoroutineScheduler.advanceTimeBy] doesn't run the tasks scheduled at the target moment. */ + @Test + fun testAdvanceTimeByDoesNotRunCurrent() { + val dispatcher = TestCoroutineDispatcher() + dispatcher.runBlockingTest { + dispatcher.pauseDispatcher { + var entered = false + launch { + delay(15) + entered = true + } + testScheduler.advanceTimeBy(15) + assertFalse(entered) + testScheduler.runCurrent() + assertTrue(entered) + } + } + } + + /** Tests that [TestCoroutineScheduler.advanceTimeBy] doesn't accept negative delays. */ + @Test + fun testAdvanceTimeByWithNegativeDelay() { + val scheduler = TestCoroutineScheduler() + assertFailsWith { + scheduler.advanceTimeBy(-1) + } + } + + /** Tests that if [TestCoroutineScheduler.advanceTimeBy] encounters an arithmetic overflow, all the tasks scheduled + * until the moment [Long.MAX_VALUE] get run. */ + @Test + fun testAdvanceTimeByEnormousDelays() { + val dispatcher = TestCoroutineDispatcher() + dispatcher.runBlockingTest { + dispatcher.pauseDispatcher { + val initialDelay = 10L + delay(initialDelay) + assertEquals(initialDelay, currentTime) + var enteredInfinity = false + launch { + delay(Long.MAX_VALUE - 1) // delay(Long.MAX_VALUE) does nothing + assertEquals(Long.MAX_VALUE, currentTime) + enteredInfinity = true + } + var enteredNearInfinity = false + launch { + delay(Long.MAX_VALUE - initialDelay - 1) + assertEquals(Long.MAX_VALUE - 1, currentTime) + enteredNearInfinity = true + } + testScheduler.advanceTimeBy(Long.MAX_VALUE) + assertFalse(enteredInfinity) + assertTrue(enteredNearInfinity) + assertEquals(Long.MAX_VALUE, currentTime) + testScheduler.runCurrent() + assertTrue(enteredInfinity) + } + } + } } From 9ec78572ea0c68c20d34314b2517ef08be277123 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 13 Oct 2021 17:18:06 +0300 Subject: [PATCH 04/22] Small refactoring --- .../api/kotlinx-coroutines-test.api | 8 ++--- .../common/src/TestCoroutineDispatcher.kt | 30 +--------------- .../common/src/TestCoroutineScheduler.kt | 1 + .../common/src/TestCoroutineScope.kt | 1 + .../common/src/TestDispatcher.kt | 36 +++++++++++++++++-- 5 files changed, 41 insertions(+), 35 deletions(-) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index efc154ec48..3198253b0f 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -23,17 +23,14 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor public fun advanceTimeBy (J)J public fun advanceUntilIdle ()J public fun cleanupTestCoroutines ()V - public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun dispatch (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V public fun dispatchYield (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V public fun getCurrentTime ()J public fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; - public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle; public fun pauseDispatcher ()V public fun pauseDispatcher (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun resumeDispatcher ()V public fun runCurrent ()V - public fun scheduleResumeAfterDelay (JLkotlinx/coroutines/CancellableContinuation;)V public fun toString ()Ljava/lang/String; } @@ -77,9 +74,12 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt { public static final fun runCurrent (Lkotlinx/coroutines/test/TestCoroutineScope;)V } -public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher { +public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay { public fun ()V + public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; + public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle; + public fun scheduleResumeAfterDelay (JLkotlinx/coroutines/CancellableContinuation;)V } public final class kotlinx/coroutines/test/TestDispatchers { diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index ee1e5dd246..12d42b88e3 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -6,7 +6,6 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlin.coroutines.* -import kotlin.jvm.* /** * [CoroutineDispatcher] that performs both immediate and lazy execution of coroutines in tests @@ -35,6 +34,7 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin } } + /** @suppress */ override fun processEvent(time: Long, marker: Any) { check(marker is Runnable) marker.run() @@ -51,25 +51,11 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin } /** @suppress */ - @InternalCoroutinesApi override fun dispatchYield(context: CoroutineContext, block: Runnable) { checkSchedulerInContext(scheduler, context) post(block) } - /** @suppress */ - override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { - checkSchedulerInContext(scheduler, continuation.context) - val timedRunnable = CancellableContinuationRunnable(continuation, this) - scheduler.registerEvent(this, timeMillis, timedRunnable, ::cancellableRunnableIsCancelled) - } - - /** @suppress */ - override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle { - checkSchedulerInContext(scheduler, context) - return scheduler.registerEvent(this, timeMillis, block) { false } - } - /** @suppress */ override fun toString(): String = "TestCoroutineDispatcher[scheduler=$scheduler]" @@ -97,17 +83,3 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin dispatchImmediately = true } } - -/** - * This class exists to allow cleanup code to avoid throwing for cancelled continuations scheduled - * in the future. - */ -private class CancellableContinuationRunnable( - @JvmField val continuation: CancellableContinuation, - private val dispatcher: CoroutineDispatcher -) : Runnable { - override fun run() = with(dispatcher) { with(continuation) { resumeUndispatched(Unit) } } -} - -private fun cancellableRunnableIsCancelled(runnable: CancellableContinuationRunnable): Boolean = - !runnable.continuation.isActive \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index df6022cbba..d5885a5935 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -39,6 +39,7 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti private val count = atomic(0L) /** The current virtual time. */ + @ExperimentalCoroutinesApi public var currentTime: Long = 0 private set diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 58746cac61..426a3cae2f 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -20,6 +20,7 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended * coroutines. */ + @ExperimentalCoroutinesApi public fun cleanupTestCoroutines() /** diff --git a/kotlinx-coroutines-test/common/src/TestDispatcher.kt b/kotlinx-coroutines-test/common/src/TestDispatcher.kt index 2b982be38a..549eddc34f 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatcher.kt @@ -5,14 +5,46 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlin.coroutines.* +import kotlin.jvm.* /** * A test dispatcher that can interface with a [TestCoroutineScheduler]. */ -public abstract class TestDispatcher: CoroutineDispatcher() { +@ExperimentalCoroutinesApi +public abstract class TestDispatcher: 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]. */ + @ExperimentalCoroutinesApi internal abstract fun processEvent(time: Long, marker: Any) -} \ No newline at end of file + + /** @suppress */ + override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { + checkSchedulerInContext(scheduler, continuation.context) + val timedRunnable = CancellableContinuationRunnable(continuation, this) + scheduler.registerEvent(this, timeMillis, timedRunnable, ::cancellableRunnableIsCancelled) + } + + /** @suppress */ + override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle { + checkSchedulerInContext(scheduler, context) + return scheduler.registerEvent(this, timeMillis, block) { false } + } +} + +/** + * This class exists to allow cleanup code to avoid throwing for cancelled continuations scheduled + * in the future. + */ +private class CancellableContinuationRunnable( + @JvmField val continuation: CancellableContinuation, + private val dispatcher: CoroutineDispatcher +) : Runnable { + override fun run() = with(dispatcher) { with(continuation) { resumeUndispatched(Unit) } } +} + +private fun cancellableRunnableIsCancelled(runnable: CancellableContinuationRunnable): Boolean = + !runnable.continuation.isActive From cada696b484ac98cfdd0cf81e02399252a9e5785 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 15 Oct 2021 10:29:13 +0300 Subject: [PATCH 05/22] Fix 'TestCoroutineScheduler' being ignored on 'TestCoroutineScope' creation --- .../common/src/TestBuilders.kt | 2 +- .../common/test/TestCoroutineScopeTest.kt | 47 ++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 2063e465e5..c3984f7a3f 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -96,7 +96,7 @@ internal fun CoroutineContext.checkTestScopeArguments(): Pair { - scheduler = TestCoroutineScheduler() + scheduler = get(TestCoroutineScheduler) ?: TestCoroutineScheduler() TestCoroutineDispatcher(scheduler) } else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index 4480cd99a3..a7d4480d63 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -5,21 +5,54 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlin.coroutines.* import kotlin.test.* class TestCoroutineScopeTest { + /** Tests failing to create a [TestCoroutineScope] with incorrect contexts. */ @Test - fun whenGivenInvalidExceptionHandler_throwsException() { - val handler = CoroutineExceptionHandler { _, _ -> } - assertFails { - TestCoroutineScope(handler) + fun testCreateThrowsOnInvalidArguments() { + for (ctx in invalidContexts) { + assertFailsWith { + TestCoroutineScope(ctx) + } } } + /** Tests that a newly-created [TestCoroutineScope] provides the correct scheduler. */ @Test - fun whenGivenInvalidDispatcher_throwsException() { - assertFails { - TestCoroutineScope(Dispatchers.Default) + fun testCreateProvidesScheduler() { + // Creates a new scheduler. + run { + val scope = TestCoroutineScope() + assertNotNull(scope.coroutineContext[TestCoroutineScheduler]) + } + // Reuses the scheduler that the dispatcher is linked to. + run { + val dispatcher = TestCoroutineDispatcher() + val scope = TestCoroutineScope(dispatcher) + assertSame(dispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) + } + // Uses the scheduler passed to it. + run { + val scheduler = TestCoroutineScheduler() + val scope = TestCoroutineScope(scheduler) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertSame(scheduler, (scope.coroutineContext[ContinuationInterceptor] as TestDispatcher).scheduler) + } + // Doesn't touch the passed dispatcher and the scheduler if they match. + run { + val scheduler = TestCoroutineScheduler() + val dispatcher = TestCoroutineDispatcher(scheduler) + val scope = TestCoroutineScope(scheduler + dispatcher) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertSame(dispatcher, scope.coroutineContext[ContinuationInterceptor]) } } + + private val invalidContexts = listOf( + Dispatchers.Default, // not a [TestDispatcher] + TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler + CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` + ) } From e2567fdbe861491bf1a0d7f7dbd1c8b2d910b8ca Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 15 Oct 2021 11:50:50 +0300 Subject: [PATCH 06/22] Fix 'runCurrent' executing newly-added tasks --- .../common/src/TestCoroutineScheduler.kt | 14 +- .../common/test/TestCoroutineSchedulerTest.kt | 127 ++++++++++++------ 2 files changed, 86 insertions(+), 55 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index d5885a5935..db5d2c1dcb 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -41,6 +41,7 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti /** The current virtual time. */ @ExperimentalCoroutinesApi public var currentTime: Long = 0 + get() = synchronized(lock) { field } private set /** @@ -96,19 +97,10 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti */ @ExperimentalCoroutinesApi public fun runCurrent() { + val timeMark = synchronized(lock) { currentTime } while (true) { val event = synchronized(lock) { - val timeMark = currentTime - val event = events.peek() ?: return - when { - timeMark > event.time -> currentTimeAheadOfEvents() - timeMark < event.time -> return - else -> { - val event2 = events.removeFirstOrNull() - if (event !== event2) concurrentModificationUnderLock() - event2 - } - } + events.removeFirstIf { it.time <= timeMark } ?: return } event.dispatcher.processEvent(event.time, event.marker) } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index 0862c1ff93..dfeabd530d 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -10,11 +10,9 @@ import kotlin.test.* class TestCoroutineSchedulerTest { /** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */ @Test - fun testContextElement() { - runBlockingTest { - assertFailsWith { - withContext(TestCoroutineDispatcher()) { - } + fun testContextElement() = runBlockingTest { + assertFailsWith { + withContext(TestCoroutineDispatcher()) { } } } @@ -22,21 +20,16 @@ class TestCoroutineSchedulerTest { /** Tests that, as opposed to [DelayController.advanceTimeBy] or [TestCoroutineScope.advanceTimeBy], * [TestCoroutineScheduler.advanceTimeBy] doesn't run the tasks scheduled at the target moment. */ @Test - fun testAdvanceTimeByDoesNotRunCurrent() { - val dispatcher = TestCoroutineDispatcher() - dispatcher.runBlockingTest { - dispatcher.pauseDispatcher { - var entered = false - launch { - delay(15) - entered = true - } - testScheduler.advanceTimeBy(15) - assertFalse(entered) - testScheduler.runCurrent() - assertTrue(entered) - } + fun testAdvanceTimeByDoesNotRunCurrent() = runBlockingTest { + var entered = false + launch { + delay(15) + entered = true } + testScheduler.advanceTimeBy(15) + assertFalse(entered) + testScheduler.runCurrent() + assertTrue(entered) } /** Tests that [TestCoroutineScheduler.advanceTimeBy] doesn't accept negative delays. */ @@ -51,32 +44,78 @@ class TestCoroutineSchedulerTest { /** Tests that if [TestCoroutineScheduler.advanceTimeBy] encounters an arithmetic overflow, all the tasks scheduled * until the moment [Long.MAX_VALUE] get run. */ @Test - fun testAdvanceTimeByEnormousDelays() { - val dispatcher = TestCoroutineDispatcher() - dispatcher.runBlockingTest { - dispatcher.pauseDispatcher { - val initialDelay = 10L - delay(initialDelay) - assertEquals(initialDelay, currentTime) - var enteredInfinity = false - launch { - delay(Long.MAX_VALUE - 1) // delay(Long.MAX_VALUE) does nothing - assertEquals(Long.MAX_VALUE, currentTime) - enteredInfinity = true - } - var enteredNearInfinity = false - launch { - delay(Long.MAX_VALUE - initialDelay - 1) - assertEquals(Long.MAX_VALUE - 1, currentTime) - enteredNearInfinity = true - } - testScheduler.advanceTimeBy(Long.MAX_VALUE) - assertFalse(enteredInfinity) - assertTrue(enteredNearInfinity) - assertEquals(Long.MAX_VALUE, currentTime) - testScheduler.runCurrent() - assertTrue(enteredInfinity) + fun testAdvanceTimeByEnormousDelays() = runBlockingTest { + val initialDelay = 10L + delay(initialDelay) + assertEquals(initialDelay, currentTime) + var enteredInfinity = false + launch { + delay(Long.MAX_VALUE - 1) // delay(Long.MAX_VALUE) does nothing + assertEquals(Long.MAX_VALUE, currentTime) + enteredInfinity = true + } + var enteredNearInfinity = false + launch { + delay(Long.MAX_VALUE - initialDelay - 1) + assertEquals(Long.MAX_VALUE - 1, currentTime) + enteredNearInfinity = true + } + testScheduler.advanceTimeBy(Long.MAX_VALUE) + assertFalse(enteredInfinity) + assertTrue(enteredNearInfinity) + assertEquals(Long.MAX_VALUE, currentTime) + testScheduler.runCurrent() + assertTrue(enteredInfinity) + } + + /** Tests the basic functionality of [TestCoroutineScheduler.advanceTimeBy]. */ + @Test + fun testAdvanceTimeBy() { + val scheduler = TestCoroutineScheduler() + val scope = TestCoroutineScope(scheduler) + var stage = 1 + scope.launch { + delay(1_000) + assertEquals(1_000, scheduler.currentTime) + stage = 2 + delay(500) + assertEquals(1_500, scheduler.currentTime) + stage = 3 + delay(501) + assertEquals(2_001, scheduler.currentTime) + stage = 4 + } + assertEquals(1, stage) + assertEquals(0, scheduler.currentTime) + scheduler.advanceTimeBy(2_000) + assertEquals(3, stage) + assertEquals(2_000, scheduler.currentTime) + scheduler.advanceTimeBy(2) + assertEquals(4, stage) + assertEquals(2_002, scheduler.currentTime) + scope.cleanupTestCoroutines() + } + + /** Tests that [TestCoroutineScheduler.runCurrent] will not run new tasks after the current time has advanced. */ + @Test + fun testRunCurrentNotDrainingQueue() { + val scheduler = TestCoroutineScheduler() + val scope = TestCoroutineScope(scheduler) + var stage = 1 + scope.launch { + delay(1) + launch { + delay(1) + stage = 3 } + scheduler.advanceTimeBy(1) + stage = 2 } + scheduler.advanceTimeBy(1) + assertEquals(1, stage) + scheduler.runCurrent() + assertEquals(2, stage) + scheduler.runCurrent() + assertEquals(3, stage) } } From 8aae9e76bff60b0c5d7abd76b85d787f1893e97e Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 15 Oct 2021 12:54:28 +0300 Subject: [PATCH 07/22] More tests for the scheduler --- .../common/test/TestCoroutineSchedulerTest.kt | 75 +++++++++++++++++-- .../common/test/TestModuleHelpers.kt | 27 ++++--- 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index dfeabd530d..ab6ced4741 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -70,7 +70,7 @@ class TestCoroutineSchedulerTest { /** Tests the basic functionality of [TestCoroutineScheduler.advanceTimeBy]. */ @Test - fun testAdvanceTimeBy() { + fun testAdvanceTimeBy() = assertRunsFast { val scheduler = TestCoroutineScheduler() val scope = TestCoroutineScope(scheduler) var stage = 1 @@ -96,26 +96,89 @@ class TestCoroutineSchedulerTest { scope.cleanupTestCoroutines() } + /** Tests the basic functionality of [TestCoroutineScheduler.runCurrent]. */ + @Test + fun testRunCurrent() = runBlockingTest { + var stage = 0 + launch { + delay(1) + ++stage + delay(1) + stage += 10 + } + launch { + delay(1) + ++stage + delay(1) + stage += 10 + } + testScheduler.advanceTimeBy(1) + assertEquals(0, stage) + runCurrent() + assertEquals(2, stage) + testScheduler.advanceTimeBy(1) + assertEquals(2, stage) + runCurrent() + assertEquals(22, stage) + } + /** Tests that [TestCoroutineScheduler.runCurrent] will not run new tasks after the current time has advanced. */ @Test - fun testRunCurrentNotDrainingQueue() { + fun testRunCurrentNotDrainingQueue() = assertRunsFast { val scheduler = TestCoroutineScheduler() val scope = TestCoroutineScope(scheduler) var stage = 1 scope.launch { - delay(1) + delay(SLOW) launch { - delay(1) + delay(SLOW) stage = 3 } - scheduler.advanceTimeBy(1) + scheduler.advanceTimeBy(SLOW) stage = 2 } - scheduler.advanceTimeBy(1) + scheduler.advanceTimeBy(SLOW) assertEquals(1, stage) scheduler.runCurrent() assertEquals(2, stage) scheduler.runCurrent() assertEquals(3, stage) } + + /** Tests that [TestCoroutineScheduler.advanceUntilIdle] doesn't hang when itself running in a scheduler task. */ + @Test + fun testNestedAdvanceUntilIdle() = assertRunsFast { + val scheduler = TestCoroutineScheduler() + val scope = TestCoroutineScope(scheduler) + var executed = false + scope.launch { + launch { + delay(SLOW) + executed = true + } + scheduler.advanceUntilIdle() + } + scheduler.advanceUntilIdle() + assertTrue(executed) + } + + /** Tests [yield] scheduling tasks for future execution and not executing immediately. */ + @Test + fun testYield() { + val scope = TestCoroutineScope() + var stage = 0 + scope.launch { + yield() + assertEquals(1, stage) + stage = 2 + } + scope.launch { + yield() + assertEquals(2, stage) + stage = 3 + } + assertEquals(0, stage) + stage = 1 + scope.runCurrent() + } } diff --git a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt b/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt index a34dbfd6c7..f3b8e79407 100644 --- a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt +++ b/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt @@ -4,22 +4,27 @@ package kotlinx.coroutines.test -import kotlinx.coroutines.* import kotlin.test.* import kotlin.time.* -const val SLOW = 10_000L +/** + * The number of milliseconds that is sure not to pass [assertRunsFast]. + */ +const val SLOW = 100_000L /** - * Assert a block completes within a second or fail the suite + * Asserts that a block completed within [timeout]. */ @OptIn(ExperimentalTime::class) -suspend fun CoroutineScope.assertRunsFast(block: suspend CoroutineScope.() -> Unit) { - val start = TimeSource.Monotonic.markNow() - // don't need to be fancy with timeouts here since anything longer than a few ms is an error - block() - val duration = start.elapsedNow() - assertTrue("All tests must complete within 2000ms (use longer timeouts to cause failure)") { - duration.inWholeSeconds < 2 - } +inline fun assertRunsFast(timeout: Duration, block: () -> T): T { + val result: T + val elapsed = TimeSource.Monotonic.measureTime { result = block() } + assertTrue("Should complete in $timeout, but took $elapsed") { elapsed < timeout } + return result } + +/** + * Asserts that a block completed within two seconds. + */ +@OptIn(ExperimentalTime::class) +inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.seconds(2), block) From 3898c7f057cc1a25aafeec83704dcbcf6cf9af60 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 25 Oct 2021 11:51:45 +0300 Subject: [PATCH 08/22] Fixes --- .../common/src/DelayController.kt | 22 ++++++------- .../common/src/TestBuilders.kt | 6 ++-- .../common/src/TestCoroutineDispatcher.kt | 2 +- .../src/TestCoroutineExceptionHandler.kt | 4 +-- .../common/src/TestCoroutineScheduler.kt | 33 ++++++++----------- .../common/src/TestCoroutineScope.kt | 8 ++--- 6 files changed, 35 insertions(+), 40 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/DelayController.kt index 693a44da55..b86955db31 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/DelayController.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi * * Testing libraries may expose this interface to the tests instead of [TestCoroutineDispatcher]. */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi @Deprecated("Use `TestCoroutineScheduler` to control virtual time.", level = DeprecationLevel.WARNING) public interface DelayController { @@ -21,7 +21,7 @@ public interface DelayController { * * @return The virtual clock-time */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public val currentTime: Long /** @@ -59,7 +59,7 @@ public interface DelayController { * @param delayTimeMillis The amount of time to move the CoroutineContext's clock forward. * @return The amount of delay-time that this Dispatcher's clock has been forwarded. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public fun advanceTimeBy(delayTimeMillis: Long): Long /** @@ -70,7 +70,7 @@ public interface DelayController { * * @return the amount of delay-time that this Dispatcher's clock has been forwarded in milliseconds. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public fun advanceUntilIdle(): Long /** @@ -78,7 +78,7 @@ public interface DelayController { * * Calling this function will never advance the clock. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public fun runCurrent() /** @@ -87,7 +87,7 @@ public interface DelayController { * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended * coroutines. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi @Throws(UncompletedCoroutinesError::class) public fun cleanupTestCoroutines() @@ -100,7 +100,7 @@ public interface DelayController { * This is useful when testing functions that start a coroutine. By pausing the dispatcher assertions or * setup may be done between the time the coroutine is created and started. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public suspend fun pauseDispatcher(block: suspend () -> Unit) /** @@ -109,7 +109,7 @@ public interface DelayController { * When paused, the dispatcher will not execute any coroutines automatically, and you must call [runCurrent] or * [advanceTimeBy], or [advanceUntilIdle] to execute coroutines. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public fun pauseDispatcher() /** @@ -119,7 +119,7 @@ public interface DelayController { * time and execute coroutines scheduled in the future use, one of [advanceTimeBy], * or [advanceUntilIdle]. */ - @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 + @ExperimentalCoroutinesApi public fun resumeDispatcher() } @@ -127,7 +127,7 @@ public interface DelayController { * Thrown when a test has completed and there are tasks that are not completed or cancelled. */ // todo: maybe convert into non-public class in 1.3.0 (need use-cases for a public exception type) -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public class UncompletedCoroutinesError(message: String): AssertionError(message) internal interface SchedulerAsDelayController: DelayController { @@ -179,4 +179,4 @@ internal interface SchedulerAsDelayController: DelayController { ) } } -} \ No newline at end of file +} diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index c3984f7a3f..ebe22050f1 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -41,7 +41,7 @@ import kotlin.coroutines.* * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively. * @param testBody The code of the unit-test. */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { val (safeContext, dispatcher) = context.checkTestScopeArguments() val startingJobs = safeContext.activeJobs() @@ -68,14 +68,14 @@ private fun CoroutineContext.activeJobs(): Set { * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ // todo: need documentation on how this extension is supposed to be used -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(coroutineContext, block) /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index 12d42b88e3..7baf0a8e17 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -21,7 +21,7 @@ import kotlin.coroutines.* * * @see DelayController */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public class TestCoroutineDispatcher(public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()): TestDispatcher(), Delay, SchedulerAsDelayController { diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt index b1296df12a..9f49292dab 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt @@ -11,7 +11,7 @@ import kotlin.coroutines.* /** * Access uncaught coroutine exceptions captured during test execution. */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public interface UncaughtExceptionCaptor { /** * List of uncaught coroutine exceptions. @@ -35,7 +35,7 @@ public interface UncaughtExceptionCaptor { /** * An exception handler that captures uncaught exceptions in tests. */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public class TestCoroutineExceptionHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), UncaughtExceptionCaptor, CoroutineExceptionHandler { diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index db5d2c1dcb..0d0716999c 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -13,9 +13,9 @@ import kotlin.jvm.* /** * This is a scheduler for coroutines used in tests, providing the delay-skipping behavior. * - * [Test dispatchers][TestCoroutineDispatcher] are parameterized with a scheduler. Several dispatchers can share the - * same scheduler, in which case * their knowledge about the virtual time will be synchronized. When the dispatchers - * require scheduling an event at a * later point in time, they notify the scheduler, which will establish the order of + * [Test dispatchers][TestDispatcher] are parameterized with a scheduler. Several dispatchers can share the + * same scheduler, in which case their knowledge about the virtual time will be synchronized. When the dispatchers + * require scheduling an event at a later point in time, they notify the scheduler, which will establish the order of * the tasks. * * The scheduler can be queried to advance the time (via [advanceTimeBy]), run all the scheduled tasks advancing the @@ -26,6 +26,7 @@ import kotlin.jvm.* // TODO: maybe make this a `TimeSource`? public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCoroutineScheduler), CoroutineContext.Element { + /** @suppress */ public companion object Key: CoroutineContext.Key /** This heap stores the knowledge about which dispatchers are interested in which moments of virtual time. */ @@ -45,8 +46,8 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti private set /** - * Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds later - * via [TestDispatcher.processEvent], which will be called with the provided [marker] object. + * Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds + * later via [TestDispatcher.processEvent], which will be called with the provided [marker] object. * * Returns the handler which can be used to cancel the registration. */ @@ -74,7 +75,7 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti * Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more * tasks associated with the dispatchers linked to this scheduler. * - * A breaking change from [TestCoroutineDispatcher.advanceTimeBy] is that it no longer returns the total amount of + * A breaking change from [TestCoroutineDispatcher.advanceTimeBy] is that it no longer returns the total number of * 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. */ @@ -124,24 +125,22 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti */ @ExperimentalCoroutinesApi public fun advanceTimeBy(delayTimeMillis: Long) { - require(delayTimeMillis >= 0) { "" } + require(delayTimeMillis >= 0) { "Can not advance time by a negative delay: $delayTimeMillis" } val startingTime = currentTime val targetTime = addClamping(startingTime, delayTimeMillis) while (true) { val event = synchronized(lock) { val timeMark = currentTime - val event = events.peek() + val event = events.removeFirstIf { targetTime > it.time } when { - event == null || targetTime <= event.time -> { + event == null -> { currentTime = targetTime return } timeMark > event.time -> currentTimeAheadOfEvents() else -> { - val event2 = events.removeFirstOrNull() - if (event !== event2) concurrentModificationUnderLock() currentTime = event.time - event2 + event } } } @@ -166,7 +165,6 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti // Some error-throwing functions for pretty stack traces private fun currentTimeAheadOfEvents(): Nothing = invalidSchedulerState() -private fun concurrentModificationUnderLock(): Nothing = invalidSchedulerState() private fun invalidSchedulerState(): Nothing = throw IllegalStateException("The test scheduler entered an invalid state. Please report this at https://github.com/Kotlin/kotlinx.coroutines/issues.") @@ -177,16 +175,13 @@ private class TestDispatchEvent( private val count: Long, @JvmField val time: Long, @JvmField val marker: T, - val isCancelled: () -> Boolean + @JvmField val isCancelled: () -> Boolean ) : Comparable>, ThreadSafeHeapNode { override var heap: ThreadSafeHeap<*>? = null override var index: Int = 0 - override fun compareTo(other: TestDispatchEvent<*>) = if (time == other.time) { - count.compareTo(other.count) - } else { - time.compareTo(other.time) - } + override fun compareTo(other: TestDispatchEvent<*>) = + compareValuesBy(this, other, TestDispatchEvent<*>::time, TestDispatchEvent<*>::count) override fun toString() = "TestDispatchEvent(time=$time, dispatcher=$dispatcher)" } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 426a3cae2f..fae427aa62 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -10,7 +10,7 @@ import kotlin.coroutines.* /** * A scope which provides detailed control over the execution of coroutines for tests. */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { /** * Call after the test completes. @@ -54,7 +54,7 @@ private class TestCoroutineScopeImpl ( * @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController] */ @Suppress("FunctionName") -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope = context.checkTestScopeArguments().let { TestCoroutineScopeImpl(it.first, it.second.scheduler) } @@ -109,7 +109,7 @@ public fun TestCoroutineScope.advanceTimeBy(delayTimeMillis: Long): Unit = * Advances the [testScheduler][TestCoroutineScope.testScheduler] to the point where there are no tasks remaining. * @see TestCoroutineScheduler.advanceUntilIdle */ -@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +@ExperimentalCoroutinesApi public fun TestCoroutineScope.advanceUntilIdle(): Unit { coroutineContext.delayController?.advanceUntilIdle() ?: testScheduler.advanceUntilIdle() } @@ -157,4 +157,4 @@ public fun TestCoroutineScope.resumeDispatcher() { private val TestCoroutineScope.delayControllerForPausing: DelayController get() = coroutineContext.delayController - ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") \ No newline at end of file + ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") From d6cf1201306e9712af2c9e2bb07216c34f8588ad Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 27 Oct 2021 10:29:25 +0300 Subject: [PATCH 09/22] Fixes --- kotlinx-coroutines-test/README.md | 5 ++--- .../api/kotlinx-coroutines-test.api | 8 -------- .../common/src/DelayController.kt | 11 ++--------- kotlinx-coroutines-test/common/src/TestBuilders.kt | 2 +- .../common/src/TestCoroutineScheduler.kt | 7 ++++--- .../common/src/TestCoroutineScope.kt | 12 ++++++++---- kotlinx-coroutines-test/common/src/TestDispatcher.kt | 1 - 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/kotlinx-coroutines-test/README.md b/kotlinx-coroutines-test/README.md index 43ae18f532..5130da1167 100644 --- a/kotlinx-coroutines-test/README.md +++ b/kotlinx-coroutines-test/README.md @@ -114,7 +114,7 @@ suspend fun bar() {} ``` `runBlockingTest` will auto-progress virtual time until all coroutines are completed before returning. If any coroutines -are not able to complete, an [UncompletedCoroutinesError] will be thrown. +are not able to complete, an `AssertionError` will be thrown. *Note:* The default eager behavior of [runBlockingTest] will ignore [CoroutineStart] parameters. @@ -148,7 +148,7 @@ suspend fun CoroutineScope.foo() { *Note:* `runBlockingTest` will always attempt to auto-progress time until all coroutines are completed just before exiting. This is a convenience to avoid having to call [advanceUntilIdle][DelayController.advanceUntilIdle] as the last line of many common test cases. -If any coroutines cannot complete by advancing time, an [UncompletedCoroutinesError] is thrown. +If any coroutines cannot complete by advancing time, an `AssertionError` is thrown. ### Testing `withTimeout` using `runBlockingTest` @@ -447,7 +447,6 @@ If you have any suggestions for improvements to this experimental API please sha [setMain]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/set-main.html [runBlockingTest]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-blocking-test.html -[UncompletedCoroutinesError]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-uncompleted-coroutines-error/index.html [DelayController]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/index.html [DelayController.advanceUntilIdle]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/advance-until-idle.html [DelayController.pauseDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/pause-dispatcher.html diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 3198253b0f..0aebee5275 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -58,10 +58,6 @@ public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kot public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; } -public final class kotlinx/coroutines/test/TestCoroutineScope$DefaultImpls { - public static fun getTestScheduler (Lkotlinx/coroutines/test/TestCoroutineScope;)Lkotlinx/coroutines/test/TestCoroutineScheduler; -} - public final class kotlinx/coroutines/test/TestCoroutineScopeKt { public static final fun TestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope; public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope; @@ -92,7 +88,3 @@ public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor public abstract fun getUncaughtExceptions ()Ljava/util/List; } -public final class kotlinx/coroutines/test/UncompletedCoroutinesError : java/lang/AssertionError { - public fun (Ljava/lang/String;)V -} - diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/DelayController.kt index b86955db31..5d8686da6b 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/DelayController.kt @@ -84,11 +84,11 @@ public interface DelayController { /** * Call after test code completes to ensure that the dispatcher is properly cleaned up. * - * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended + * @throws AssertionError if any pending tasks are active, however it will not throw for suspended * coroutines. */ @ExperimentalCoroutinesApi - @Throws(UncompletedCoroutinesError::class) + @Throws(AssertionError::class) public fun cleanupTestCoroutines() /** @@ -123,13 +123,6 @@ public interface DelayController { public fun resumeDispatcher() } -/** - * Thrown when a test has completed and there are tasks that are not completed or cancelled. - */ -// todo: maybe convert into non-public class in 1.3.0 (need use-cases for a public exception type) -@ExperimentalCoroutinesApi -public class UncompletedCoroutinesError(message: String): AssertionError(message) - internal interface SchedulerAsDelayController: DelayController { public val scheduler: TestCoroutineScheduler diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index ebe22050f1..f66f962be7 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -34,7 +34,7 @@ import kotlin.coroutines.* * * Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test. * - * @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches + * @throws AssertionError If the [testBody] does not complete (or cancel) all coroutines that it launches * (including coroutines suspended on join/await). * * @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler], diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 0d0716999c..2f5e1fd216 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -24,10 +24,11 @@ import kotlin.jvm.* */ @ExperimentalCoroutinesApi // TODO: maybe make this a `TimeSource`? -public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCoroutineScheduler), CoroutineContext.Element { +public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCoroutineScheduler), + CoroutineContext.Element { /** @suppress */ - public companion object Key: CoroutineContext.Key + public companion object Key : CoroutineContext.Key /** This heap stores the knowledge about which dispatchers are interested in which moments of virtual time. */ // TODO: all the synchronization is done via a separate lock, so a non-thread-safe priority queue can be used. @@ -55,7 +56,7 @@ public class TestCoroutineScheduler: AbstractCoroutineContextElement(TestCorouti dispatcher: TestDispatcher, timeDeltaMillis: Long, marker: T, - isCancelled : (T) -> Boolean + isCancelled: (T) -> Boolean ): DisposableHandle { require(timeDeltaMillis >= 0) { "Attempted scheduling an event earlier in time (with the time delta $timeDeltaMillis)" } val count = count.getAndIncrement() diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index fae427aa62..e4e92bd486 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -11,13 +11,13 @@ import kotlin.coroutines.* * A scope which provides detailed control over the execution of coroutines for tests. */ @ExperimentalCoroutinesApi -public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { +public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { /** * Call after the test completes. * Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines]. * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions. - * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended + * @throws AssertionError if any pending tasks are active, however it will not throw for suspended * coroutines. */ @ExperimentalCoroutinesApi @@ -28,8 +28,6 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { */ @ExperimentalCoroutinesApi public val testScheduler: TestCoroutineScheduler - get() = coroutineContext[TestCoroutineScheduler] - ?: throw UnsupportedOperationException("This scope does not have a TestCoroutineScheduler linked to it") } private class TestCoroutineScopeImpl ( @@ -158,3 +156,9 @@ public fun TestCoroutineScope.resumeDispatcher() { private val TestCoroutineScope.delayControllerForPausing: DelayController get() = coroutineContext.delayController ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") + +/** + * Thrown when a test has completed and there are tasks that are not completed or cancelled. + */ +@ExperimentalCoroutinesApi +internal class UncompletedCoroutinesError(message: String): AssertionError(message) diff --git a/kotlinx-coroutines-test/common/src/TestDispatcher.kt b/kotlinx-coroutines-test/common/src/TestDispatcher.kt index 549eddc34f..b37f10bfda 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatcher.kt @@ -18,7 +18,6 @@ public abstract class TestDispatcher: CoroutineDispatcher(), Delay { public abstract val scheduler: TestCoroutineScheduler /** Notifies the dispatcher that it should process a single event marked with [marker] happening at time [time]. */ - @ExperimentalCoroutinesApi internal abstract fun processEvent(time: Long, marker: Any) /** @suppress */ From 17aed1e3b30dde7328eb9ee9a702bb116c3f4b50 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 27 Oct 2021 14:34:26 +0300 Subject: [PATCH 10/22] Reformat --- .../common/src/DelayController.kt | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/DelayController.kt index 5d8686da6b..eba12efc27 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/DelayController.kt @@ -13,8 +13,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi * Testing libraries may expose this interface to the tests instead of [TestCoroutineDispatcher]. */ @ExperimentalCoroutinesApi -@Deprecated("Use `TestCoroutineScheduler` to control virtual time.", - level = DeprecationLevel.WARNING) +@Deprecated( + "Use `TestCoroutineScheduler` to control virtual time.", + level = DeprecationLevel.WARNING +) public interface DelayController { /** * Returns the current virtual clock-time as it is known to this Dispatcher. @@ -123,20 +125,25 @@ public interface DelayController { public fun resumeDispatcher() } -internal interface SchedulerAsDelayController: DelayController { +internal interface SchedulerAsDelayController : DelayController { public val scheduler: TestCoroutineScheduler /** @suppress */ - @Deprecated("This property delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + @Deprecated( + "This property delegates to the test scheduler, which may cause confusing behavior unless made explicit.", ReplaceWith("this.scheduler.currentTime"), - level = DeprecationLevel.WARNING) - override val currentTime: Long get() = scheduler.currentTime + level = DeprecationLevel.WARNING + ) + override val currentTime: Long + get() = scheduler.currentTime /** @suppress */ - @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + @Deprecated( + "This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", ReplaceWith("this.scheduler.apply { advanceTimeBy(delayTimeMillis); runCurrent() }"), - level = DeprecationLevel.WARNING) + level = DeprecationLevel.WARNING + ) override fun advanceTimeBy(delayTimeMillis: Long): Long { val oldTime = scheduler.currentTime scheduler.advanceTimeBy(delayTimeMillis) @@ -145,9 +152,11 @@ internal interface SchedulerAsDelayController: DelayController { } /** @suppress */ - @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + @Deprecated( + "This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", ReplaceWith("this.scheduler.advanceUntilIdle()"), - level = DeprecationLevel.WARNING) + level = DeprecationLevel.WARNING + ) override fun advanceUntilIdle(): Long { val oldTime = scheduler.currentTime scheduler.advanceUntilIdle() @@ -155,9 +164,11 @@ internal interface SchedulerAsDelayController: DelayController { } /** @suppress */ - @Deprecated("This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", + @Deprecated( + "This function delegates to the test scheduler, which may cause confusing behavior unless made explicit.", ReplaceWith("this.scheduler.runCurrent()"), - level = DeprecationLevel.WARNING) + level = DeprecationLevel.WARNING + ) override fun runCurrent(): Unit = scheduler.runCurrent() /** @suppress */ From 721b5fe7661717b4aa4d028df895be7af0c4761b Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Thu, 28 Oct 2021 15:09:57 +0300 Subject: [PATCH 11/22] Improve `TestCoroutineScope` (#2975) * Add more detailed documentation; * Move most verification logic from `runBlockingTest` to `cleanupTestCoroutines` Fixes #1749 --- .../common/src/TestBuilders.kt | 45 +---------- .../common/src/TestCoroutineScope.kt | 74 ++++++++++++++++--- .../common/test/TestBuildersTest.kt | 2 +- .../common/test/TestCoroutineSchedulerTest.kt | 1 + .../common/test/TestCoroutineScopeTest.kt | 45 +++++++++++ 5 files changed, 112 insertions(+), 55 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index f66f962be7..bf864333b4 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -43,25 +43,16 @@ import kotlin.coroutines.* */ @ExperimentalCoroutinesApi public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { - val (safeContext, dispatcher) = context.checkTestScopeArguments() - val startingJobs = safeContext.activeJobs() - val scope = TestCoroutineScope(safeContext) + val scope = TestCoroutineScope(context) + val scheduler = scope.coroutineContext[TestCoroutineScheduler]!! val deferred = scope.async { scope.testBody() } - dispatcher.scheduler.advanceUntilIdle() + scheduler.advanceUntilIdle() deferred.getCompletionExceptionOrNull()?.let { throw it } scope.cleanupTestCoroutines() - val endingJobs = safeContext.activeJobs() - if ((endingJobs - startingJobs).isNotEmpty()) { - throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") - } -} - -private fun CoroutineContext.activeJobs(): Set { - return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() } /** @@ -78,33 +69,3 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope. @ExperimentalCoroutinesApi public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) - -internal fun CoroutineContext.checkTestScopeArguments(): Pair { - val scheduler: TestCoroutineScheduler - val dispatcher = when (val dispatcher = get(ContinuationInterceptor)) { - is TestDispatcher -> { - val ctxScheduler = get(TestCoroutineScheduler) - if (ctxScheduler == null) { - scheduler = dispatcher.scheduler - } else { - require(dispatcher.scheduler === ctxScheduler) { - "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + - "another scheduler were passed." - } - scheduler = ctxScheduler - } - dispatcher - } - null -> { - scheduler = get(TestCoroutineScheduler) ?: TestCoroutineScheduler() - TestCoroutineDispatcher(scheduler) - } - else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") - } - val exceptionHandler = get(CoroutineExceptionHandler).run { - this?.let { require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $this" } } - this ?: TestCoroutineExceptionHandler() - } - val job = get(Job) ?: SupervisorJob() - return Pair(this + scheduler + dispatcher + exceptionHandler + job, dispatcher) -} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index e4e92bd486..c37f834356 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -13,12 +13,12 @@ import kotlin.coroutines.* @ExperimentalCoroutinesApi public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { /** - * Call after the test completes. + * Called after the test completes. + * * Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines]. * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions. - * @throws AssertionError if any pending tasks are active, however it will not throw for suspended - * coroutines. + * @throws AssertionError if any pending tasks are active. */ @ExperimentalCoroutinesApi public fun cleanupTestCoroutines() @@ -30,31 +30,81 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap public val testScheduler: TestCoroutineScheduler } -private class TestCoroutineScopeImpl ( - override val coroutineContext: CoroutineContext, - override val testScheduler: TestCoroutineScheduler +private class TestCoroutineScopeImpl( + override val coroutineContext: CoroutineContext ): TestCoroutineScope, UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor { + override val testScheduler: TestCoroutineScheduler + get() = coroutineContext[TestCoroutineScheduler]!! + + /** These jobs existed before the coroutine scope was used, so it's alright if they don't get cancelled. */ + private val initialJobs = coroutineContext.activeJobs() + override fun cleanupTestCoroutines() { coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() coroutineContext.delayController?.cleanupTestCoroutines() + val jobs = coroutineContext.activeJobs() + if ((jobs - initialJobs).isNotEmpty()) + throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") } } +private fun CoroutineContext.activeJobs(): Set { + return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() +} + /** - * A scope which provides detailed control over the execution of coroutines for tests. + * A coroutine scope for launching test coroutines. * - * If the provided context does not provide a [ContinuationInterceptor] (Dispatcher) or [CoroutineExceptionHandler], the - * scope adds [TestCoroutineDispatcher] and [TestCoroutineExceptionHandler] automatically. + * It ensures that all the test module machinery is properly initialized. + * * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, + * a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used. + * * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created. + * * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created + * automatically. + * * If [context] provides a [Job], that job is used for the new scope; otherwise, a [CompletableJob] is created. * - * @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController] + * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a + * different scheduler. + * @throws IllegalArgumentException if [context] has a [ContinuationInterceptor] that is not a [TestDispatcher]. + * @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an + * [UncaughtExceptionCaptor]. */ @Suppress("FunctionName") @ExperimentalCoroutinesApi -public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope = - context.checkTestScopeArguments().let { TestCoroutineScopeImpl(it.first, it.second.scheduler) } +public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { + val scheduler: TestCoroutineScheduler + val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) { + is TestDispatcher -> { + scheduler = dispatcher.scheduler + val ctxScheduler = context[TestCoroutineScheduler] + if (ctxScheduler != null) { + require(dispatcher.scheduler === ctxScheduler) { + "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + + "another scheduler were passed." + } + } + dispatcher + } + null -> { + scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() + TestCoroutineDispatcher(scheduler) + } + else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") + } + val exceptionHandler = context[CoroutineExceptionHandler].run { + this?.let { + require(this is UncaughtExceptionCaptor) { + "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context" + } + } + this ?: TestCoroutineExceptionHandler() + } + val job: Job = context[Job] ?: SupervisorJob() + return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job) +} private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor get() { diff --git a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt index a3167e5876..7fefaf78b5 100644 --- a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt @@ -104,7 +104,7 @@ class TestBuildersTest { } @Test - fun whenInrunBlocking_runBlockingTest_nestsProperly() { + fun whenInRunBlocking_runBlockingTest_nestsProperly() { // this is not a supported use case, but it is possible so ensure it works val scope = TestCoroutineScope() diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index ab6ced4741..f7d2ed13fd 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -5,6 +5,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlin.coroutines.* import kotlin.test.* class TestCoroutineSchedulerTest { diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index a7d4480d63..85e44351ee 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -50,6 +50,51 @@ class TestCoroutineScopeTest { } } + /** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */ + @Test + fun testPresentDelaysThrowing() { + val scope = TestCoroutineScope() + var result = false + scope.launch { + delay(5) + result = true + } + assertFalse(result) + assertFailsWith { scope.cleanupTestCoroutines() } + assertFalse(result) + } + + /** Tests that the cleanup procedure throws if there were active jobs by the end. */ + @Test + fun testActiveJobsThrowing() { + val scope = TestCoroutineScope() + var result = false + val deferred = CompletableDeferred() + scope.launch { + deferred.await() + result = true + } + assertFalse(result) + assertFailsWith { scope.cleanupTestCoroutines() } + assertFalse(result) + } + + /** Tests that the cleanup procedure doesn't throw if it detects that the job is already cancelled. */ + @Test + fun testCancelledDelaysNotThrowing() { + val scope = TestCoroutineScope() + var result = false + val deferred = CompletableDeferred() + val job = scope.launch { + deferred.await() + result = true + } + job.cancel() + assertFalse(result) + scope.cleanupTestCoroutines() + assertFalse(result) + } + private val invalidContexts = listOf( Dispatchers.Default, // not a [TestDispatcher] TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler From d9bc7acb29da1882f0c6cee04a52b368b98d6ede Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Mon, 1 Nov 2021 16:26:45 +0300 Subject: [PATCH 12/22] Implement 'runTest' that waits for asynchronous completion (#2978) Implement a multiplatform runTest as an initial implementation of #1996. Fixes #1204 Fixes #1222 Fixes #1395 Fixes #1881 Fixes #1910 Fixes #1772 --- .../api/kotlinx-coroutines-test.api | 6 + .../common/src/TestBuilders.kt | 258 +++++++++++++++++- .../common/src/TestCoroutineDispatcher.kt | 1 + .../common/src/TestCoroutineScheduler.kt | 47 +++- .../common/src/TestCoroutineScope.kt | 5 +- .../common/test/Helpers.kt | 32 +++ .../common/test/RunTestTest.kt | 254 +++++++++++++++++ .../common/test/TestCoroutineSchedulerTest.kt | 83 +++++- .../common/test/TestCoroutineScopeTest.kt | 41 ++- .../common/test/TestDispatchersTest.kt | 3 +- .../common/test/TestModuleHelpers.kt | 30 -- .../common/test/TestRunBlockingTest.kt | 4 +- .../js/src/TestBuilders.kt | 15 + kotlinx-coroutines-test/js/test/Helpers.kt | 16 ++ .../js/test/PromiseTest.kt | 21 ++ .../jvm/src/TestBuildersJvm.kt | 15 + .../jvm/test/HelpersJvm.kt | 10 + .../jvm/test/MultithreadingTest.kt | 18 +- .../native/src/TestBuilders.kt | 15 + .../src/{ => internal}/TestMainDispatcher.kt | 0 .../native/test/Helpers.kt | 6 + 21 files changed, 817 insertions(+), 63 deletions(-) create mode 100644 kotlinx-coroutines-test/common/test/RunTestTest.kt delete mode 100644 kotlinx-coroutines-test/common/test/TestModuleHelpers.kt create mode 100644 kotlinx-coroutines-test/js/src/TestBuilders.kt create mode 100644 kotlinx-coroutines-test/js/test/Helpers.kt create mode 100644 kotlinx-coroutines-test/js/test/PromiseTest.kt create mode 100644 kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt create mode 100644 kotlinx-coroutines-test/jvm/test/HelpersJvm.kt create mode 100644 kotlinx-coroutines-test/native/src/TestBuilders.kt rename kotlinx-coroutines-test/native/src/{ => internal}/TestMainDispatcher.kt (100%) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 0aebee5275..f60326b0eb 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -14,6 +14,12 @@ public final class kotlinx/coroutines/test/TestBuildersKt { public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineDispatcher;Lkotlin/jvm/functions/Function2;)V public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V + public static final fun runTest (Lkotlinx/coroutines/test/TestDispatcher;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/TestDispatcher;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController { diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index bf864333b4..941f864a3e 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -5,6 +5,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlinx.coroutines.selects.* import kotlin.coroutines.* /** @@ -41,10 +42,10 @@ import kotlin.coroutines.* * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively. * @param testBody The code of the unit-test. */ -@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { - val scope = TestCoroutineScope(context) - val scheduler = scope.coroutineContext[TestCoroutineScheduler]!! + val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) + val scheduler = scope.testScheduler val deferred = scope.async { scope.testBody() } @@ -59,13 +60,260 @@ public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, te * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ // todo: need documentation on how this extension is supposed to be used -@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(coroutineContext, block) /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. */ -@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) + +/** + * A test result. + * + * * On JVM and Native, this resolves to [Unit], representing the fact that tests are run in a blocking manner on these + * platforms: a call to a function returning a [TestResult] will simply execute the test inside it. + * * On JS, this is a `Promise`, which reflects the fact that the test-running function does not wait for a test to + * finish. The JS test frameworks typically support returning `Promise` from a test and will correctly handle it. + * + * Because of the behavior on JS, extra care must be taken when writing multiplatform tests to avoid losing test errors: + * * Don't do anything after running the functions returning a [TestResult]. On JS, this code will execute *before* the + * test finishes. + * * As a corollary, don't run functions returning a [TestResult] more than once per test. The only valid thing to do + * with a [TestResult] is to immediately `return` it from a test. + * * Don't nest functions returning a [TestResult]. + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +@ExperimentalCoroutinesApi +public expect class TestResult + +/** + * Executes [testBody] as a test in a new coroutine, returning [TestResult]. + * + * On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs + * will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary. + * On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior. + * + * ``` + * @Test + * fun exampleTest() = runTest { + * val deferred = async { + * delay(1_000) + * async { + * delay(1_000) + * }.await() + * } + * + * deferred.await() // result available immediately + * } + * ``` + * + * The platform difference entails that, in order to use this function correctly in common code, one must always + * immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See + * [TestResult] for details on this. + * + * The test is run in a single thread, unless other [ContinuationInterceptor] are used for child coroutines. + * Because of this, child coroutines are not executed in parallel to the test body. + * In order to for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the + * test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]). + * + * ``` + * @Test + * fun exampleWaitingForAsyncTasks1() = runTest { + * // 1 + * val job = launch { + * // 3 + * } + * // 2 + * job.join() // the main test coroutine suspends here, so the child is executed + * // 4 + * } + * + * @Test + * fun exampleWaitingForAsyncTasks2() = runTest { + * // 1 + * launch { + * // 3 + * } + * // 2 + * advanceUntilIdle() // runs the tasks until their queue is empty + * // 4 + * } + * ``` + * + * ### Task scheduling + * + * Delay-skipping is achieved by using virtual time. [TestCoroutineScheduler] is automatically created (if it wasn't + * passed in some way in [context]) and can be used to control the virtual time, advancing it, running the tasks + * scheduled at a specific time etc. Some convenience methods are available on [TestCoroutineScope] to control the + * scheduler. + * + * Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped: + * ``` + * @Test + * fun exampleTest() = runTest { + * val elapsed = TimeSource.Monotonic.measureTime { + * val deferred = async { + * delay(1_000) // will be skipped + * withContext(Dispatchers.Default) { + * delay(5_000) // Dispatchers.Default doesn't know about TestCoroutineScheduler + * } + * } + * deferred.await() + * } + * println(elapsed) // about five seconds + * } + * ``` + * + * ### Failures + * + * #### Test body failures + * + * If the created coroutine completes with an exception, then this exception will be thrown at the end of the test. + * + * #### Reported exceptions + * + * Unhandled exceptions will be thrown at the end of the test. + * If the uncaught exceptions happen after the test finishes, the error is propagated in a platform-specific manner. + * If the test coroutine completes with an exception, the unhandled exceptions are suppressed by it. + * + * #### Uncompleted coroutines + * + * This method requires that, after the test coroutine has completed, all the other coroutines launched inside + * [testBody] also complete, or are cancelled. + * 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 [dispatchTimeoutMs] milliseconds (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 + * scope created for the test, [context] also can be used to change how the test is executed. + * See the [TestCoroutineScope] constructor documentation for details. + * + * @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for + * details. + */ +@ExperimentalCoroutinesApi +public fun runTest( + context: CoroutineContext = EmptyCoroutineContext, + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + testBody: suspend TestCoroutineScope.() -> Unit +): TestResult { + if (context[RunningInRunTest] != null) + throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") + val testScope = TestBodyCoroutine(TestCoroutineScope(context + RunningInRunTest)) + val scheduler = testScope.testScheduler + return createTestResult { + /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with + * [TestCoroutineDispatcher], because the event loop is not started. */ + testScope.start(CoroutineStart.DEFAULT, testScope) { + testBody() + } + var completed = false + while (!completed) { + scheduler.advanceUntilIdle() + if (testScope.isCompleted) { + /* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no + non-trivial dispatches. */ + completed = true + continue + } + select { + testScope.onJoin { + completed = true + } + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } + onTimeout(dispatchTimeoutMs) { + try { + testScope.cleanupTestCoroutines() + } catch (e: UncompletedCoroutinesError) { + // we expect these and will instead throw a more informative exception just below. + } + throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") + } + } + } + testScope.getCompletionExceptionOrNull()?.let { + try { + testScope.cleanupTestCoroutines() + } catch (e: UncompletedCoroutinesError) { + // it's normal that some jobs are not completed if the test body has failed, won't clutter the output + } catch (e: Throwable) { + it.addSuppressed(e) + } + throw it + } + testScope.cleanupTestCoroutines() + } +} + +/** + * Runs [testProcedure], creating a [TestResult]. + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` +internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult + +/** + * Runs a test in a [TestCoroutineScope] based on this one. + * + * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run + * [block] will be different from this one, but will use its [Job] as a parent. + * + * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned + * immediately from the test body. See the docs for [TestResult] for details. + */ +@ExperimentalCoroutinesApi +public fun TestCoroutineScope.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult = + runTest(coroutineContext, dispatchTimeoutMs, block) + +/** + * Run a test using this [TestDispatcher]. + * + * A convenience function that calls [runTest] with the given arguments. + * + * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned + * immediately from the test body. See the docs for [TestResult] for details. + */ +@ExperimentalCoroutinesApi +public fun TestDispatcher.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult = + runTest(this, dispatchTimeoutMs, block) + +/** A coroutine context element indicating that the coroutine is running inside `runTest`. */ +private object RunningInRunTest: CoroutineContext.Key, CoroutineContext.Element { + override val key: CoroutineContext.Key<*> + get() = this + + override fun toString(): String = "RunningInRunTest" +} + +/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by + * a [TestCoroutineScheduler]. */ +private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L + +private class TestBodyCoroutine( + private val testScope: TestCoroutineScope, +) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope, + UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor +{ + override val testScheduler get() = testScope.testScheduler + + override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines() + +} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index 7baf0a8e17..3537910ac5 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -44,6 +44,7 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin override fun dispatch(context: CoroutineContext, block: Runnable) { checkSchedulerInContext(scheduler, context) if (dispatchImmediately) { + scheduler.sendDispatchEvent() block.run() } else { post(block) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 2f5e1fd216..2acd8e527f 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -6,7 +6,10 @@ package kotlinx.coroutines.test import kotlinx.atomicfu.* import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.internal.* +import kotlinx.coroutines.selects.* import kotlin.coroutines.* import kotlin.jvm.* @@ -46,6 +49,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout get() = synchronized(lock) { field } private set + /** A channel for notifying about the fact that a dispatch recently happened. */ + private val dispatchEvents: Channel = Channel(CONFLATED) + /** * Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds * later via [TestDispatcher.processEvent], which will be called with the provided [marker] object. @@ -64,6 +70,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout val time = addClamping(currentTime, timeDeltaMillis) val event = TestDispatchEvent(dispatcher, count, time, marker as Any) { 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. */ + sendDispatchEvent() DisposableHandle { synchronized(lock) { events.remove(event) @@ -72,6 +81,21 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout } } + /** + * Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening. + */ + private fun tryRunNextTask(): Boolean { + val event = synchronized(lock) { + val event = events.removeFirstOrNull() ?: return false + if (currentTime > event.time) + currentTimeAheadOfEvents() + currentTime = event.time + event + } + event.dispatcher.processEvent(event.time, event.marker) + return true + } + /** * Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more * tasks associated with the dispatchers linked to this scheduler. @@ -82,15 +106,8 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout */ @ExperimentalCoroutinesApi public fun advanceUntilIdle() { - while (!events.isEmpty) { - val event = synchronized(lock) { - val event = events.removeFirstOrNull() ?: return - if (currentTime > event.time) - currentTimeAheadOfEvents() - currentTime = event.time - event - } - event.dispatcher.processEvent(event.time, event.marker) + while (!synchronized(lock) { events.isEmpty }) { + tryRunNextTask() } } @@ -162,6 +179,18 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout return presentEvents.all { it.isCancelled() } } } + + /** + * Notifies this scheduler about a dispatch event. + */ + internal fun sendDispatchEvent() { + dispatchEvents.trySend(Unit) + } + + /** + * Consumes the knowledge that a dispatch event happened recently. + */ + internal val onDispatchEvent: SelectClause1 get() = dispatchEvents.onReceive } // Some error-throwing functions for pretty stack traces diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index c37f834356..17978df229 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -16,6 +16,7 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap * Called after the test completes. * * Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines]. + * If a new job was created for this scope, the job is completed. * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions. * @throws AssertionError if any pending tasks are active. @@ -102,11 +103,11 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) } this ?: TestCoroutineExceptionHandler() } - val job: Job = context[Job] ?: SupervisorJob() + val job: Job = context[Job] ?: Job() return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job) } -private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor +internal inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor get() { val handler = this[CoroutineExceptionHandler] return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException( diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt index f0a462e4b6..5c1796b148 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -4,5 +4,37 @@ package kotlinx.coroutines.test +import kotlin.test.* +import kotlin.time.* + +/** + * The number of milliseconds that is sure not to pass [assertRunsFast]. + */ +const val SLOW = 100_000L + +/** + * Asserts that a block completed within [timeout]. + */ +@OptIn(ExperimentalTime::class) +inline fun assertRunsFast(timeout: Duration, block: () -> T): T { + val result: T + val elapsed = TimeSource.Monotonic.measureTime { result = block() } + assertTrue("Should complete in $timeout, but took $elapsed") { elapsed < timeout } + return result +} + +/** + * Asserts that a block completed within two seconds. + */ +@OptIn(ExperimentalTime::class) +inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.seconds(2), block) + +/** + * Passes [test] as an argument to [block], but as a function returning not a [TestResult] but [Unit]. +*/ +expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult + +class TestException(message: String? = null): Exception(message) + @OptionalExpectation expect annotation class NoNative() diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt new file mode 100644 index 0000000000..fbca2b05ac --- /dev/null +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2016-2021 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.flow.* +import kotlin.coroutines.* +import kotlin.test.* + +class RunTestTest { + + /** Tests that [withContext] that sends work to other threads works in [runTest]. */ + @Test + fun testWithContextDispatching() = runTest { + var counter = 0 + withContext(Dispatchers.Default) { + counter += 1 + } + assertEquals(counter, 1) + } + + /** Tests that joining [GlobalScope.launch] works in [runTest]. */ + @Test + fun testJoiningForkedJob() = runTest { + var counter = 0 + val job = GlobalScope.launch { + counter += 1 + } + job.join() + assertEquals(counter, 1) + } + + /** Tests [suspendCoroutine] not failing [runTest]. */ + @Test + fun testSuspendCoroutine() = runTest { + val answer = suspendCoroutine { + it.resume(42) + } + assertEquals(42, answer) + } + + /** Tests that [runTest] attempts to detect it being run inside another [runTest] and failing in such scenarios. */ + @Test + fun testNestedRunTestForbidden() = runTest { + assertFailsWith { + runTest { } + } + } + + /** 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) { + // below is some arbitrary concurrent code where all dispatches go through the same scheduler. + launch { + delay(2000) + } + val deferred = async { + val job = launch(TestCoroutineDispatcher(testScheduler)) { + launch { + delay(500) + } + delay(1000) + } + job.join() + } + deferred.await() + } + + /** Tests that a dispatch timeout of `0` will fail the test if there are some dispatches outside the scheduler. */ + @Test + fun testRunTestWithZeroTimeoutWithUncontrolledDispatches() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 0) { + withContext(Dispatchers.Default) { + delay(10) + 3 + } + fail("shouldn't be reached") + } + } + + /** Tests that too low of a dispatch timeout causes crashes. */ + @Test + @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestWithSmallTimeout() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 100) { + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + /** Tests that too low of a dispatch timeout causes crashes. */ + @Test + fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) { + withContext(Dispatchers.Default) { + delay(50) + } + } + + /** Tests uncaught exceptions taking priority over dispatch timeout in error reports. */ + @Test + @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 1) { + coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException()) + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + /** Tests that passing invalid contexts to [runTest] causes it to fail (on JS, without forking). */ + @Test + fun testRunTestWithIllegalContext() { + for (ctx in TestCoroutineScopeTest.invalidContexts) { + assertFailsWith { + runTest(ctx) { } + } + } + } + + /** Tests that throwing exceptions in [runTest] fails the test with them. */ + @Test + fun testThrowingInRunTestBody() = testResultMap({ + assertFailsWith { it() } + }) { + runTest { + throw RuntimeException() + } + } + + /** Tests that throwing exceptions in pending tasks [runTest] fails the test with them. */ + @Test + fun testThrowingInRunTestPendingTask() = testResultMap({ + assertFailsWith { it() } + }) { + runTest { + launch { + delay(SLOW) + throw RuntimeException() + } + } + } + + @Test + fun reproducer2405() = runTest { + val dispatcher = TestCoroutineDispatcher(testScheduler) + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + /** Tests that, once the test body has thrown, the child coroutines are cancelled. */ + @Test + fun testChildrenCancellationOnTestBodyFailure() { + var job: Job? = null + testResultMap({ + assertFailsWith { it() } + assertTrue(job!!.isCancelled) + }, { + runTest { + job = launch { + while (true) { + delay(1000) + } + } + throw AssertionError() + } + }) + } + + /** Tests that [runTest] reports [TimeoutCancellationException]. */ + @Test + fun testTimeout() = testResultMap({ + assertFailsWith { it() } + }, { + runTest { + withTimeout(50) { + launch { + delay(1000) + } + } + } + }) + + /** Checks that [runTest] throws the root cause and not [JobCancellationException] when a child coroutine throws. */ + @Test + fun testRunTestThrowsRootCause() = testResultMap({ + assertFailsWith { it() } + }, { + runTest { + launch { + throw TestException() + } + } + }) + + /** Tests that [runTest] completes its job. */ + @Test + fun testCompletesOwnJob(): TestResult { + var handlerCalled = false + return testResultMap({ + it() + assertTrue(handlerCalled) + }, { + runTest { + coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + } + }) + } + + /** Tests that [runTest] doesn't complete the job that was passed to it as an argument. */ + @Test + fun testDoesNotCompleteGivenJob(): TestResult { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + return testResultMap({ + it() + assertFalse(handlerCalled) + assertEquals(0, job.children.filter { it.isActive }.count()) + }, { + runTest(job) { + assertTrue(coroutineContext.job in job.children) + } + }) + } +} diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index f7d2ed13fd..45c02c09ad 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -11,7 +11,7 @@ import kotlin.test.* class TestCoroutineSchedulerTest { /** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */ @Test - fun testContextElement() = runBlockingTest { + fun testContextElement() = runTest { assertFailsWith { withContext(TestCoroutineDispatcher()) { } @@ -21,7 +21,7 @@ class TestCoroutineSchedulerTest { /** Tests that, as opposed to [DelayController.advanceTimeBy] or [TestCoroutineScope.advanceTimeBy], * [TestCoroutineScheduler.advanceTimeBy] doesn't run the tasks scheduled at the target moment. */ @Test - fun testAdvanceTimeByDoesNotRunCurrent() = runBlockingTest { + fun testAdvanceTimeByDoesNotRunCurrent() = runTest { var entered = false launch { delay(15) @@ -45,7 +45,7 @@ class TestCoroutineSchedulerTest { /** Tests that if [TestCoroutineScheduler.advanceTimeBy] encounters an arithmetic overflow, all the tasks scheduled * until the moment [Long.MAX_VALUE] get run. */ @Test - fun testAdvanceTimeByEnormousDelays() = runBlockingTest { + fun testAdvanceTimeByEnormousDelays() = runTest { val initialDelay = 10L delay(initialDelay) assertEquals(initialDelay, currentTime) @@ -99,7 +99,7 @@ class TestCoroutineSchedulerTest { /** Tests the basic functionality of [TestCoroutineScheduler.runCurrent]. */ @Test - fun testRunCurrent() = runBlockingTest { + fun testRunCurrent() = runTest { var stage = 0 launch { delay(1) @@ -182,4 +182,79 @@ class TestCoroutineSchedulerTest { stage = 1 scope.runCurrent() } + + private fun TestCoroutineScope.checkTimeout( + timesOut: Boolean, timeoutMillis: Long = SLOW, block: suspend () -> Unit + ) = assertRunsFast { + var caughtException = false + launch { + try { + withTimeout(timeoutMillis) { + block() + } + } catch (e: TimeoutCancellationException) { + caughtException = true + } + } + advanceUntilIdle() + cleanupTestCoroutines() + if (timesOut) + assertTrue(caughtException) + else + assertFalse(caughtException) + } + + /** Tests that timeouts get triggered. */ + @Test + fun testSmallTimeouts() { + val scope = TestCoroutineScope() + scope.checkTimeout(true) { + val half = SLOW / 2 + delay(half) + delay(SLOW - half) + } + } + + /** Tests that timeouts don't get triggered if the code finishes in time. */ + @Test + fun testLargeTimeouts() { + val scope = TestCoroutineScope() + scope.checkTimeout(false) { + val half = SLOW / 2 + delay(half) + delay(SLOW - half - 1) + } + } + + /** Tests that timeouts get triggered if the code fails to finish in time asynchronously. */ + @Test + fun testSmallAsynchronousTimeouts() { + val scope = TestCoroutineScope() + val deferred = CompletableDeferred() + scope.launch { + val half = SLOW / 2 + delay(half) + delay(SLOW - half) + deferred.complete(Unit) + } + scope.checkTimeout(true) { + deferred.await() + } + } + + /** Tests that timeouts don't get triggered if the code finishes in time, even if it does so asynchronously. */ + @Test + fun testLargeAsynchronousTimeouts() { + val scope = TestCoroutineScope() + val deferred = CompletableDeferred() + scope.launch { + val half = SLOW / 2 + delay(half) + delay(SLOW - half - 1) + deferred.complete(Unit) + } + scope.checkTimeout(false) { + deferred.await() + } + } } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index 85e44351ee..b81eddbb5b 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -95,9 +95,40 @@ class TestCoroutineScopeTest { assertFalse(result) } - private val invalidContexts = listOf( - Dispatchers.Default, // not a [TestDispatcher] - TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler - CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` - ) + /** Tests that uncaught exceptions are thrown at the cleanup. */ + @Test + fun testThrowsUncaughtExceptionsOnCleanup() { + val scope = TestCoroutineScope() + val exception = TestException("test") + scope.launch { + throw exception + } + assertFailsWith { + scope.cleanupTestCoroutines() + } + } + + /** Tests that uncaught exceptions take priority over uncompleted jobs when throwing on cleanup. */ + @Test + fun testUncaughtExceptionsPrioritizedOnCleanup() { + val scope = TestCoroutineScope() + val exception = TestException("test") + scope.launch { + throw exception + } + scope.launch { + delay(1000) + } + assertFailsWith { + scope.cleanupTestCoroutines() + } + } + + companion object { + internal val invalidContexts = listOf( + Dispatchers.Default, // not a [TestDispatcher] + TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler + CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` + ) + } } diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 4ffbfc2c37..441ea0418e 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -34,8 +34,7 @@ class TestDispatchersTest { } @Test - @NoNative - fun testImmediateDispatcher() = runBlockingTest { + fun testImmediateDispatcher() = runTest { Dispatchers.setMain(ImmediateDispatcher()) expect(1) withContext(Dispatchers.Main) { diff --git a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt b/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt deleted file mode 100644 index f3b8e79407..0000000000 --- a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. - */ - -package kotlinx.coroutines.test - -import kotlin.test.* -import kotlin.time.* - -/** - * The number of milliseconds that is sure not to pass [assertRunsFast]. - */ -const val SLOW = 100_000L - -/** - * Asserts that a block completed within [timeout]. - */ -@OptIn(ExperimentalTime::class) -inline fun assertRunsFast(timeout: Duration, block: () -> T): T { - val result: T - val elapsed = TimeSource.Monotonic.measureTime { result = block() } - assertTrue("Should complete in $timeout, but took $elapsed") { elapsed < timeout } - return result -} - -/** - * Asserts that a block completed within two seconds. - */ -@OptIn(ExperimentalTime::class) -inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.seconds(2), block) diff --git a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt index 041f58a3c5..139229e610 100644 --- a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt +++ b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt @@ -435,6 +435,4 @@ class TestRunBlockingTest { } } } -} - -private class TestException(message: String? = null): Exception(message) \ No newline at end of file +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/js/src/TestBuilders.kt b/kotlinx-coroutines-test/js/src/TestBuilders.kt new file mode 100644 index 0000000000..3976885991 --- /dev/null +++ b/kotlinx-coroutines-test/js/src/TestBuilders.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2016-2021 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 kotlin.js.* + +@Suppress("ACTUAL_WITHOUT_EXPECT", "ACTUAL_TYPE_ALIAS_TO_CLASS_WITH_DECLARATION_SITE_VARIANCE") +public actual typealias TestResult = Promise + +internal actual fun createTestResult(testProcedure: suspend () -> Unit): TestResult = + GlobalScope.promise { + testProcedure() + } \ No newline at end of file diff --git a/kotlinx-coroutines-test/js/test/Helpers.kt b/kotlinx-coroutines-test/js/test/Helpers.kt new file mode 100644 index 0000000000..b0a767c5df --- /dev/null +++ b/kotlinx-coroutines-test/js/test/Helpers.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult = + test().then( + { + block { + } + }, { + block { + throw it + } + }) diff --git a/kotlinx-coroutines-test/js/test/PromiseTest.kt b/kotlinx-coroutines-test/js/test/PromiseTest.kt new file mode 100644 index 0000000000..ff09d9ab86 --- /dev/null +++ b/kotlinx-coroutines-test/js/test/PromiseTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2016-2021 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 kotlin.test.* + +class PromiseTest { + @Test + fun testCompletionFromPromise() = runTest { + var promiseEntered = false + val p = promise { + delay(1) + promiseEntered = true + } + delay(2) + p.await() + assertTrue(promiseEntered) + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt new file mode 100644 index 0000000000..7cafb54753 --- /dev/null +++ b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* + +@Suppress("ACTUAL_WITHOUT_EXPECT") +public actual typealias TestResult = Unit + +internal actual fun createTestResult(testProcedure: suspend () -> Unit) { + runBlocking { + testProcedure() + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt new file mode 100644 index 0000000000..e9aa3ff747 --- /dev/null +++ b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +package kotlinx.coroutines.test + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { + block { + test() + } +} diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index 7dc99406a1..6b0c071a56 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -4,10 +4,11 @@ import kotlinx.coroutines.* import kotlinx.coroutines.test.* +import kotlin.concurrent.* import kotlin.coroutines.* import kotlin.test.* -class MultithreadingTest : TestBase() { +class MultithreadingTest { @Test fun incorrectlyCalledRunBlocking_doesNotHaveSameInterceptor() = runBlockingTest { @@ -22,7 +23,7 @@ class MultithreadingTest : TestBase() { } @Test - fun testSingleThreadExecutor() = runTest { + fun testSingleThreadExecutor() = runBlocking { val mainThread = Thread.currentThread() Dispatchers.setMain(Dispatchers.Unconfined) newSingleThreadContext("testSingleThread").use { threadPool -> @@ -86,4 +87,15 @@ class MultithreadingTest : TestBase() { assertEquals(3, deferred.await()) } } -} \ No newline at end of file + + /** Tests that resuming the coroutine of [runTest] asynchronously in reasonable time succeeds. */ + @Test + fun testResumingFromAnotherThread() = runTest { + suspendCancellableCoroutine { cont -> + thread { + Thread.sleep(10) + cont.resume(Unit) + } + } + } +} diff --git a/kotlinx-coroutines-test/native/src/TestBuilders.kt b/kotlinx-coroutines-test/native/src/TestBuilders.kt new file mode 100644 index 0000000000..c3176a03de --- /dev/null +++ b/kotlinx-coroutines-test/native/src/TestBuilders.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test +import kotlinx.coroutines.* + +@Suppress("ACTUAL_WITHOUT_EXPECT") +public actual typealias TestResult = Unit + +internal actual fun createTestResult(testProcedure: suspend () -> Unit) { + runBlocking { + testProcedure() + } +} diff --git a/kotlinx-coroutines-test/native/src/TestMainDispatcher.kt b/kotlinx-coroutines-test/native/src/internal/TestMainDispatcher.kt similarity index 100% rename from kotlinx-coroutines-test/native/src/TestMainDispatcher.kt rename to kotlinx-coroutines-test/native/src/internal/TestMainDispatcher.kt diff --git a/kotlinx-coroutines-test/native/test/Helpers.kt b/kotlinx-coroutines-test/native/test/Helpers.kt index 28cc28ca00..ef478b7eb1 100644 --- a/kotlinx-coroutines-test/native/test/Helpers.kt +++ b/kotlinx-coroutines-test/native/test/Helpers.kt @@ -5,4 +5,10 @@ package kotlinx.coroutines.test import kotlin.test.* +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { + block { + test() + } +} + actual typealias NoNative = Ignore From 5c7d0341fda46d7352370f72114a14d1654c25da Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Mon, 1 Nov 2021 16:49:41 +0300 Subject: [PATCH 13/22] Implement new test dispatchers (#2986) Defines two test dispatchers: * StandardTestDispatcher, which, combined with runTest, gives an illusion of an event loop; * UnconfinedTestDispatcher, which is like Dispatchers.Unconfined, but skips delays. By default, StandardTestDispatcher is used due to the somewhat chaotic execution order of Dispatchers.Unconfined. TestCoroutineDispatcher is deprecated. Fixes #1626 Fixes #1742 Fixes #2082 Fixes #2102 Fixes #2405 Fixes #2462 --- .../api/kotlinx-coroutines-core.api | 9 + .../common/src/Unconfined.kt | 1 + .../api/kotlinx-coroutines-test.api | 10 +- .../common/src/DelayController.kt | 20 +- .../common/src/TestBuilders.kt | 9 +- .../common/src/TestCoroutineDispatcher.kt | 10 +- .../common/src/TestCoroutineDispatchers.kt | 129 +++++++++++++ .../common/src/TestCoroutineScope.kt | 54 +++++- .../common/src/TestDispatcher.kt | 7 +- .../common/test/Helpers.kt | 30 +++ .../common/test/RunTestTest.kt | 4 +- .../common/test/StandardTestDispatcherTest.kt | 57 ++++++ .../common/test/TestCoroutineSchedulerTest.kt | 182 ++++++++++++------ .../common/test/TestCoroutineScopeTest.kt | 26 +-- .../common/test/TestDispatchersTest.kt | 15 +- .../test/UnconfinedTestDispatcherTest.kt | 137 +++++++++++++ .../test/{ => migration}/TestBuildersTest.kt | 1 + .../TestCoroutineDispatcherOrderTest.kt | 16 +- .../TestCoroutineDispatcherTest.kt | 1 + .../TestRunBlockingOrderTest.kt | 19 +- .../{ => migration}/TestRunBlockingTest.kt | 1 + .../jvm/test/MultithreadingTest.kt | 12 ++ .../jvm/test/RunTestStressTest.kt | 26 +++ 23 files changed, 629 insertions(+), 147 deletions(-) create mode 100644 kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt create mode 100644 kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt create mode 100644 kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt rename kotlinx-coroutines-test/common/test/{ => migration}/TestBuildersTest.kt (99%) rename kotlinx-coroutines-test/common/test/{ => migration}/TestCoroutineDispatcherOrderTest.kt (70%) rename kotlinx-coroutines-test/common/test/{ => migration}/TestCoroutineDispatcherTest.kt (98%) rename kotlinx-coroutines-test/common/test/{ => migration}/TestRunBlockingOrderTest.kt (76%) rename kotlinx-coroutines-test/common/test/{ => migration}/TestRunBlockingTest.kt (99%) create mode 100644 kotlinx-coroutines-test/jvm/test/RunTestStressTest.kt diff --git a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api index 36a516e070..ee4d8bfc09 100644 --- a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api +++ b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.api @@ -556,6 +556,15 @@ public final class kotlinx/coroutines/TimeoutKt { public static final fun withTimeoutOrNull-KLykuaI (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class kotlinx/coroutines/YieldContext : kotlin/coroutines/AbstractCoroutineContextElement { + public static final field Key Lkotlinx/coroutines/YieldContext$Key; + public field dispatcherWasUnconfined Z + public fun ()V +} + +public final class kotlinx/coroutines/YieldContext$Key : kotlin/coroutines/CoroutineContext$Key { +} + public final class kotlinx/coroutines/YieldKt { public static final fun yield (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/kotlinx-coroutines-core/common/src/Unconfined.kt b/kotlinx-coroutines-core/common/src/Unconfined.kt index 24a401f702..5837ae83f3 100644 --- a/kotlinx-coroutines-core/common/src/Unconfined.kt +++ b/kotlinx-coroutines-core/common/src/Unconfined.kt @@ -38,6 +38,7 @@ internal object Unconfined : CoroutineDispatcher() { /** * Used to detect calls to [Unconfined.dispatch] from [yield] function. */ +@PublishedApi internal class YieldContext : AbstractCoroutineContextElement(Key) { companion object Key : CoroutineContext.Key diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index f60326b0eb..f024f2105f 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -40,6 +40,13 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor public fun toString ()Ljava/lang/String; } +public final class kotlinx/coroutines/test/TestCoroutineDispatchersKt { + public static final fun StandardTestDispatcher (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)Lkotlinx/coroutines/test/TestDispatcher; + public static synthetic fun StandardTestDispatcher$default (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestDispatcher; + public static final fun UnconfinedTestDispatcher (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)Lkotlinx/coroutines/test/TestDispatcher; + public static synthetic fun UnconfinedTestDispatcher$default (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestDispatcher; +} + public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor { public fun ()V public fun cleanupTestCoroutinesCaptor ()V @@ -69,6 +76,8 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt { public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope; public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V + public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope; + public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope; public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -77,7 +86,6 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt { } public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay { - public fun ()V public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle; diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/DelayController.kt index eba12efc27..8b34b8a267 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/DelayController.kt @@ -1,6 +1,7 @@ /* * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:Suppress("DEPRECATION") package kotlinx.coroutines.test @@ -102,7 +103,10 @@ public interface DelayController { * This is useful when testing functions that start a coroutine. By pausing the dispatcher assertions or * setup may be done between the time the coroutine is created and started. */ - @ExperimentalCoroutinesApi + @Deprecated( + "Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.", + level = DeprecationLevel.WARNING + ) public suspend fun pauseDispatcher(block: suspend () -> Unit) /** @@ -111,7 +115,10 @@ public interface DelayController { * When paused, the dispatcher will not execute any coroutines automatically, and you must call [runCurrent] or * [advanceTimeBy], or [advanceUntilIdle] to execute coroutines. */ - @ExperimentalCoroutinesApi + @Deprecated( + "Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.", + level = DeprecationLevel.WARNING + ) public fun pauseDispatcher() /** @@ -121,12 +128,15 @@ public interface DelayController { * time and execute coroutines scheduled in the future use, one of [advanceTimeBy], * or [advanceUntilIdle]. */ - @ExperimentalCoroutinesApi + @Deprecated( + "Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.", + level = DeprecationLevel.WARNING + ) public fun resumeDispatcher() } internal interface SchedulerAsDelayController : DelayController { - public val scheduler: TestCoroutineScheduler + val scheduler: TestCoroutineScheduler /** @suppress */ @Deprecated( @@ -178,7 +188,7 @@ internal interface SchedulerAsDelayController : DelayController { scheduler.runCurrent() if (!scheduler.isIdle()) { throw UncompletedCoroutinesError( - "Unfinished coroutines during teardown. Ensure all coroutines are" + + "Unfinished coroutines during tear-down. Ensure all coroutines are" + " completed or cancelled by your test." ) } diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 941f864a3e..0d5013cb88 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -44,7 +44,7 @@ import kotlin.coroutines.* */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { - val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) + val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) val scheduler = scope.testScheduler val deferred = scope.async { scope.testBody() @@ -197,10 +197,9 @@ public expect class TestResult * * [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine * scope created for the test, [context] also can be used to change how the test is executed. - * See the [TestCoroutineScope] constructor documentation for details. + * See the [createTestCoroutineScope] documentation for details. * - * @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for - * details. + * @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details. */ @ExperimentalCoroutinesApi public fun runTest( @@ -210,7 +209,7 @@ public fun runTest( ): TestResult { if (context[RunningInRunTest] != null) throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") - val testScope = TestBodyCoroutine(TestCoroutineScope(context + RunningInRunTest)) + val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest)) val scheduler = testScope.testScheduler return createTestResult { /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index 3537910ac5..31249ee6e4 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -21,7 +21,9 @@ import kotlin.coroutines.* * * @see DelayController */ -@ExperimentalCoroutinesApi +@Deprecated("The execution order of `TestCoroutineDispatcher` can be confusing, and the mechanism of " + + "pausing is typically misunderstood. Please use `StandardTestDispatcher` or `UnconfinedTestDispatcher` instead.", + level = DeprecationLevel.WARNING) public class TestCoroutineDispatcher(public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()): TestDispatcher(), Delay, SchedulerAsDelayController { @@ -34,12 +36,6 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin } } - /** @suppress */ - override fun processEvent(time: Long, marker: Any) { - check(marker is Runnable) - marker.run() - } - /** @suppress */ override fun dispatch(context: CoroutineContext, block: Runnable) { checkSchedulerInContext(scheduler, context) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt new file mode 100644 index 0000000000..6e18bf348e --- /dev/null +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2016-2021 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.channels.* +import kotlinx.coroutines.flow.* +import kotlin.coroutines.* + +/** + * Creates an instance of an unconfined [TestDispatcher]. + * + * This dispatcher is similar to [Dispatchers.Unconfined]: the tasks that it executes are not confined to any particular + * thread and form an event loop; it's different in that it skips delays, as all [TestDispatcher]s do. + * + * Using this [TestDispatcher] can greatly simplify writing tests where it's not important which thread is used when and + * in which order the queued coroutines are executed. + * The typical use case for this is launching child coroutines that are resumed immediately, without going through a + * dispatch; this can be helpful for testing [Channel] and [StateFlow] usages. + * + * ``` + * @Test + * fun testUnconfinedDispatcher() = runTest { + * val values = mutableListOf() + * val stateFlow = MutableStateFlow(0) + * val job = launch(UnconfinedTestDispatcher(testScheduler)) { + * stateFlow.collect { + * values.add(it) + * } + * } + * stateFlow.value = 1 + * stateFlow.value = 2 + * stateFlow.value = 3 + * job.cancel() + * // each assignment will immediately resume the collecting child coroutine, + * // so no values will be skipped. + * assertEquals(listOf(0, 1, 2, 3), values) + * } + * ``` + * + * However, please be aware that, like [Dispatchers.Unconfined], this is a specific dispatcher with execution order + * guarantees that are unusual and not shared by most other dispatchers, so it can only be used reliably for testing + * functionality, not the specific order of actions. + * See [Dispatchers.Unconfined] for a discussion of the execution order guarantees. + * + * In order to support delay skipping, this dispatcher is linked to a [TestCoroutineScheduler], which is used to control + * the virtual time and can be shared among many test dispatchers. If no [scheduler] is passed as an argument, a new one + * is created. + * + * Additionally, [name] can be set to distinguish each dispatcher instance when debugging. + * + * @see StandardTestDispatcher for a more predictable [TestDispatcher]. + */ +@ExperimentalCoroutinesApi +@Suppress("FunctionName") +public fun UnconfinedTestDispatcher( + scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), + name: String? = null +): TestDispatcher = UnconfinedTestDispatcherImpl(scheduler, name) + +private class UnconfinedTestDispatcherImpl( + override val scheduler: TestCoroutineScheduler, + private val name: String? = null +): TestDispatcher() { + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = false + + @Suppress("INVISIBLE_MEMBER") + override fun dispatch(context: CoroutineContext, block: Runnable) { + checkSchedulerInContext(scheduler, context) + scheduler.sendDispatchEvent() + + /** copy-pasted from [kotlinx.coroutines.Unconfined.dispatch] */ + /** It can only be called by the [yield] function. See also code of [yield] function. */ + val yieldContext = context[YieldContext] + if (yieldContext !== null) { + // report to "yield" that it is an unconfined dispatcher and don't call "block.run()" + yieldContext.dispatcherWasUnconfined = true + return + } + throw UnsupportedOperationException( + "Function UnconfinedTestCoroutineDispatcher.dispatch can only be used by " + + "the yield function. If you wrap Unconfined dispatcher in your code, make sure you properly delegate " + + "isDispatchNeeded and dispatch calls." + ) + } + + override fun toString(): String = "${name ?: "UnconfinedTestDispatcher"}[scheduler=$scheduler]" +} + +/** + * Creates an instance of a [TestDispatcher] whose tasks are run inside calls to the [scheduler]. + * + * This [TestDispatcher] instance does not itself execute any of the tasks. Instead, it always sends them to its + * [scheduler], which can then be accessed via [TestCoroutineScheduler.runCurrent], + * [TestCoroutineScheduler.advanceUntilIdle], or [TestCoroutineScheduler.advanceTimeBy], which will then execute these + * tasks in a blocking manner. + * + * In practice, this means that [launch] or [async] blocks will not be entered immediately (unless they are + * parameterized with [CoroutineStart.UNDISPATCHED]), and one should either call [TestCoroutineScheduler.runCurrent] to + * run these pending tasks, which will block until there are no more tasks scheduled at this point in time, or, when + * inside [runTest], call [yield] to yield the (only) thread used by [runTest] to the newly-launched coroutines. + * + * If a [scheduler] is not passed as an argument, a new one is created. + * + * One can additionally pass a [name] in order to more easily distinguish this dispatcher during debugging. + * + * @see UnconfinedTestDispatcher for a dispatcher that is not confined to any particular thread. + */ +@Suppress("FunctionName") +public fun StandardTestDispatcher( + scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), + name: String? = null +): TestDispatcher = StandardTestDispatcherImpl(scheduler, name) + +private class StandardTestDispatcherImpl( + override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), + private val name: String? = null +): TestDispatcher() { + + override fun dispatch(context: CoroutineContext, block: Runnable) { + checkSchedulerInContext(scheduler, context) + scheduler.registerEvent(this, 0, block) { false } + } + + override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]" +} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 17978df229..f60a97e088 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -44,8 +44,24 @@ private class TestCoroutineScopeImpl( private val initialJobs = coroutineContext.activeJobs() override fun cleanupTestCoroutines() { + val delayController = coroutineContext.delayController + val hasUnfinishedJobs = if (delayController != null) { + try { + delayController.cleanupTestCoroutines() + false + } catch (e: UncompletedCoroutinesError) { + true + } + } else { + testScheduler.runCurrent() + !testScheduler.isIdle() + } coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() - coroutineContext.delayController?.cleanupTestCoroutines() + if (hasUnfinishedJobs) + throw UncompletedCoroutinesError( + "Unfinished coroutines during teardown. Ensure all coroutines are" + + " completed or cancelled by your test." + ) val jobs = coroutineContext.activeJobs() if ((jobs - initialJobs).isNotEmpty()) throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") @@ -56,13 +72,29 @@ private fun CoroutineContext.activeJobs(): Set { return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() } +/** + * A coroutine scope for launching test coroutines using [TestCoroutineDispatcher]. + * + * [createTestCoroutineScope] is a similar function that defaults to [StandardTestDispatcher]. + */ +@Deprecated("This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " + + "Please use `createTestCoroutineScope` instead.", + ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + context)", + "kotlin.coroutines.EmptyCoroutineContext"), + level = DeprecationLevel.WARNING +) +public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { + val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() + return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + context) +} + /** * A coroutine scope for launching test coroutines. * * It ensures that all the test module machinery is properly initialized. * * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, * a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used. - * * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created. + * * If [context] doesn't have a [ContinuationInterceptor], a [StandardTestDispatcher] is created. * * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created * automatically. * * If [context] provides a [Job], that job is used for the new scope; otherwise, a [CompletableJob] is created. @@ -73,9 +105,8 @@ private fun CoroutineContext.activeJobs(): Set { * @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an * [UncaughtExceptionCaptor]. */ -@Suppress("FunctionName") @ExperimentalCoroutinesApi -public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { +public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { val scheduler: TestCoroutineScheduler val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) { is TestDispatcher -> { @@ -91,7 +122,7 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) } null -> { scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() - TestCoroutineDispatcher(scheduler) + StandardTestDispatcher(scheduler) } else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") } @@ -159,7 +190,7 @@ public fun TestCoroutineScope.advanceTimeBy(delayTimeMillis: Long): Unit = * @see TestCoroutineScheduler.advanceUntilIdle */ @ExperimentalCoroutinesApi -public fun TestCoroutineScope.advanceUntilIdle(): Unit { +public fun TestCoroutineScope.advanceUntilIdle() { coroutineContext.delayController?.advanceUntilIdle() ?: testScheduler.advanceUntilIdle() } @@ -170,13 +201,14 @@ public fun TestCoroutineScope.advanceUntilIdle(): Unit { * @see TestCoroutineScheduler.runCurrent */ @ExperimentalCoroutinesApi -public fun TestCoroutineScope.runCurrent(): Unit { +public fun TestCoroutineScope.runCurrent() { coroutineContext.delayController?.runCurrent() ?: testScheduler.runCurrent() } @ExperimentalCoroutinesApi @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", + "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + + "\"paused\", like `StandardTestDispatcher`.", ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher(block)", "kotlin.coroutines.ContinuationInterceptor"), DeprecationLevel.WARNING) @@ -186,7 +218,8 @@ public suspend fun TestCoroutineScope.pauseDispatcher(block: suspend () -> Unit) @ExperimentalCoroutinesApi @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", + "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + + "\"paused\", like `StandardTestDispatcher`.", ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher()", "kotlin.coroutines.ContinuationInterceptor"), level = DeprecationLevel.WARNING) @@ -196,7 +229,8 @@ public fun TestCoroutineScope.pauseDispatcher() { @ExperimentalCoroutinesApi @Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly.", + "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + + "\"paused\", like `StandardTestDispatcher`.", ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher()", "kotlin.coroutines.ContinuationInterceptor"), level = DeprecationLevel.WARNING) diff --git a/kotlinx-coroutines-test/common/src/TestDispatcher.kt b/kotlinx-coroutines-test/common/src/TestDispatcher.kt index b37f10bfda..c01e5b4d7b 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatcher.kt @@ -12,13 +12,16 @@ import kotlin.jvm.* * A test dispatcher that can interface with a [TestCoroutineScheduler]. */ @ExperimentalCoroutinesApi -public abstract class TestDispatcher: CoroutineDispatcher(), Delay { +public sealed class TestDispatcher: 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]. */ - internal abstract fun processEvent(time: Long, marker: Any) + internal fun processEvent(time: Long, marker: Any) { + check(marker is Runnable) + marker.run() + } /** @suppress */ override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt index 5c1796b148..cac2f6bc83 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -4,6 +4,7 @@ package kotlinx.coroutines.test +import kotlinx.atomicfu.* import kotlin.test.* import kotlin.time.* @@ -36,5 +37,34 @@ expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): T class TestException(message: String? = null): Exception(message) +/** + * A class inheriting from which allows to check the execution order inside tests. + * + * @see TestBase + */ +open class OrderedExecutionTestBase { + private val actionIndex = atomic(0) + private val finished = atomic(false) + + /** Expect the next action to be [index] in order. */ + protected fun expect(index: Int) { + val wasIndex = actionIndex.incrementAndGet() + check(index == wasIndex) { "Expecting action index $index but it is actually $wasIndex" } + } + + /** Expect this action to be final, with the given [index]. */ + protected fun finish(index: Int) { + expect(index) + check(!finished.getAndSet(true)) { "Should call 'finish(...)' at most once" } + } + + @AfterTest + fun ensureFinishCalls() { + assertTrue(finished.value || actionIndex.value == 0, "Expected `finish` to be called") + } +} + +internal fun T.void() { } + @OptionalExpectation expect annotation class NoNative() diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index fbca2b05ac..623b5bf758 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -57,7 +57,7 @@ class RunTestTest { delay(2000) } val deferred = async { - val job = launch(TestCoroutineDispatcher(testScheduler)) { + val job = launch(StandardTestDispatcher(testScheduler)) { launch { delay(500) } @@ -156,7 +156,7 @@ class RunTestTest { @Test fun reproducer2405() = runTest { - val dispatcher = TestCoroutineDispatcher(testScheduler) + val dispatcher = StandardTestDispatcher(testScheduler) var collectedError = false withContext(dispatcher) { flow { emit(1) } diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt new file mode 100644 index 0000000000..7e8a6ad158 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2016-2021 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.flow.* +import kotlin.test.* + +class StandardTestDispatcherTest: OrderedExecutionTestBase() { + + private val scope = createTestCoroutineScope(StandardTestDispatcher()) + + @AfterTest + fun cleanup() = scope.cleanupTestCoroutines() + + /** Tests that the [StandardTestDispatcher] follows an execution order similar to `runBlocking`. */ + @Test + fun testFlowsNotSkippingValues() = scope.launch { + // https://github.com/Kotlin/kotlinx.coroutines/issues/1626#issuecomment-554632852 + val list = flowOf(1).onStart { emit(0) } + .combine(flowOf("A")) { int, str -> "$str$int" } + .toList() + assertEquals(list, listOf("A0", "A1")) + }.void() + + /** Tests that each [launch] gets dispatched. */ + @Test + fun testLaunchDispatched() = scope.launch { + expect(1) + launch { + expect(3) + } + finish(2) + }.void() + + /** Tests that dispatching is done in a predictable order and [yield] puts this task at the end of the queue. */ + @Test + fun testYield() = scope.launch { + expect(1) + scope.launch { + expect(3) + yield() + expect(6) + } + scope.launch { + expect(4) + yield() + finish(7) + } + expect(2) + yield() + expect(5) + }.void() + +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index 45c02c09ad..5e5a91f6f7 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -13,7 +13,7 @@ class TestCoroutineSchedulerTest { @Test fun testContextElement() = runTest { assertFailsWith { - withContext(TestCoroutineDispatcher()) { + withContext(StandardTestDispatcher()) { } } } @@ -45,35 +45,42 @@ class TestCoroutineSchedulerTest { /** Tests that if [TestCoroutineScheduler.advanceTimeBy] encounters an arithmetic overflow, all the tasks scheduled * until the moment [Long.MAX_VALUE] get run. */ @Test - fun testAdvanceTimeByEnormousDelays() = runTest { - val initialDelay = 10L - delay(initialDelay) - assertEquals(initialDelay, currentTime) - var enteredInfinity = false - launch { - delay(Long.MAX_VALUE - 1) // delay(Long.MAX_VALUE) does nothing - assertEquals(Long.MAX_VALUE, currentTime) - enteredInfinity = true - } - var enteredNearInfinity = false - launch { - delay(Long.MAX_VALUE - initialDelay - 1) - assertEquals(Long.MAX_VALUE - 1, currentTime) - enteredNearInfinity = true + fun testAdvanceTimeByEnormousDelays() = forTestDispatchers { + assertRunsFast { + with (createTestCoroutineScope(it)) { + launch { + val initialDelay = 10L + delay(initialDelay) + assertEquals(initialDelay, currentTime) + var enteredInfinity = false + launch { + delay(Long.MAX_VALUE - 1) // delay(Long.MAX_VALUE) does nothing + assertEquals(Long.MAX_VALUE, currentTime) + enteredInfinity = true + } + var enteredNearInfinity = false + launch { + delay(Long.MAX_VALUE - initialDelay - 1) + assertEquals(Long.MAX_VALUE - 1, currentTime) + enteredNearInfinity = true + } + testScheduler.advanceTimeBy(Long.MAX_VALUE) + assertFalse(enteredInfinity) + assertTrue(enteredNearInfinity) + assertEquals(Long.MAX_VALUE, currentTime) + testScheduler.runCurrent() + assertTrue(enteredInfinity) + } + testScheduler.advanceUntilIdle() + } } - testScheduler.advanceTimeBy(Long.MAX_VALUE) - assertFalse(enteredInfinity) - assertTrue(enteredNearInfinity) - assertEquals(Long.MAX_VALUE, currentTime) - testScheduler.runCurrent() - assertTrue(enteredInfinity) } /** Tests the basic functionality of [TestCoroutineScheduler.advanceTimeBy]. */ @Test fun testAdvanceTimeBy() = assertRunsFast { val scheduler = TestCoroutineScheduler() - val scope = TestCoroutineScope(scheduler) + val scope = createTestCoroutineScope(scheduler) var stage = 1 scope.launch { delay(1_000) @@ -125,48 +132,52 @@ class TestCoroutineSchedulerTest { /** Tests that [TestCoroutineScheduler.runCurrent] will not run new tasks after the current time has advanced. */ @Test - fun testRunCurrentNotDrainingQueue() = assertRunsFast { - val scheduler = TestCoroutineScheduler() - val scope = TestCoroutineScope(scheduler) - var stage = 1 - scope.launch { - delay(SLOW) - launch { + fun testRunCurrentNotDrainingQueue() = forTestDispatchers { + assertRunsFast { + val scheduler = it.scheduler + val scope = createTestCoroutineScope(it) + var stage = 1 + scope.launch { delay(SLOW) - stage = 3 + launch { + delay(SLOW) + stage = 3 + } + scheduler.advanceTimeBy(SLOW) + stage = 2 } scheduler.advanceTimeBy(SLOW) - stage = 2 + assertEquals(1, stage) + scheduler.runCurrent() + assertEquals(2, stage) + scheduler.runCurrent() + assertEquals(3, stage) } - scheduler.advanceTimeBy(SLOW) - assertEquals(1, stage) - scheduler.runCurrent() - assertEquals(2, stage) - scheduler.runCurrent() - assertEquals(3, stage) } /** Tests that [TestCoroutineScheduler.advanceUntilIdle] doesn't hang when itself running in a scheduler task. */ @Test - fun testNestedAdvanceUntilIdle() = assertRunsFast { - val scheduler = TestCoroutineScheduler() - val scope = TestCoroutineScope(scheduler) - var executed = false - scope.launch { - launch { - delay(SLOW) - executed = true + fun testNestedAdvanceUntilIdle() = forTestDispatchers { + assertRunsFast { + val scheduler = it.scheduler + val scope = createTestCoroutineScope(it) + var executed = false + scope.launch { + launch { + delay(SLOW) + executed = true + } + scheduler.advanceUntilIdle() } scheduler.advanceUntilIdle() + assertTrue(executed) } - scheduler.advanceUntilIdle() - assertTrue(executed) } /** Tests [yield] scheduling tasks for future execution and not executing immediately. */ @Test - fun testYield() { - val scope = TestCoroutineScope() + fun testYield() = forTestDispatchers { + val scope = createTestCoroutineScope(it) var stage = 0 scope.launch { yield() @@ -183,6 +194,46 @@ class TestCoroutineSchedulerTest { scope.runCurrent() } + /** Tests that dispatching the delayed tasks is ordered by their waking times. */ + @Test + fun testDelaysPriority() = forTestDispatchers { + val scope = createTestCoroutineScope(it) + var lastMeasurement = 0L + fun checkTime(time: Long) { + assertTrue(lastMeasurement < time) + assertEquals(time, scope.currentTime) + lastMeasurement = scope.currentTime + } + scope.launch { + launch { + delay(100) + checkTime(100) + val deferred = async { + delay(70) + checkTime(170) + } + delay(1) + checkTime(101) + deferred.await() + delay(1) + checkTime(171) + } + launch { + delay(200) + checkTime(200) + } + launch { + delay(150) + checkTime(150) + delay(22) + checkTime(172) + } + delay(201) + } + scope.advanceUntilIdle() + checkTime(201) + } + private fun TestCoroutineScope.checkTimeout( timesOut: Boolean, timeoutMillis: Long = SLOW, block: suspend () -> Unit ) = assertRunsFast { @@ -206,8 +257,8 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts get triggered. */ @Test - fun testSmallTimeouts() { - val scope = TestCoroutineScope() + fun testSmallTimeouts() = forTestDispatchers { + val scope = createTestCoroutineScope(it) scope.checkTimeout(true) { val half = SLOW / 2 delay(half) @@ -217,8 +268,8 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts don't get triggered if the code finishes in time. */ @Test - fun testLargeTimeouts() { - val scope = TestCoroutineScope() + fun testLargeTimeouts() = forTestDispatchers { + val scope = createTestCoroutineScope(it) scope.checkTimeout(false) { val half = SLOW / 2 delay(half) @@ -228,8 +279,8 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts get triggered if the code fails to finish in time asynchronously. */ @Test - fun testSmallAsynchronousTimeouts() { - val scope = TestCoroutineScope() + fun testSmallAsynchronousTimeouts() = forTestDispatchers { + val scope = createTestCoroutineScope(it) val deferred = CompletableDeferred() scope.launch { val half = SLOW / 2 @@ -244,8 +295,8 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts don't get triggered if the code finishes in time, even if it does so asynchronously. */ @Test - fun testLargeAsynchronousTimeouts() { - val scope = TestCoroutineScope() + fun testLargeAsynchronousTimeouts() = forTestDispatchers { + val scope = createTestCoroutineScope(it) val deferred = CompletableDeferred() scope.launch { val half = SLOW / 2 @@ -257,4 +308,19 @@ class TestCoroutineSchedulerTest { deferred.await() } } + + private fun forTestDispatchers(block: (TestDispatcher) -> Unit): Unit = + @Suppress("DEPRECATION") + listOf( + TestCoroutineDispatcher(), + TestCoroutineDispatcher().also { it.pauseDispatcher() }, + StandardTestDispatcher(), + UnconfinedTestDispatcher() + ).forEach { + try { + block(it) + } catch (e: Throwable) { + throw RuntimeException("Test failed for dispatcher $it", e) + } + } } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index b81eddbb5b..9002d2d8c3 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -14,7 +14,7 @@ class TestCoroutineScopeTest { fun testCreateThrowsOnInvalidArguments() { for (ctx in invalidContexts) { assertFailsWith { - TestCoroutineScope(ctx) + createTestCoroutineScope(ctx) } } } @@ -24,27 +24,27 @@ class TestCoroutineScopeTest { fun testCreateProvidesScheduler() { // Creates a new scheduler. run { - val scope = TestCoroutineScope() + val scope = createTestCoroutineScope() assertNotNull(scope.coroutineContext[TestCoroutineScheduler]) } // Reuses the scheduler that the dispatcher is linked to. run { - val dispatcher = TestCoroutineDispatcher() - val scope = TestCoroutineScope(dispatcher) + val dispatcher = StandardTestDispatcher() + val scope = createTestCoroutineScope(dispatcher) assertSame(dispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) } // Uses the scheduler passed to it. run { val scheduler = TestCoroutineScheduler() - val scope = TestCoroutineScope(scheduler) + val scope = createTestCoroutineScope(scheduler) assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) assertSame(scheduler, (scope.coroutineContext[ContinuationInterceptor] as TestDispatcher).scheduler) } // Doesn't touch the passed dispatcher and the scheduler if they match. run { val scheduler = TestCoroutineScheduler() - val dispatcher = TestCoroutineDispatcher(scheduler) - val scope = TestCoroutineScope(scheduler + dispatcher) + val dispatcher = StandardTestDispatcher(scheduler) + val scope = createTestCoroutineScope(scheduler + dispatcher) assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) assertSame(dispatcher, scope.coroutineContext[ContinuationInterceptor]) } @@ -53,7 +53,7 @@ class TestCoroutineScopeTest { /** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */ @Test fun testPresentDelaysThrowing() { - val scope = TestCoroutineScope() + val scope = createTestCoroutineScope() var result = false scope.launch { delay(5) @@ -67,7 +67,7 @@ class TestCoroutineScopeTest { /** Tests that the cleanup procedure throws if there were active jobs by the end. */ @Test fun testActiveJobsThrowing() { - val scope = TestCoroutineScope() + val scope = createTestCoroutineScope() var result = false val deferred = CompletableDeferred() scope.launch { @@ -82,7 +82,7 @@ class TestCoroutineScopeTest { /** Tests that the cleanup procedure doesn't throw if it detects that the job is already cancelled. */ @Test fun testCancelledDelaysNotThrowing() { - val scope = TestCoroutineScope() + val scope = createTestCoroutineScope() var result = false val deferred = CompletableDeferred() val job = scope.launch { @@ -98,7 +98,7 @@ class TestCoroutineScopeTest { /** Tests that uncaught exceptions are thrown at the cleanup. */ @Test fun testThrowsUncaughtExceptionsOnCleanup() { - val scope = TestCoroutineScope() + val scope = createTestCoroutineScope() val exception = TestException("test") scope.launch { throw exception @@ -111,7 +111,7 @@ class TestCoroutineScopeTest { /** Tests that uncaught exceptions take priority over uncompleted jobs when throwing on cleanup. */ @Test fun testUncaughtExceptionsPrioritizedOnCleanup() { - val scope = TestCoroutineScope() + val scope = createTestCoroutineScope() val exception = TestException("test") scope.launch { throw exception @@ -127,7 +127,7 @@ class TestCoroutineScopeTest { companion object { internal val invalidContexts = listOf( Dispatchers.Default, // not a [TestDispatcher] - TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler + StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` ) } diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 441ea0418e..f998f3ddea 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -3,24 +3,11 @@ */ package kotlinx.coroutines.test -import kotlinx.atomicfu.* import kotlinx.coroutines.* import kotlin.coroutines.* import kotlin.test.* -class TestDispatchersTest { - private val actionIndex = atomic(0) - private val finished = atomic(false) - - private fun expect(index: Int) { - val wasIndex = actionIndex.incrementAndGet() - check(index == wasIndex) { "Expecting action index $index but it is actually $wasIndex" } - } - - private fun finish(index: Int) { - expect(index) - check(!finished.getAndSet(true)) { "Should call 'finish(...)' at most once" } - } +class TestDispatchersTest: OrderedExecutionTestBase() { @BeforeTest fun setUp() { diff --git a/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt new file mode 100644 index 0000000000..3e1c48c717 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2016-2021 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.channels.* +import kotlinx.coroutines.flow.* +import kotlin.test.* + +class UnconfinedTestDispatcherTest { + + @Test + fun reproducer1742() { + class ObservableValue(initial: T) { + var value: T = initial + private set + + private val listeners = mutableListOf<(T) -> Unit>() + + fun set(value: T) { + this.value = value + listeners.forEach { it(value) } + } + + fun addListener(listener: (T) -> Unit) { + listeners.add(listener) + } + + fun removeListener(listener: (T) -> Unit) { + listeners.remove(listener) + } + } + + fun ObservableValue.observe(): Flow = + callbackFlow { + val listener = { value: T -> + if (!isClosedForSend) { + trySend(value) + } + } + addListener(listener) + listener(value) + awaitClose { removeListener(listener) } + } + + val intProvider = ObservableValue(0) + val stringProvider = ObservableValue("") + var data = Pair(0, "") + val scope = CoroutineScope(UnconfinedTestDispatcher()) + scope.launch { + combine( + intProvider.observe(), + stringProvider.observe() + ) { intValue, stringValue -> Pair(intValue, stringValue) } + .collect { pair -> + data = pair + } + } + + intProvider.set(1) + stringProvider.set("3") + intProvider.set(2) + intProvider.set(3) + + scope.cancel() + assertEquals(Pair(3, "3"), data) + } + + @Test + fun reproducer2082() = runTest { + val subject1 = MutableStateFlow(1) + val subject2 = MutableStateFlow("a") + val values = mutableListOf>() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + combine(subject1, subject2) { intVal, strVal -> intVal to strVal } + .collect { + delay(10000) + values += it + } + } + + subject1.value = 2 + delay(10000) + subject2.value = "b" + delay(10000) + + subject1.value = 3 + delay(10000) + subject2.value = "c" + delay(10000) + delay(10000) + delay(1) + + job.cancel() + + assertEquals(listOf(Pair(1, "a"), Pair(2, "a"), Pair(2, "b"), Pair(3, "b"), Pair(3, "c")), values) + } + + @Test + fun reproducer2405() = createTestResult { + val dispatcher = UnconfinedTestDispatcher() + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + /** An example from the [UnconfinedTestDispatcher] documentation. */ + @Test + fun testUnconfinedDispatcher() = runTest { + val values = mutableListOf() + val stateFlow = MutableStateFlow(0) + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + stateFlow.collect { + values.add(it) + } + } + stateFlow.value = 1 + stateFlow.value = 2 + stateFlow.value = 3 + job.cancel() + assertEquals(listOf(0, 1, 2, 3), values) + } + +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt b/kotlinx-coroutines-test/common/test/migration/TestBuildersTest.kt similarity index 99% rename from kotlinx-coroutines-test/common/test/TestBuildersTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestBuildersTest.kt index 7fefaf78b5..6d49a01fa4 100644 --- a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestBuildersTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.* import kotlin.coroutines.* import kotlin.test.* +@Suppress("DEPRECATION") class TestBuildersTest { @Test diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherOrderTest.kt b/kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherOrderTest.kt similarity index 70% rename from kotlinx-coroutines-test/common/test/TestCoroutineDispatcherOrderTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherOrderTest.kt index e54ba21568..93fcd909cc 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherOrderTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherOrderTest.kt @@ -8,20 +8,8 @@ import kotlinx.atomicfu.* import kotlinx.coroutines.* import kotlin.test.* -class TestCoroutineDispatcherOrderTest { - - private val actionIndex = atomic(0) - private val finished = atomic(false) - - private fun expect(index: Int) { - val wasIndex = actionIndex.incrementAndGet() - check(index == wasIndex) { "Expecting action index $index but it is actually $wasIndex" } - } - - private fun finish(index: Int) { - expect(index) - check(!finished.getAndSet(true)) { "Should call 'finish(...)' at most once" } - } +@Suppress("DEPRECATION") +class TestCoroutineDispatcherOrderTest: OrderedExecutionTestBase() { @Test fun testAdvanceTimeBy_progressesOnEachDelay() { diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt b/kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherTest.kt similarity index 98% rename from kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherTest.kt index f14b72632c..a78d923d34 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherTest.kt @@ -7,6 +7,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlin.test.* +@Suppress("DEPRECATION") class TestCoroutineDispatcherTest { @Test fun whenDispatcherPaused_doesNotAutoProgressCurrent() { diff --git a/kotlinx-coroutines-test/common/test/TestRunBlockingOrderTest.kt b/kotlinx-coroutines-test/common/test/migration/TestRunBlockingOrderTest.kt similarity index 76% rename from kotlinx-coroutines-test/common/test/TestRunBlockingOrderTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestRunBlockingOrderTest.kt index 5d94bd2866..32514d90e8 100644 --- a/kotlinx-coroutines-test/common/test/TestRunBlockingOrderTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestRunBlockingOrderTest.kt @@ -4,24 +4,11 @@ package kotlinx.coroutines.test -import kotlinx.atomicfu.* import kotlinx.coroutines.* import kotlin.test.* -class TestRunBlockingOrderTest { - - private val actionIndex = atomic(0) - private val finished = atomic(false) - - private fun expect(index: Int) { - val wasIndex = actionIndex.incrementAndGet() - check(index == wasIndex) { "Expecting action index $index but it is actually $wasIndex" } - } - - private fun finish(index: Int) { - expect(index) - check(!finished.getAndSet(true)) { "Should call 'finish(...)' at most once" } - } +@Suppress("DEPRECATION") +class TestRunBlockingOrderTest: OrderedExecutionTestBase() { @Test fun testLaunchImmediate() = runBlockingTest { @@ -90,4 +77,4 @@ class TestRunBlockingOrderTest { } finish(2) } -} +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt b/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt similarity index 99% rename from kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt index 139229e610..66c06cf49f 100644 --- a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt @@ -7,6 +7,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlin.test.* +@Suppress("DEPRECATION") class TestRunBlockingTest { @Test diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index 6b0c071a56..da70bf5444 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -3,6 +3,7 @@ */ import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.* import kotlin.concurrent.* import kotlin.coroutines.* @@ -98,4 +99,15 @@ class MultithreadingTest { } } } + + /** Tests that [StandardTestDispatcher] is confined to the thread that interacts with the scheduler. */ + @Test + fun testStandardTestDispatcherIsConfined() = runTest { + val initialThread = Thread.currentThread() + withContext(Dispatchers.IO) { + val ioThread = Thread.currentThread() + assertNotSame(initialThread, ioThread) + } + assertEquals(initialThread, Thread.currentThread()) + } } diff --git a/kotlinx-coroutines-test/jvm/test/RunTestStressTest.kt b/kotlinx-coroutines-test/jvm/test/RunTestStressTest.kt new file mode 100644 index 0000000000..3edaa48fbd --- /dev/null +++ b/kotlinx-coroutines-test/jvm/test/RunTestStressTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2016-2021 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 kotlin.concurrent.* +import kotlin.coroutines.* +import kotlin.test.* + +class RunTestStressTest { + /** Tests that notifications about asynchronous resumptions aren't lost. */ + @Test + fun testRunTestActivityNotificationsRace() { + val n = 1_000 * stressTestMultiplier + for (i in 0 until n) { + runTest { + suspendCancellableCoroutine { cont -> + thread { + cont.resume(Unit) + } + } + } + } + } +} \ No newline at end of file From ca6bf0336061322a14452e5e3422884f2d6f94eb Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Mon, 1 Nov 2021 16:57:04 +0300 Subject: [PATCH 14/22] Update exception handling in the test module (#2953) --- .../api/kotlinx-coroutines-test.api | 7 +- .../common/src/TestBuilders.kt | 32 ++- .../src/TestCoroutineExceptionHandler.kt | 35 +++- .../common/src/TestCoroutineScope.kt | 191 +++++++++++++----- .../common/test/Helpers.kt | 3 +- .../common/test/RunTestTest.kt | 42 ++++ .../common/test/TestCoroutineScopeTest.kt | 57 +++++- .../TestCoroutineExceptionHandlerTest.kt | 3 +- .../test/migration/TestRunBlockingTest.kt | 3 +- .../jvm/test/MultithreadingTest.kt | 1 - 10 files changed, 296 insertions(+), 78 deletions(-) rename kotlinx-coroutines-test/common/test/{ => migration}/TestCoroutineExceptionHandlerTest.kt (83%) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index f024f2105f..f3a69b9f1c 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -49,7 +49,7 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatchersKt { public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor { public fun ()V - public fun cleanupTestCoroutinesCaptor ()V + public fun cleanupTestCoroutines ()V public fun getUncaughtExceptions ()Ljava/util/List; public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V } @@ -66,7 +66,7 @@ public final class kotlinx/coroutines/test/TestCoroutineScheduler : kotlin/corou public final class kotlinx/coroutines/test/TestCoroutineScheduler$Key : kotlin/coroutines/CoroutineContext$Key { } -public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope, kotlinx/coroutines/test/UncaughtExceptionCaptor { +public abstract interface class kotlinx/coroutines/test/TestCoroutineScope : kotlinx/coroutines/CoroutineScope { public abstract fun cleanupTestCoroutines ()V public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; } @@ -79,6 +79,7 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt { public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope; public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope; public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J + public static final fun getUncaughtExceptions (Lkotlinx/coroutines/test/TestCoroutineScope;)Ljava/util/List; public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun resumeDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V @@ -98,7 +99,7 @@ public final class kotlinx/coroutines/test/TestDispatchers { } public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor { - public abstract fun cleanupTestCoroutinesCaptor ()V + public abstract fun cleanupTestCoroutines ()V public abstract fun getUncaughtExceptions ()Ljava/util/List; } diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 0d5013cb88..de06102c5a 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -43,7 +43,10 @@ import kotlin.coroutines.* * @param testBody The code of the unit-test. */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) -public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { +public fun runBlockingTest( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestCoroutineScope.() -> Unit +) { val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) val scheduler = scope.testScheduler val deferred = scope.async { @@ -235,7 +238,7 @@ public fun runTest( } onTimeout(dispatchTimeoutMs) { try { - testScope.cleanupTestCoroutines() + testScope.cleanup() } catch (e: UncompletedCoroutinesError) { // we expect these and will instead throw a more informative exception just below. } @@ -245,7 +248,7 @@ public fun runTest( } testScope.getCompletionExceptionOrNull()?.let { try { - testScope.cleanupTestCoroutines() + testScope.cleanup() } catch (e: UncompletedCoroutinesError) { // it's normal that some jobs are not completed if the test body has failed, won't clutter the output } catch (e: Throwable) { @@ -253,7 +256,7 @@ public fun runTest( } throw it } - testScope.cleanupTestCoroutines() + testScope.cleanup() } } @@ -266,7 +269,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes /** * Runs a test in a [TestCoroutineScope] based on this one. * - * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run + * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the * [block] will be different from this one, but will use its [Job] as a parent. * * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned @@ -295,7 +298,7 @@ public fun TestDispatcher.runTest( runTest(this, dispatchTimeoutMs, block) /** A coroutine context element indicating that the coroutine is running inside `runTest`. */ -private object RunningInRunTest: CoroutineContext.Key, CoroutineContext.Element { +private object RunningInRunTest : CoroutineContext.Key, CoroutineContext.Element { override val key: CoroutineContext.Key<*> get() = this @@ -308,11 +311,20 @@ private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L private class TestBodyCoroutine( private val testScope: TestCoroutineScope, -) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope, - UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor -{ +) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope { + override val testScheduler get() = testScope.testScheduler - override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines() + @Deprecated( + "This deprecation is to prevent accidentally calling `cleanupTestCoroutines` in our own code.", + ReplaceWith("this.cleanup()"), + DeprecationLevel.ERROR + ) + override fun cleanupTestCoroutines() = + throw UnsupportedOperationException( + "Calling `cleanupTestCoroutines` inside `runTest` is prohibited: " + + "it will be called at the end of the test in any case." + ) + fun cleanup() = testScope.cleanupTestCoroutines() } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt index 9f49292dab..b85f21ee69 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt @@ -11,13 +11,19 @@ import kotlin.coroutines.* /** * Access uncaught coroutine exceptions captured during test execution. */ -@ExperimentalCoroutinesApi +@Deprecated( + "Deprecated for removal without a replacement. " + + "Consider whether the default mechanism of handling uncaught exceptions is sufficient. " + + "If not, try writing your own `CoroutineExceptionHandler` and " + + "please report your use case at https://github.com/Kotlin/kotlinx.coroutines/issues.", + level = DeprecationLevel.WARNING +) public interface UncaughtExceptionCaptor { /** * List of uncaught coroutine exceptions. * * The returned list is a copy of the currently caught exceptions. - * During [cleanupTestCoroutinesCaptor] the first element of this list is rethrown if it is not empty. + * During [cleanupTestCoroutines] the first element of this list is rethrown if it is not empty. */ public val uncaughtExceptions: List @@ -29,33 +35,40 @@ public interface UncaughtExceptionCaptor { * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions. */ - public fun cleanupTestCoroutinesCaptor() + public fun cleanupTestCoroutines() } /** * An exception handler that captures uncaught exceptions in tests. */ -@ExperimentalCoroutinesApi +@Deprecated( + "Deprecated for removal without a replacement. " + + "It may be to define one's own `CoroutineExceptionHandler` if you just need to handle '" + + "uncaught exceptions without a special `TestCoroutineScope` integration.", level = DeprecationLevel.WARNING +) public class TestCoroutineExceptionHandler : - AbstractCoroutineContextElement(CoroutineExceptionHandler), UncaughtExceptionCaptor, CoroutineExceptionHandler -{ + AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler, UncaughtExceptionCaptor { private val _exceptions = mutableListOf() private val _lock = SynchronizedObject() + private var _coroutinesCleanedUp = false - /** @suppress **/ + @Suppress("INVISIBLE_MEMBER") override fun handleException(context: CoroutineContext, exception: Throwable) { synchronized(_lock) { + if (_coroutinesCleanedUp) { + handleCoroutineExceptionImpl(context, exception) + return + } _exceptions += exception } } - /** @suppress **/ - override val uncaughtExceptions: List + public override val uncaughtExceptions: List get() = synchronized(_lock) { _exceptions.toList() } - /** @suppress **/ - override fun cleanupTestCoroutinesCaptor() { + public override fun cleanupTestCoroutines() { synchronized(_lock) { + _coroutinesCleanedUp = true val exception = _exceptions.firstOrNull() ?: return // log the rest _exceptions.drop(1).forEach { it.printStackTrace() } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index f60a97e088..e4de60f227 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -5,21 +5,32 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlinx.coroutines.internal.* import kotlin.coroutines.* /** * A scope which provides detailed control over the execution of coroutines for tests. */ @ExperimentalCoroutinesApi -public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { +public sealed interface TestCoroutineScope : CoroutineScope { /** * Called after the test completes. * - * Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines]. - * If a new job was created for this scope, the job is completed. + * * It checks that there were no uncaught exceptions caught by its [CoroutineExceptionHandler]. + * If there were any, then the first one is thrown, whereas the rest are suppressed by it. + * * It runs the tasks pending in the scheduler at the current time. If there are any uncompleted tasks afterwards, + * it fails with [UncompletedCoroutinesError]. + * * It checks whether some new child [Job]s were created but not completed since this [TestCoroutineScope] was + * created. If so, it fails with [UncompletedCoroutinesError]. + * + * For backward compatibility, if the [CoroutineExceptionHandler] is an [UncaughtExceptionCaptor], its + * [TestCoroutineExceptionHandler.cleanupTestCoroutines] behavior is performed. + * Likewise, if the [ContinuationInterceptor] is a [DelayController], its [DelayController.cleanupTestCoroutines] + * is called. * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions. * @throws AssertionError if any pending tasks are active. + * @throws IllegalStateException if called more than once. */ @ExperimentalCoroutinesApi public fun cleanupTestCoroutines() @@ -33,10 +44,29 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap private class TestCoroutineScopeImpl( override val coroutineContext: CoroutineContext -): - TestCoroutineScope, - UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor -{ +) : TestCoroutineScope { + private val lock = SynchronizedObject() + private var exceptions = mutableListOf() + private var cleanedUp = false + + /** + * Reports an exception so that it is thrown on [cleanupTestCoroutines]. + * + * If several exceptions are reported, only the first one will be thrown, and the other ones will be suppressed by + * it. + * + * Returns `false` if [cleanupTestCoroutines] was already called. + */ + fun reportException(throwable: Throwable): Boolean = + synchronized(lock) { + if (cleanedUp) { + false + } else { + exceptions.add(throwable) + true + } + } + override val testScheduler: TestCoroutineScheduler get() = coroutineContext[TestCoroutineScheduler]!! @@ -56,7 +86,16 @@ private class TestCoroutineScopeImpl( testScheduler.runCurrent() !testScheduler.isIdle() } - coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() + (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.cleanupTestCoroutines() + synchronized(lock) { + if (cleanedUp) + throw IllegalStateException("Attempting to clean up a test coroutine scope more than once.") + cleanedUp = true + } + exceptions.firstOrNull()?.let { toThrow -> + exceptions.drop(1).forEach { toThrow.addSuppressed(it) } + throw toThrow + } if (hasUnfinishedJobs) throw UncompletedCoroutinesError( "Unfinished coroutines during teardown. Ensure all coroutines are" + @@ -77,15 +116,18 @@ private fun CoroutineContext.activeJobs(): Set { * * [createTestCoroutineScope] is a similar function that defaults to [StandardTestDispatcher]. */ -@Deprecated("This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " + - "Please use `createTestCoroutineScope` instead.", - ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + context)", - "kotlin.coroutines.EmptyCoroutineContext"), +@Deprecated( + "This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " + + "Please use `createTestCoroutineScope` instead.", + ReplaceWith( + "createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + context)", + "kotlin.coroutines.EmptyCoroutineContext" + ), level = DeprecationLevel.WARNING ) public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() - return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + context) + return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + TestCoroutineExceptionHandler() + context) } /** @@ -95,8 +137,13 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) * * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, * a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used. * * If [context] doesn't have a [ContinuationInterceptor], a [StandardTestDispatcher] is created. - * * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created - * automatically. + * * A [CoroutineExceptionHandler] is created that makes [TestCoroutineScope.cleanupTestCoroutines] throw if there were + * any uncaught exceptions, or forwards the exceptions further in a platform-specific manner if the cleanup was + * already performed when an exception happened. Passing a [CoroutineExceptionHandler] is illegal, unless it's an + * [UncaughtExceptionCaptor], in which case the behavior is preserved for the time being for backward compatibility. + * If you need to have a specific [CoroutineExceptionHandler], please pass it to [launch] on an already-created + * [TestCoroutineScope] and share your use case at + * [our issue tracker](https://github.com/Kotlin/kotlinx.coroutines/issues). * * If [context] provides a [Job], that job is used for the new scope; otherwise, a [CompletableJob] is created. * * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a @@ -126,26 +173,39 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo } else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") } - val exceptionHandler = context[CoroutineExceptionHandler].run { - this?.let { - require(this is UncaughtExceptionCaptor) { - "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context" + var scope: TestCoroutineScopeImpl? = null + val ownExceptionHandler = run { + val lock = SynchronizedObject() + object : AbstractCoroutineContextElement(CoroutineExceptionHandler), TestCoroutineScopeExceptionHandler { + override fun handleException(context: CoroutineContext, exception: Throwable) { + val reported = synchronized(lock) { + scope!!.reportException(exception) + } + if (!reported) + throw exception // let this exception crash everything } } - this ?: TestCoroutineExceptionHandler() + } + val exceptionHandler = when (val exceptionHandler = context[CoroutineExceptionHandler]) { + is UncaughtExceptionCaptor -> exceptionHandler + null -> ownExceptionHandler + is TestCoroutineScopeExceptionHandler -> ownExceptionHandler + else -> throw IllegalArgumentException( + "A CoroutineExceptionHandler was passed to TestCoroutineScope. " + + "Please pass it as an argument to a `launch` or `async` block on an already-created scope " + + "if uncaught exceptions require special treatment." + ) } val job: Job = context[Job] ?: Job() - return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job) + return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job).also { + scope = it + } } -internal inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor - get() { - val handler = this[CoroutineExceptionHandler] - return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException( - "TestCoroutineScope requires a UncaughtExceptionCaptor such as " + - "TestCoroutineExceptionHandler as the CoroutineExceptionHandler" - ) - } +/** A marker that shows that this [CoroutineExceptionHandler] was created for [TestCoroutineScope]. With this, + * constructing a new [TestCoroutineScope] with the [CoroutineScope.coroutineContext] of an existing one will override + * the exception handler, instead of failing. */ +private interface TestCoroutineScopeExceptionHandler: CoroutineExceptionHandler private inline val CoroutineContext.delayController: DelayController? get() { @@ -169,10 +229,12 @@ public val TestCoroutineScope.currentTime: Long * @see TestCoroutineScheduler.advanceTimeBy */ @ExperimentalCoroutinesApi -@Deprecated("The name of this function is misleading: it not only advances the time, but also runs the tasks " + - "scheduled *at* the ending moment.", +@Deprecated( + "The name of this function is misleading: it not only advances the time, but also runs the tasks " + + "scheduled *at* the ending moment.", ReplaceWith("this.testScheduler.apply { advanceTimeBy(delayTimeMillis); runCurrent() }"), - DeprecationLevel.WARNING) + DeprecationLevel.WARNING +) public fun TestCoroutineScope.advanceTimeBy(delayTimeMillis: Long): Unit = when (val controller = coroutineContext.delayController) { null -> { @@ -206,38 +268,69 @@ public fun TestCoroutineScope.runCurrent() { } @ExperimentalCoroutinesApi -@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + - "\"paused\", like `StandardTestDispatcher`.", - ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher(block)", - "kotlin.coroutines.ContinuationInterceptor"), - DeprecationLevel.WARNING) +@Deprecated( + "The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + + "\"paused\", like `StandardTestDispatcher`.", + ReplaceWith( + "(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher(block)", + "kotlin.coroutines.ContinuationInterceptor" + ), + DeprecationLevel.WARNING +) public suspend fun TestCoroutineScope.pauseDispatcher(block: suspend () -> Unit) { delayControllerForPausing.pauseDispatcher(block) } @ExperimentalCoroutinesApi -@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + +@Deprecated( + "The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + "\"paused\", like `StandardTestDispatcher`.", - ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher()", - "kotlin.coroutines.ContinuationInterceptor"), -level = DeprecationLevel.WARNING) + ReplaceWith( + "(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher()", + "kotlin.coroutines.ContinuationInterceptor" + ), + level = DeprecationLevel.WARNING +) public fun TestCoroutineScope.pauseDispatcher() { delayControllerForPausing.pauseDispatcher() } @ExperimentalCoroutinesApi -@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " + - "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + +@Deprecated( + "The test coroutine scope isn't able to pause its dispatchers in the general case. " + + "Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " + "\"paused\", like `StandardTestDispatcher`.", - ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher()", - "kotlin.coroutines.ContinuationInterceptor"), - level = DeprecationLevel.WARNING) + ReplaceWith( + "(this.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher()", + "kotlin.coroutines.ContinuationInterceptor" + ), + level = DeprecationLevel.WARNING +) public fun TestCoroutineScope.resumeDispatcher() { delayControllerForPausing.resumeDispatcher() } +/** + * List of uncaught coroutine exceptions, for backward compatibility. + * + * The returned list is a copy of the exceptions caught during execution. + * During [TestCoroutineScope.cleanupTestCoroutines] the first element of this list is rethrown if it is not empty. + * + * Exceptions are only collected in this list if the [UncaughtExceptionCaptor] is in the test context. + */ +@Deprecated( + "This list is only populated if `UncaughtExceptionCaptor` is in the test context, and so can be " + + "easily misused. It is only present for backward compatibility and will be removed in the subsequent " + + "releases. If you need to check the list of exceptions, please consider creating your own " + + "`CoroutineExceptionHandler`.", + level = DeprecationLevel.WARNING +) +public val TestCoroutineScope.uncaughtExceptions: List + get() = (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.uncaughtExceptions + ?: emptyList() + private val TestCoroutineScope.delayControllerForPausing: DelayController get() = coroutineContext.delayController ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") @@ -246,4 +339,4 @@ private val TestCoroutineScope.delayControllerForPausing: DelayController * Thrown when a test has completed and there are tasks that are not completed or cancelled. */ @ExperimentalCoroutinesApi -internal class UncompletedCoroutinesError(message: String): AssertionError(message) +internal class UncompletedCoroutinesError(message: String) : AssertionError(message) diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt index cac2f6bc83..0132bafa0d 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -7,6 +7,7 @@ package kotlinx.coroutines.test import kotlinx.atomicfu.* import kotlin.test.* import kotlin.time.* +import kotlin.time.Duration.Companion.seconds /** * The number of milliseconds that is sure not to pass [assertRunsFast]. @@ -28,7 +29,7 @@ inline fun assertRunsFast(timeout: Duration, block: () -> T): T { * Asserts that a block completed within two seconds. */ @OptIn(ExperimentalTime::class) -inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.seconds(2), block) +inline fun assertRunsFast(block: () -> T): T = assertRunsFast(2.seconds, block) /** * Passes [test] as an argument to [block], but as a function returning not a [TestResult] but [Unit]. diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 623b5bf758..a679a7cf6e 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -251,4 +251,46 @@ class RunTestTest { } }) } + + /** Tests that, when the test body fails, the reported exceptions are suppressed. */ + @Test + fun testSuppressedExceptions() = testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + assertEquals("w", e.message) + val suppressed = e.suppressedExceptions + + (e.suppressedExceptions.firstOrNull()?.suppressedExceptions ?: emptyList()) + assertEquals(3, suppressed.size) + assertEquals("x", suppressed[0].message) + assertEquals("y", suppressed[1].message) + assertEquals("z", suppressed[2].message) + } + }, { + runTest { + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + throw TestException("w") + } + }) + + /** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */ + @Test + fun testScopeRunTestExceptionHandler(): TestResult { + val scope = createTestCoroutineScope() + return testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + scope.cleanupTestCoroutines() // should not fail + } + }, { + scope.runTest { + launch(SupervisorJob()) { throw TestException("x") } + } + }) + } } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index 9002d2d8c3..bdea37de49 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -124,11 +124,66 @@ class TestCoroutineScopeTest { } } + /** Tests that cleaning up twice is forbidden. */ + @Test + fun testClosingTwice() { + val scope = createTestCoroutineScope() + scope.cleanupTestCoroutines() + assertFailsWith { + scope.cleanupTestCoroutines() + } + } + + /** Tests that, when reporting several exceptions, the first one is thrown, with the rest suppressed. */ + @Test + fun testSuppressedExceptions() { + createTestCoroutineScope().apply { + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + try { + cleanupTestCoroutines() + fail("should not be reached") + } catch (e: TestException) { + assertEquals("x", e.message) + assertEquals(2, e.suppressedExceptions.size) + assertEquals("y", e.suppressedExceptions[0].message) + assertEquals("z", e.suppressedExceptions[1].message) + } + } + } + + /** Tests that constructing a new [TestCoroutineScope] using another one's scope works and overrides the exception + * handler. */ + @Test + fun testCopyingContexts() { + val deferred = CompletableDeferred() + val scope1 = createTestCoroutineScope() + scope1.launch { deferred.await() } // a pending job in the outer scope + val scope2 = createTestCoroutineScope(scope1.coroutineContext) + val scope3 = createTestCoroutineScope(scope1.coroutineContext) + assertEquals( + scope1.coroutineContext.minusKey(CoroutineExceptionHandler), + scope2.coroutineContext.minusKey(CoroutineExceptionHandler)) + scope2.launch(SupervisorJob()) { throw TestException("x") } // will fail the cleanup of scope2 + try { + scope2.cleanupTestCoroutines() + fail("should not be reached") + } catch (e: TestException) { } + scope3.cleanupTestCoroutines() // the pending job in the outer scope will not cause this to fail + try { + scope1.cleanupTestCoroutines() + fail("should not be reached") + } catch (e: UncompletedCoroutinesError) { + // the pending job in the outer scope + } + } + companion object { internal val invalidContexts = listOf( Dispatchers.Default, // not a [TestDispatcher] + CoroutineExceptionHandler { _, _ -> }, // not an [UncaughtExceptionCaptor] StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler - CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` ) } } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineExceptionHandlerTest.kt b/kotlinx-coroutines-test/common/test/migration/TestCoroutineExceptionHandlerTest.kt similarity index 83% rename from kotlinx-coroutines-test/common/test/TestCoroutineExceptionHandlerTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestCoroutineExceptionHandlerTest.kt index 674fd288dd..20da130725 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineExceptionHandlerTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestCoroutineExceptionHandlerTest.kt @@ -1,11 +1,12 @@ /* - * Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ package kotlinx.coroutines.test import kotlin.test.* +@Suppress("DEPRECATION") class TestCoroutineExceptionHandlerTest { @Test fun whenExceptionsCaught_availableViaProperty() { diff --git a/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt b/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt index 66c06cf49f..af3b24892a 100644 --- a/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt @@ -235,12 +235,13 @@ class TestRunBlockingTest { fun callingAsyncFunction_executesAsyncBlockImmediately() = runBlockingTest { assertRunsFast { var executed = false - async { + val deferred = async { delay(SLOW) executed = true } advanceTimeBy(SLOW) + assertTrue(deferred.isCompleted) assertTrue(executed) } } diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index da70bf5444..90a16d0622 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -3,7 +3,6 @@ */ import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.* import kotlin.concurrent.* import kotlin.coroutines.* From 665cc4333d36b997a44958814ae7377e73ff0e09 Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Mon, 1 Nov 2021 16:59:30 +0300 Subject: [PATCH 15/22] Use the TestCoroutineDispatcher from Dispatchers.Main by default (#3006) --- .../common/src/TestBuilders.kt | 10 ++++--- .../common/src/TestCoroutineScope.kt | 11 ++++++-- .../common/src/TestDispatchers.kt | 2 +- .../common/src/internal/TestMainDispatcher.kt | 6 +---- .../common/test/TestCoroutineScopeTest.kt | 27 +++++++++++++++++++ 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index de06102c5a..db15bbfdbf 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -149,10 +149,12 @@ public expect class TestResult * * ### Task scheduling * - * Delay-skipping is achieved by using virtual time. [TestCoroutineScheduler] is automatically created (if it wasn't - * passed in some way in [context]) and can be used to control the virtual time, advancing it, running the tasks - * scheduled at a specific time etc. Some convenience methods are available on [TestCoroutineScope] to control the - * scheduler. + * Delay-skipping is achieved by using virtual time. + * If [Dispatchers.Main] is set to a [TestDispatcher] via [Dispatchers.setMain] before the test, + * then its [TestCoroutineScheduler] is used; + * otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control + * the virtual time, advancing it, running the tasks scheduled at a specific time etc. + * Some convenience methods are available on [TestCoroutineScope] to control the scheduler. * * Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped: * ``` diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index e4de60f227..f7b38dcc01 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -6,6 +6,8 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.internal.* +import kotlinx.coroutines.test.internal.* +import kotlinx.coroutines.test.internal.TestMainDispatcher import kotlin.coroutines.* /** @@ -135,7 +137,10 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) * * It ensures that all the test module machinery is properly initialized. * * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, - * a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used. + * a new one is created, unless either + * - a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used; + * - at the moment of the creation of the scope, [Dispatchers.Main] is delegated to a [TestDispatcher], in which case + * its [TestCoroutineScheduler] is used. * * If [context] doesn't have a [ContinuationInterceptor], a [StandardTestDispatcher] is created. * * A [CoroutineExceptionHandler] is created that makes [TestCoroutineScope.cleanupTestCoroutines] throw if there were * any uncaught exceptions, or forwards the exceptions further in a platform-specific manner if the cleanup was @@ -168,7 +173,9 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo dispatcher } null -> { - scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() + val mainDispatcherScheduler = + ((Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher)?.scheduler + scheduler = context[TestCoroutineScheduler] ?: mainDispatcherScheduler ?: TestCoroutineScheduler() StandardTestDispatcher(scheduler) } else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") diff --git a/kotlinx-coroutines-test/common/src/TestDispatchers.kt b/kotlinx-coroutines-test/common/src/TestDispatchers.kt index 508608e173..8e70050ebb 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatchers.kt @@ -18,7 +18,7 @@ import kotlin.jvm.* @ExperimentalCoroutinesApi public fun Dispatchers.setMain(dispatcher: CoroutineDispatcher) { require(dispatcher !is TestMainDispatcher) { "Dispatchers.setMain(Dispatchers.Main) is prohibited, probably Dispatchers.resetMain() should be used instead" } - getTestMainDispatcher().setDispatcher(dispatcher) + getTestMainDispatcher().delegate = dispatcher } /** diff --git a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt index f2e5b7a168..f9225584e3 100644 --- a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt @@ -10,7 +10,7 @@ import kotlin.coroutines.* * The testable main dispatcher used by kotlinx-coroutines-test. * It is a [MainCoroutineDispatcher] that delegates all actions to a settable delegate. */ -internal class TestMainDispatcher(private var delegate: CoroutineDispatcher): +internal class TestMainDispatcher(var delegate: CoroutineDispatcher): MainCoroutineDispatcher(), Delay by (delegate as? Delay ?: defaultDelay) { @@ -25,10 +25,6 @@ internal class TestMainDispatcher(private var delegate: CoroutineDispatcher): override fun dispatchYield(context: CoroutineContext, block: Runnable) = delegate.dispatchYield(context, block) - fun setDispatcher(dispatcher: CoroutineDispatcher) { - delegate = dispatcher - } - fun resetDispatcher() { delegate = mainDispatcher } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index bdea37de49..35d92904c8 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -48,6 +48,33 @@ class TestCoroutineScopeTest { assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) assertSame(dispatcher, scope.coroutineContext[ContinuationInterceptor]) } + // Reuses the scheduler of `Dispatchers.Main` + run { + val scheduler = TestCoroutineScheduler() + val mainDispatcher = StandardTestDispatcher(scheduler) + Dispatchers.setMain(mainDispatcher) + try { + val scope = createTestCoroutineScope() + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher, scope.coroutineContext[ContinuationInterceptor]) + } finally { + Dispatchers.resetMain() + } + } + // Does not reuse the scheduler of `Dispatchers.Main` if one is explicitly passed + run { + val mainDispatcher = StandardTestDispatcher() + Dispatchers.setMain(mainDispatcher) + try { + val scheduler = TestCoroutineScheduler() + val scope = createTestCoroutineScope(scheduler) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher, scope.coroutineContext[ContinuationInterceptor]) + } finally { + Dispatchers.resetMain() + } + } } /** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */ From f979638583b6e896d3cb7ec0f038a6a7f1f21819 Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Wed, 10 Nov 2021 10:41:33 +0300 Subject: [PATCH 16/22] Eagerly enter launch and async blocks with unconfined dispatcher (#3011) Also, fix `Dispatchers.Main` not delegating `Delay` methods and discover that, on JS, `Dispatchers.Main` gets reset during the test if it is reset in `AfterTest`. --- .../common/src/CoroutineContext.common.kt | 1 + .../common/src/TestBuilders.kt | 2 +- .../common/src/TestCoroutineDispatchers.kt | 55 +++++++++++++++---- .../common/src/TestCoroutineScope.kt | 9 +-- .../common/src/internal/TestMainDispatcher.kt | 11 +++- .../common/test/Helpers.kt | 3 + .../common/test/RunTestTest.kt | 36 ++++++------ .../common/test/StandardTestDispatcherTest.kt | 13 +++++ .../common/test/TestDispatchersTest.kt | 42 ++++++++++++++ .../test/UnconfinedTestDispatcherTest.kt | 30 ++++++++++ .../js/test/FailingTests.kt | 37 +++++++++++++ kotlinx-coroutines-test/js/test/Helpers.kt | 4 ++ .../native/test/FailingTests.kt | 25 +++++++++ 13 files changed, 228 insertions(+), 40 deletions(-) create mode 100644 kotlinx-coroutines-test/js/test/FailingTests.kt create mode 100644 kotlinx-coroutines-test/native/test/FailingTests.kt diff --git a/kotlinx-coroutines-core/common/src/CoroutineContext.common.kt b/kotlinx-coroutines-core/common/src/CoroutineContext.common.kt index e17833218f..da094e152d 100644 --- a/kotlinx-coroutines-core/common/src/CoroutineContext.common.kt +++ b/kotlinx-coroutines-core/common/src/CoroutineContext.common.kt @@ -12,6 +12,7 @@ import kotlin.coroutines.* */ public expect fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext +@PublishedApi @Suppress("PropertyName") internal expect val DefaultDelay: Delay diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index db15bbfdbf..e1f7c0a908 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -219,7 +219,7 @@ public fun runTest( return createTestResult { /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with * [TestCoroutineDispatcher], because the event loop is not started. */ - testScope.start(CoroutineStart.DEFAULT, testScope) { + testScope.start(CoroutineStart.UNDISPATCHED, testScope) { testBody() } var completed = false diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt index 6e18bf348e..0152c9a21b 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt @@ -7,6 +7,8 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.channels.* import kotlinx.coroutines.flow.* +import kotlinx.coroutines.test.internal.* +import kotlinx.coroutines.test.internal.TestMainDispatcher import kotlin.coroutines.* /** @@ -15,10 +17,32 @@ import kotlin.coroutines.* * This dispatcher is similar to [Dispatchers.Unconfined]: the tasks that it executes are not confined to any particular * thread and form an event loop; it's different in that it skips delays, as all [TestDispatcher]s do. * + * Like [Dispatchers.Unconfined], this one does not provide guarantees about the execution order when several coroutines + * are queued in this dispatcher. However, we ensure that the [launch] and [async] blocks at the top level of [runTest] + * are entered eagerly. This allows launching child coroutines and not calling [runCurrent] for them to start executing. + * + * ``` + * @Test + * fun testEagerlyEnteringChildCoroutines() = runTest(UnconfinedTestDispatcher()) { + * var entered = false + * val deferred = CompletableDeferred() + * var completed = false + * launch { + * entered = true + * deferred.await() + * completed = true + * } + * assertTrue(entered) // `entered = true` already executed. + * assertFalse(completed) // however, the child coroutine then suspended, so it is enqueued. + * deferred.complete(Unit) // resume the coroutine. + * assertTrue(completed) // now the child coroutine is immediately completed. + * } + * ``` + * * Using this [TestDispatcher] can greatly simplify writing tests where it's not important which thread is used when and * in which order the queued coroutines are executed. - * The typical use case for this is launching child coroutines that are resumed immediately, without going through a - * dispatch; this can be helpful for testing [Channel] and [StateFlow] usages. + * Another typical use case for this dispatcher is launching child coroutines that are resumed immediately, without + * going through a dispatch; this can be helpful for testing [Channel] and [StateFlow] usages. * * ``` * @Test @@ -40,14 +64,16 @@ import kotlin.coroutines.* * } * ``` * - * However, please be aware that, like [Dispatchers.Unconfined], this is a specific dispatcher with execution order + * Please be aware that, like [Dispatchers.Unconfined], this is a specific dispatcher with execution order * guarantees that are unusual and not shared by most other dispatchers, so it can only be used reliably for testing * functionality, not the specific order of actions. * See [Dispatchers.Unconfined] for a discussion of the execution order guarantees. * * In order to support delay skipping, this dispatcher is linked to a [TestCoroutineScheduler], which is used to control - * the virtual time and can be shared among many test dispatchers. If no [scheduler] is passed as an argument, a new one - * is created. + * the virtual time and can be shared among many test dispatchers. + * If no [scheduler] is passed as an argument, [Dispatchers.Main] is checked, and if it was mocked with a + * [TestDispatcher] via [Dispatchers.setMain], the [TestDispatcher.scheduler] of the mock dispatcher is used; if + * [Dispatchers.Main] is not mocked with a [TestDispatcher], a new [TestCoroutineScheduler] is created. * * Additionally, [name] can be set to distinguish each dispatcher instance when debugging. * @@ -56,14 +82,14 @@ import kotlin.coroutines.* @ExperimentalCoroutinesApi @Suppress("FunctionName") public fun UnconfinedTestDispatcher( - scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), + scheduler: TestCoroutineScheduler? = null, name: String? = null -): TestDispatcher = UnconfinedTestDispatcherImpl(scheduler, name) +): TestDispatcher = UnconfinedTestDispatcherImpl(scheduler ?: mainTestScheduler ?: TestCoroutineScheduler(), name) private class UnconfinedTestDispatcherImpl( override val scheduler: TestCoroutineScheduler, private val name: String? = null -): TestDispatcher() { +) : TestDispatcher() { override fun isDispatchNeeded(context: CoroutineContext): Boolean = false @@ -103,7 +129,9 @@ private class UnconfinedTestDispatcherImpl( * run these pending tasks, which will block until there are no more tasks scheduled at this point in time, or, when * inside [runTest], call [yield] to yield the (only) thread used by [runTest] to the newly-launched coroutines. * - * If a [scheduler] is not passed as an argument, a new one is created. + * If no [scheduler] is passed as an argument, [Dispatchers.Main] is checked, and if it was mocked with a + * [TestDispatcher] via [Dispatchers.setMain], the [TestDispatcher.scheduler] of the mock dispatcher is used; if + * [Dispatchers.Main] is not mocked with a [TestDispatcher], a new [TestCoroutineScheduler] is created. * * One can additionally pass a [name] in order to more easily distinguish this dispatcher during debugging. * @@ -111,14 +139,14 @@ private class UnconfinedTestDispatcherImpl( */ @Suppress("FunctionName") public fun StandardTestDispatcher( - scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), + scheduler: TestCoroutineScheduler? = null, name: String? = null -): TestDispatcher = StandardTestDispatcherImpl(scheduler, name) +): TestDispatcher = StandardTestDispatcherImpl(scheduler ?: mainTestScheduler ?: TestCoroutineScheduler(), name) private class StandardTestDispatcherImpl( override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), private val name: String? = null -): TestDispatcher() { +) : TestDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { checkSchedulerInContext(scheduler, context) @@ -127,3 +155,6 @@ private class StandardTestDispatcherImpl( override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]" } + +private val mainTestScheduler + get() = ((Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher)?.scheduler \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index f7b38dcc01..01a6aa4b88 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -6,8 +6,6 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.internal.* -import kotlinx.coroutines.test.internal.* -import kotlinx.coroutines.test.internal.TestMainDispatcher import kotlin.coroutines.* /** @@ -172,12 +170,7 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo } dispatcher } - null -> { - val mainDispatcherScheduler = - ((Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher)?.scheduler - scheduler = context[TestCoroutineScheduler] ?: mainDispatcherScheduler ?: TestCoroutineScheduler() - StandardTestDispatcher(scheduler) - } + null -> StandardTestDispatcher(context[TestCoroutineScheduler]).also { scheduler = it.scheduler } else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") } var scope: TestCoroutineScopeImpl? = null diff --git a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt index f9225584e3..3810c06536 100644 --- a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt @@ -12,10 +12,13 @@ import kotlin.coroutines.* */ internal class TestMainDispatcher(var delegate: CoroutineDispatcher): MainCoroutineDispatcher(), - Delay by (delegate as? Delay ?: defaultDelay) + Delay { private val mainDispatcher = delegate // the initial value passed to the constructor + private val delay + get() = delegate as? Delay ?: defaultDelay + override val immediate: MainCoroutineDispatcher get() = (delegate as? MainCoroutineDispatcher)?.immediate ?: this @@ -28,6 +31,12 @@ internal class TestMainDispatcher(var delegate: CoroutineDispatcher): fun resetDispatcher() { delegate = mainDispatcher } + + override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) = + delay.scheduleResumeAfterDelay(timeMillis, continuation) + + override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle = + delay.invokeOnTimeout(timeMillis, block, context) } @Suppress("INVISIBLE_MEMBER") diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt index 0132bafa0d..a63311b7e4 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -67,5 +67,8 @@ open class OrderedExecutionTestBase { internal fun T.void() { } +@OptionalExpectation +expect annotation class NoJs() + @OptionalExpectation expect annotation class NoNative() diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index a679a7cf6e..20e24d448a 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -84,7 +84,7 @@ class RunTestTest { /** Tests that too low of a dispatch timeout causes crashes. */ @Test - @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native fun testRunTestWithSmallTimeout() = testResultMap({ fn -> assertFailsWith { fn() } }) { @@ -107,7 +107,7 @@ class RunTestTest { /** Tests uncaught exceptions taking priority over dispatch timeout in error reports. */ @Test - @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> assertFailsWith { fn() } }) { @@ -174,12 +174,12 @@ class RunTestTest { /** Tests that, once the test body has thrown, the child coroutines are cancelled. */ @Test - fun testChildrenCancellationOnTestBodyFailure() { + fun testChildrenCancellationOnTestBodyFailure(): TestResult { var job: Job? = null - testResultMap({ + return testResultMap({ assertFailsWith { it() } assertTrue(job!!.isCancelled) - }, { + }) { runTest { job = launch { while (true) { @@ -188,14 +188,14 @@ class RunTestTest { } throw AssertionError() } - }) + } } /** Tests that [runTest] reports [TimeoutCancellationException]. */ @Test fun testTimeout() = testResultMap({ assertFailsWith { it() } - }, { + }) { runTest { withTimeout(50) { launch { @@ -203,19 +203,19 @@ class RunTestTest { } } } - }) + } /** Checks that [runTest] throws the root cause and not [JobCancellationException] when a child coroutine throws. */ @Test fun testRunTestThrowsRootCause() = testResultMap({ assertFailsWith { it() } - }, { + }) { runTest { launch { throw TestException() } } - }) + } /** Tests that [runTest] completes its job. */ @Test @@ -224,13 +224,13 @@ class RunTestTest { return testResultMap({ it() assertTrue(handlerCalled) - }, { + }) { runTest { coroutineContext.job.invokeOnCompletion { handlerCalled = true } } - }) + } } /** Tests that [runTest] doesn't complete the job that was passed to it as an argument. */ @@ -245,11 +245,11 @@ class RunTestTest { it() assertFalse(handlerCalled) assertEquals(0, job.children.filter { it.isActive }.count()) - }, { + }) { runTest(job) { assertTrue(coroutineContext.job in job.children) } - }) + } } /** Tests that, when the test body fails, the reported exceptions are suppressed. */ @@ -267,14 +267,14 @@ class RunTestTest { assertEquals("y", suppressed[1].message) assertEquals("z", suppressed[2].message) } - }, { + }) { runTest { launch(SupervisorJob()) { throw TestException("x") } launch(SupervisorJob()) { throw TestException("y") } launch(SupervisorJob()) { throw TestException("z") } throw TestException("w") } - }) + } /** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */ @Test @@ -287,10 +287,10 @@ class RunTestTest { } catch (e: TestException) { scope.cleanupTestCoroutines() // should not fail } - }, { + }) { scope.runTest { launch(SupervisorJob()) { throw TestException("x") } } - }) + } } } diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt index 7e8a6ad158..d00b50d90c 100644 --- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -54,4 +54,17 @@ class StandardTestDispatcherTest: OrderedExecutionTestBase() { expect(5) }.void() + /** Tests that the [TestCoroutineScheduler] used for [Dispatchers.Main] gets used by default. */ + @Test + fun testSchedulerReuse() { + val dispatcher1 = StandardTestDispatcher() + Dispatchers.setMain(dispatcher1) + try { + val dispatcher2 = StandardTestDispatcher() + assertSame(dispatcher1.scheduler, dispatcher2.scheduler) + } finally { + Dispatchers.resetMain() + } + } + } \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index f998f3ddea..6cfc038345 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -4,6 +4,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlinx.coroutines.test.internal.* import kotlin.coroutines.* import kotlin.test.* @@ -11,9 +12,48 @@ class TestDispatchersTest: OrderedExecutionTestBase() { @BeforeTest fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @AfterTest + fun tearDown() { Dispatchers.resetMain() } + /** Tests that asynchronous execution of tests does not happen concurrently with [AfterTest]. */ + @NoJs + @Test + fun testMainMocking() = runTest { + val mainAtStart = mainTestDispatcher + assertNotNull(mainAtStart) + withContext(Dispatchers.Main) { + delay(10) + } + withContext(Dispatchers.Default) { + delay(10) + } + withContext(Dispatchers.Main) { + delay(10) + } + assertSame(mainAtStart, mainTestDispatcher) + } + + /** Tests that the mocked [Dispatchers.Main] correctly forwards [Delay] methods. */ + @Test + fun testMockedMainImplementsDelay() = runTest { + val main = Dispatchers.Main + withContext(main) { + delay(10) + } + withContext(Dispatchers.Default) { + delay(10) + } + withContext(main) { + delay(10) + } + } + + /** Tests that [Distpachers.setMain] fails when called with [Dispatchers.Main]. */ @Test @NoNative fun testSelfSet() { @@ -57,3 +97,5 @@ class TestDispatchersTest: OrderedExecutionTestBase() { } } } + +private val mainTestDispatcher get() = ((Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher) diff --git a/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt index 3e1c48c717..719698e843 100644 --- a/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt @@ -134,4 +134,34 @@ class UnconfinedTestDispatcherTest { assertEquals(listOf(0, 1, 2, 3), values) } + /** Tests that child coroutines are eagerly entered. */ + @Test + fun testEagerlyEnteringChildCoroutines() = runTest(UnconfinedTestDispatcher()) { + var entered = false + val deferred = CompletableDeferred() + var completed = false + launch { + entered = true + deferred.await() + completed = true + } + assertTrue(entered) // `entered = true` already executed. + assertFalse(completed) // however, the child coroutine then suspended, so it is enqueued. + deferred.complete(Unit) // resume the coroutine. + assertTrue(completed) // now the child coroutine is immediately completed. + } + + /** Tests that the [TestCoroutineScheduler] used for [Dispatchers.Main] gets used by default. */ + @Test + fun testSchedulerReuse() { + val dispatcher1 = StandardTestDispatcher() + Dispatchers.setMain(dispatcher1) + try { + val dispatcher2 = UnconfinedTestDispatcher() + assertSame(dispatcher1.scheduler, dispatcher2.scheduler) + } finally { + Dispatchers.resetMain() + } + } + } \ No newline at end of file diff --git a/kotlinx-coroutines-test/js/test/FailingTests.kt b/kotlinx-coroutines-test/js/test/FailingTests.kt new file mode 100644 index 0000000000..54d6aed855 --- /dev/null +++ b/kotlinx-coroutines-test/js/test/FailingTests.kt @@ -0,0 +1,37 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.test.internal.* +import kotlin.test.* + +/** These are tests that we want to fail. They are here so that, when the issue is fixed, their failure indicates that + * everything is better now. */ +class FailingTests { + + private var tearDownEntered = false + + @BeforeTest + fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + tearDownEntered = true + } + + /** [TestDispatchersTest.testMainMocking]. */ + @Test + fun testAfterTestIsConcurrent() = runTest { + try { + val mainAtStart = (Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher ?: return@runTest + withContext(Dispatchers.Default) { + // context switch + } + assertNotSame(mainAtStart, (Dispatchers.Main as TestMainDispatcher).delegate) + } finally { + assertTrue(tearDownEntered) + } + } +} diff --git a/kotlinx-coroutines-test/js/test/Helpers.kt b/kotlinx-coroutines-test/js/test/Helpers.kt index b0a767c5df..5f19d1ac58 100644 --- a/kotlinx-coroutines-test/js/test/Helpers.kt +++ b/kotlinx-coroutines-test/js/test/Helpers.kt @@ -4,6 +4,8 @@ package kotlinx.coroutines.test +import kotlin.test.* + actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult = test().then( { @@ -14,3 +16,5 @@ actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): T throw it } }) + +actual typealias NoJs = Ignore diff --git a/kotlinx-coroutines-test/native/test/FailingTests.kt b/kotlinx-coroutines-test/native/test/FailingTests.kt new file mode 100644 index 0000000000..9fb77ce7c8 --- /dev/null +++ b/kotlinx-coroutines-test/native/test/FailingTests.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2016-2021 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 kotlin.test.* + +/** These are tests that we want to fail. They are here so that, when the issue is fixed, their failure indicates that + * everything is better now. */ +class FailingTests { + @Test + fun testRunTestLoopShutdownOnTimeout() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 1) { + withContext(Dispatchers.Default) { + delay(10000) + } + fail("shouldn't be reached") + } + } + +} \ No newline at end of file From c014717a7c20050ff1d94dcaf9b25b578fd56371 Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Tue, 16 Nov 2021 15:59:09 +0300 Subject: [PATCH 17/22] Implement TestScope (#3015) Co-authored-by: Vsevolod Tolstopyatov --- .../api/kotlinx-coroutines-test.api | 22 +- .../common/src/TestBuilders.kt | 250 ++++++---------- .../common/src/TestCoroutineScheduler.kt | 6 +- .../common/src/TestScope.kt | 231 +++++++++++++++ .../src/{ => migration}/DelayController.kt | 5 +- .../src/migration/TestBuildersDeprecated.kt | 183 ++++++++++++ .../TestCoroutineDispatcher.kt | 0 .../TestCoroutineExceptionHandler.kt | 1 - .../src/{ => migration}/TestCoroutineScope.kt | 45 +-- .../common/test/RunTestTest.kt | 6 +- .../common/test/StandardTestDispatcherTest.kt | 12 +- .../common/test/TestCoroutineSchedulerTest.kt | 69 +++-- .../common/test/TestScopeTest.kt | 173 +++++++++++ .../RunBlockingTestOnTestScopeTest.kt | 165 +++++++++++ .../test/migration/RunTestLegacyScopeTest.kt | 279 ++++++++++++++++++ .../{ => migration}/TestCoroutineScopeTest.kt | 5 +- 16 files changed, 1208 insertions(+), 244 deletions(-) create mode 100644 kotlinx-coroutines-test/common/src/TestScope.kt rename kotlinx-coroutines-test/common/src/{ => migration}/DelayController.kt (98%) create mode 100644 kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt rename kotlinx-coroutines-test/common/src/{ => migration}/TestCoroutineDispatcher.kt (100%) rename kotlinx-coroutines-test/common/src/{ => migration}/TestCoroutineExceptionHandler.kt (99%) rename kotlinx-coroutines-test/common/src/{ => migration}/TestCoroutineScope.kt (90%) create mode 100644 kotlinx-coroutines-test/common/test/TestScopeTest.kt create mode 100644 kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt create mode 100644 kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt rename kotlinx-coroutines-test/common/test/{ => migration}/TestCoroutineScopeTest.kt (98%) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index f3a69b9f1c..d90a319825 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -13,13 +13,18 @@ public final class kotlinx/coroutines/test/TestBuildersKt { public static final fun runBlockingTest (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineDispatcher;Lkotlin/jvm/functions/Function2;)V public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V + public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestScope;Lkotlin/jvm/functions/Function2;)V public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun runBlockingTestOnTestScope (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun runBlockingTestOnTestScope$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V - public static final fun runTest (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;)V + 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/TestDispatcher;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 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 } public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController { @@ -98,6 +103,19 @@ public final class kotlinx/coroutines/test/TestDispatchers { public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V } +public abstract interface class kotlinx/coroutines/test/TestScope : kotlinx/coroutines/CoroutineScope { + public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; +} + +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 advanceUntilIdle (Lkotlinx/coroutines/test/TestScope;)V + public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestScope;)J + public static final fun runCurrent (Lkotlinx/coroutines/test/TestScope;)V +} + public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor { public abstract fun cleanupTestCoroutines ()V public abstract fun getUncaughtExceptions ()Ljava/util/List; diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index e1f7c0a908..bc591a35a3 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -1,78 +1,15 @@ /* * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:JvmName("TestBuildersKt") +@file:JvmMultifileClass package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.selects.* import kotlin.coroutines.* - -/** - * Executes a [testBody] inside an immediate execution dispatcher. - * - * This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks. - * You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take - * extra time. - * - * ``` - * @Test - * fun exampleTest() = runBlockingTest { - * val deferred = async { - * delay(1_000) - * async { - * delay(1_000) - * }.await() - * } - * - * deferred.await() // result available immediately - * } - * - * ``` - * - * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test - * conditions. - * - * Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test. - * - * @throws AssertionError If the [testBody] does not complete (or cancel) all coroutines that it launches - * (including coroutines suspended on join/await). - * - * @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler], - * 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.", level = DeprecationLevel.WARNING) -public fun runBlockingTest( - context: CoroutineContext = EmptyCoroutineContext, - testBody: suspend TestCoroutineScope.() -> Unit -) { - val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) - val scheduler = scope.testScheduler - val deferred = scope.async { - scope.testBody() - } - scheduler.advanceUntilIdle() - deferred.getCompletionExceptionOrNull()?.let { - throw it - } - scope.cleanupTestCoroutines() -} - -/** - * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. - */ -// todo: need documentation on how this extension is supposed to be used -@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) -public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = - runBlockingTest(coroutineContext, block) - -/** - * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. - */ -@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) -public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = - runBlockingTest(this, block) +import kotlin.jvm.* /** * A test result. @@ -96,7 +33,7 @@ public expect class TestResult /** * Executes [testBody] as a test in a new coroutine, returning [TestResult]. * - * On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs + * On JVM and Native, this function behaves similarly to `runBlocking`, with the difference that the code that it runs * will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary. * On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior. * @@ -154,7 +91,7 @@ public expect class TestResult * then its [TestCoroutineScheduler] is used; * otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control * the virtual time, advancing it, running the tasks scheduled at a specific time etc. - * Some convenience methods are available on [TestCoroutineScope] to control the scheduler. + * Some convenience methods are available on [TestScope] to control the scheduler. * * Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped: * ``` @@ -202,105 +139,43 @@ public expect class TestResult * * [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine * scope created for the test, [context] also can be used to change how the test is executed. - * See the [createTestCoroutineScope] documentation for details. + * See the [TestScope] constructor function documentation for details. * - * @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details. + * @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details. */ @ExperimentalCoroutinesApi public fun runTest( context: CoroutineContext = EmptyCoroutineContext, dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, - testBody: suspend TestCoroutineScope.() -> Unit + 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.") - val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest)) - val scheduler = testScope.testScheduler - return createTestResult { - /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with - * [TestCoroutineDispatcher], because the event loop is not started. */ - testScope.start(CoroutineStart.UNDISPATCHED, testScope) { - testBody() - } - var completed = false - while (!completed) { - scheduler.advanceUntilIdle() - if (testScope.isCompleted) { - /* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no - non-trivial dispatches. */ - completed = true - continue - } - select { - testScope.onJoin { - completed = true - } - scheduler.onDispatchEvent { - // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout - } - onTimeout(dispatchTimeoutMs) { - try { - testScope.cleanup() - } catch (e: UncompletedCoroutinesError) { - // we expect these and will instead throw a more informative exception just below. - } - throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") - } - } - } - testScope.getCompletionExceptionOrNull()?.let { - try { - testScope.cleanup() - } catch (e: UncompletedCoroutinesError) { - // it's normal that some jobs are not completed if the test body has failed, won't clutter the output - } catch (e: Throwable) { - it.addSuppressed(e) - } - throw it - } - testScope.cleanup() - } + return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs, testBody) } /** - * Runs [testProcedure], creating a [TestResult]. - */ -@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` -internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult - -/** - * Runs a test in a [TestCoroutineScope] based on this one. - * - * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the - * [block] will be different from this one, but will use its [Job] as a parent. - * - * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned - * immediately from the test body. See the docs for [TestResult] for details. + * Performs [runTest] on an existing [TestScope]. */ @ExperimentalCoroutinesApi -public fun TestCoroutineScope.runTest( +public fun TestScope.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, - block: suspend TestCoroutineScope.() -> Unit -): TestResult = - runTest(coroutineContext, dispatchTimeoutMs, block) + testBody: suspend TestScope.() -> Unit +): TestResult = asSpecificImplementation().let { + it.enter() + createTestResult { + runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() } + } +} /** - * Run a test using this [TestDispatcher]. - * - * A convenience function that calls [runTest] with the given arguments. - * - * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned - * immediately from the test body. See the docs for [TestResult] for details. + * Runs [testProcedure], creating a [TestResult]. */ -@ExperimentalCoroutinesApi -public fun TestDispatcher.runTest( - dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, - block: suspend TestCoroutineScope.() -> Unit -): TestResult = - runTest(this, dispatchTimeoutMs, block) +@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` +internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult /** A coroutine context element indicating that the coroutine is running inside `runTest`. */ -private object RunningInRunTest : CoroutineContext.Key, CoroutineContext.Element { +internal object RunningInRunTest : CoroutineContext.Key, CoroutineContext.Element { override val key: CoroutineContext.Key<*> get() = this @@ -309,24 +184,69 @@ private object RunningInRunTest : CoroutineContext.Key, Corout /** The default timeout to use when waiting for asynchronous completions of the coroutines managed by * a [TestCoroutineScheduler]. */ -private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L - -private class TestBodyCoroutine( - private val testScope: TestCoroutineScope, -) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope { - - override val testScheduler get() = testScope.testScheduler +internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L - @Deprecated( - "This deprecation is to prevent accidentally calling `cleanupTestCoroutines` in our own code.", - ReplaceWith("this.cleanup()"), - DeprecationLevel.ERROR - ) - override fun cleanupTestCoroutines() = - throw UnsupportedOperationException( - "Calling `cleanupTestCoroutines` inside `runTest` is prohibited: " + - "it will be called at the end of the test in any case." - ) +/** + * Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most + * [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end. + * + * 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 > runTestCoroutine( + coroutine: T, + dispatchTimeoutMs: Long, + testBody: suspend T.() -> Unit, + cleanup: () -> List, +) { + val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!! + /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with + * [TestCoroutineDispatcher], because the event loop is not started. */ + coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) { + testBody() + } + var completed = false + while (!completed) { + 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. */ + completed = true + continue + } + select { + coroutine.onJoin { + completed = true + } + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } + onTimeout(dispatchTimeoutMs) { + try { + cleanup() + } catch (e: UncompletedCoroutinesError) { + // we expect these and will instead throw a more informative exception just below. + emptyList() + }.throwAll() + throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") + } + } + } + coroutine.getCompletionExceptionOrNull()?.let { exception -> + val exceptions = try { + cleanup() + } catch (e: UncompletedCoroutinesError) { + // 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() + } + cleanup().throwAll() +} - fun cleanup() = testScope.cleanupTestCoroutines() +internal fun List.throwAll() { + firstOrNull()?.apply { + drop(1).forEach { addSuppressed(it) } + throw this + } } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 2acd8e527f..d256f27fb0 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -169,9 +169,11 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout /** * Checks that the only tasks remaining in the scheduler are cancelled. */ - // TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap] - internal fun isIdle(): Boolean { + internal fun isIdle(strict: Boolean = true): Boolean { synchronized(lock) { + if (strict) + return events.isEmpty + // TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap] val presentEvents = mutableListOf>() while (true) { presentEvents += events.removeFirstOrNull() ?: break diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt new file mode 100644 index 0000000000..b48b273cd9 --- /dev/null +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -0,0 +1,231 @@ +/* + * Copyright 2016-2021 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.internal.* +import kotlin.coroutines.* + +/** + * A coroutine scope that for launching test coroutines. + * + * The scope provides the following functionality: + * * The [coroutineContext] includes a [coroutine dispatcher][TestDispatcher] that supports delay-skipping, using + * a [TestCoroutineScheduler] for orchestrating the virtual time. + * This scheduler is also available via the [testScheduler] property, and some helper extension + * methods are defined to more conveniently interact with it: see [TestScope.currentTime], [TestScope.runCurrent], + * [TestScope.advanceTimeBy], and [TestScope.advanceUntilIdle]. + * * When inside [runTest], uncaught exceptions from the child coroutines of this scope will be reported at the end of + * the test. + * It is invalid for child coroutines to throw uncaught exceptions when outside the call to [TestScope.runTest]: + * the only guarantee in this case is the best effort to deliver the exception. + * + * The usual way to access a [TestScope] is to call [runTest], but it can also be constructed manually, in order to + * use it to initialize the components that participate in the test. + * + * #### Differences from the deprecated [TestCoroutineScope] + * + * * This doesn't provide an equivalent of [TestCoroutineScope.cleanupTestCoroutines], and so can't be used as a + * standalone mechanism for writing tests: it does require that [runTest] is eventually called. + * The reason for this is that a proper cleanup procedure that supports using non-test dispatchers and arbitrary + * coroutine suspensions would be equivalent to [runTest], but would also be more error-prone, due to the potential + * for forgetting to perform the cleanup. + * * [TestCoroutineScope.advanceTimeBy] also calls [TestCoroutineScheduler.runCurrent] after advancing the virtual time. + * * No support for dispatcher pausing, like [DelayController] allows. [TestCoroutineDispatcher], which supported + * pausing, is deprecated; now, instead of pausing a dispatcher, one can use [withContext] to run a dispatcher that's + * 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 +} + +/** + * The current virtual time on [testScheduler][TestScope.testScheduler]. + * @see TestCoroutineScheduler.currentTime + */ +@ExperimentalCoroutinesApi +public val TestScope.currentTime: Long + get() = testScheduler.currentTime + +/** + * Advances the [testScheduler][TestScope.testScheduler] to the point where there are no tasks remaining. + * @see TestCoroutineScheduler.advanceUntilIdle + */ +@ExperimentalCoroutinesApi +public fun TestScope.advanceUntilIdle(): Unit = testScheduler.advanceUntilIdle() + +/** + * Run any tasks that are pending at the current virtual time, according to + * the [testScheduler][TestScope.testScheduler]. + * + * @see TestCoroutineScheduler.runCurrent + */ +@ExperimentalCoroutinesApi +public fun TestScope.runCurrent(): Unit = testScheduler.runCurrent() + +/** + * Moves the virtual clock of this dispatcher forward by [the specified amount][delayTimeMillis], running the + * scheduled tasks in the meantime. + * + * In contrast with [TestScope.advanceTimeBy], this function does not run the tasks scheduled at the moment + * [currentTime] + [delayTimeMillis]. + * + * @throws IllegalStateException if passed a negative [delay][delayTimeMillis]. + * @see TestCoroutineScheduler.advanceTimeBy + */ +@ExperimentalCoroutinesApi +public fun TestScope.advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis) + +/** + * Creates a [TestScope]. + * + * It ensures that all the test module machinery is properly initialized. + * * If [context] doesn't provide a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, + * a new one is created, unless either + * - a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used; + * - at the moment of the creation of the scope, [Dispatchers.Main] is delegated to a [TestDispatcher], in which case + * its [TestCoroutineScheduler] is used. + * * If [context] doesn't have a [TestDispatcher], a [StandardTestDispatcher] is created. + * * A [CoroutineExceptionHandler] is created that makes [TestCoroutineScope.cleanupTestCoroutines] throw if there were + * any uncaught exceptions, or forwards the exceptions further in a platform-specific manner if the cleanup was + * already performed when an exception happened. Passing a [CoroutineExceptionHandler] is illegal, unless it's an + * [UncaughtExceptionCaptor], in which case the behavior is preserved for the time being for backward compatibility. + * If you need to have a specific [CoroutineExceptionHandler], please pass it to [launch] on an already-created + * [TestCoroutineScope] and share your use case at + * [our issue tracker](https://github.com/Kotlin/kotlinx.coroutines/issues). + * * If [context] provides a [Job], that job is used as a parent for the new scope. + * + * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a + * different scheduler. + * @throws IllegalArgumentException if [context] has a [ContinuationInterceptor] that is not a [TestDispatcher]. + * @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() + var scope: TestScopeImpl? = null + val exceptionHandler = when (ctxWithDispatcher[CoroutineExceptionHandler]) { + null -> CoroutineExceptionHandler { _, exception -> + scope!!.reportException(exception) + } + else -> throw IllegalArgumentException( + "A CoroutineExceptionHandler was passed to TestScope. " + + "Please pass it as an argument to a `launch` or `async` block on an already-created scope " + + "if uncaught exceptions require special treatment." + ) + } + return TestScopeImpl(ctxWithDispatcher + exceptionHandler).also { scope = it } +} + +/** + * Adds a [TestDispatcher] and a [TestCoroutineScheduler] to the context if there aren't any already. + * + * @throws IllegalArgumentException if both a [TestCoroutineScheduler] and a [TestDispatcher] are passed. + * @throws IllegalArgumentException if a [ContinuationInterceptor] is passed that is not a [TestDispatcher]. + */ +internal fun CoroutineContext.withDelaySkipping(): CoroutineContext { + val dispatcher: TestDispatcher = when (val dispatcher = get(ContinuationInterceptor)) { + is TestDispatcher -> { + val ctxScheduler = get(TestCoroutineScheduler) + if (ctxScheduler != null) { + require(dispatcher.scheduler === ctxScheduler) { + "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + + "another scheduler were passed." + } + } + dispatcher + } + null -> StandardTestDispatcher(get(TestCoroutineScheduler)) + else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") + } + return this + dispatcher + dispatcher.scheduler +} + +internal class TestScopeImpl(context: CoroutineContext) : + AbstractCoroutine(context, initParentJob = true, active = true), TestScope { + + override val testScheduler get() = context[TestCoroutineScheduler]!! + + private var entered = false + private var finished = false + private val uncaughtExceptions = mutableListOf() + private val lock = SynchronizedObject() + + /** Called upon entry to [runTest]. Will throw if called more than once. */ + fun enter() { + val exceptions = synchronized(lock) { + if (entered) + throw IllegalStateException("Only a single call to `runTest` can be performed during one test.") + entered = true + check(!finished) + uncaughtExceptions + } + if (exceptions.isNotEmpty()) { + throw UncaughtExceptionsBeforeTest().apply { + for (e in exceptions) + addSuppressed(e) + } + } + } + + /** Called at the end of the test. May only be called once. */ + fun leave(): List { + val exceptions = synchronized(lock) { + if(!entered || finished) + throw IllegalStateException("An internal error. Please report to the Kotlinx Coroutines issue tracker") + finished = true + uncaughtExceptions + } + val activeJobs = children.filter { it.isActive }.toList() // only non-empty if used with `runBlockingTest` + if (exceptions.isEmpty()) { + if (activeJobs.isNotEmpty()) + throw UncompletedCoroutinesError( + "Active jobs found during the tear-down. " + + "Ensure that all coroutines are completed or cancelled by your test. " + + "The active jobs: $activeJobs" + ) + if (!testScheduler.isIdle()) + throw UncompletedCoroutinesError( + "Unfinished coroutines found during the tear-down. " + + "Ensure that all coroutines are completed or cancelled by your test." + ) + } + return exceptions + } + + /** Stores an exception to report after [runTest], or rethrows it if not inside [runTest]. */ + fun reportException(throwable: Throwable) { + synchronized(lock) { + if (finished) { + throw throwable + } else { + uncaughtExceptions.add(throwable) + if (!entered) + throw UncaughtExceptionsBeforeTest().apply { addSuppressed(throwable) } + } + } + } + + override fun toString(): String = + "TestScope[" + (if (finished) "test ended" else if (entered) "test started" else "test not started") + "]" +} + +/** Use the knowledge that any [TestScope] that we receive is necessarily a [TestScopeImpl]. */ +internal fun TestScope.asSpecificImplementation(): TestScopeImpl = when (this) { + is TestScopeImpl -> this +} + +internal class UncaughtExceptionsBeforeTest : IllegalStateException( + "There were uncaught exceptions in coroutines launched from TestScope before the test started. Please avoid this," + + " as such exceptions are also reported in a platform-dependent manner so that they are not lost." +) \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/migration/DelayController.kt similarity index 98% rename from kotlinx-coroutines-test/common/src/DelayController.kt rename to kotlinx-coroutines-test/common/src/migration/DelayController.kt index 8b34b8a267..62c2167177 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/migration/DelayController.kt @@ -5,8 +5,7 @@ package kotlinx.coroutines.test -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* /** * Control the virtual clock time of a [CoroutineDispatcher]. @@ -186,7 +185,7 @@ internal interface SchedulerAsDelayController : DelayController { override fun cleanupTestCoroutines() { // process any pending cancellations or completions, but don't advance time scheduler.runCurrent() - if (!scheduler.isIdle()) { + if (!scheduler.isIdle(strict = false)) { throw UncompletedCoroutinesError( "Unfinished coroutines during tear-down. Ensure all coroutines are" + " completed or cancelled by your test." diff --git a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt new file mode 100644 index 0000000000..68398fb424 --- /dev/null +++ b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +@file:Suppress("DEPRECATION") +@file:JvmName("TestBuildersKt") +@file:JvmMultifileClass + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.selects.* +import kotlin.coroutines.* +import kotlin.jvm.* + +/** + * Executes a [testBody] inside an immediate execution dispatcher. + * + * This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks. + * You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take + * extra time. + * + * ``` + * @Test + * fun exampleTest() = runBlockingTest { + * val deferred = async { + * delay(1_000) + * async { + * delay(1_000) + * }.await() + * } + * + * deferred.await() // result available immediately + * } + * + * ``` + * + * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test + * conditions. + * + * Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test. + * + * @throws AssertionError If the [testBody] does not complete (or cancel) all coroutines that it launches + * (including coroutines suspended on join/await). + * + * @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler], + * 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.", level = DeprecationLevel.WARNING) +public fun runBlockingTest( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestCoroutineScope.() -> Unit +) { + val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) + val scheduler = scope.testScheduler + val deferred = scope.async { + scope.testBody() + } + scheduler.advanceUntilIdle() + deferred.getCompletionExceptionOrNull()?.let { + throw it + } + scope.cleanupTestCoroutines() +} + +/** + * A version of [runBlockingTest] that works with [TestScope]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun runBlockingTestOnTestScope( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestScope.() -> Unit +) { + val completeContext = TestCoroutineDispatcher() + SupervisorJob() + context + val startJobs = completeContext.activeJobs() + val scope = TestScope(completeContext).asSpecificImplementation() + scope.enter() + scope.start(CoroutineStart.UNDISPATCHED, scope) { + scope.testBody() + } + scope.testScheduler.advanceUntilIdle() + try { + scope.getCompletionExceptionOrNull() + } catch (e: IllegalStateException) { + null // the deferred was not completed yet; `scope.leave()` should complain then about unfinished jobs + }?.let { + val exceptions = try { + scope.leave() + } catch (e: UncompletedCoroutinesError) { + listOf() + } + (listOf(it) + exceptions).throwAll() + return + } + scope.leave().throwAll() + val jobs = completeContext.activeJobs() - startJobs + if (jobs.isNotEmpty()) + throw UncompletedCoroutinesError("Some jobs were not completed at the end of the test: $jobs") +} + +/** + * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = + runBlockingTest(coroutineContext, block) + +/** + * Convenience method for calling [runBlockingTestOnTestScope] on an existing [TestScope]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun TestScope.runBlockingTest(block: suspend TestScope.() -> Unit): Unit = + runBlockingTestOnTestScope(coroutineContext, block) + +/** + * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = + runBlockingTest(this, block) + +/** + * This is an overload of [runTest] that works with [TestCoroutineScope]. + */ +@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead.", level = DeprecationLevel.WARNING) +public fun runTestWithLegacyScope( + context: CoroutineContext = EmptyCoroutineContext, + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + testBody: suspend TestCoroutineScope.() -> Unit +): TestResult { + if (context[RunningInRunTest] != null) + 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, testBody) { + try { + testScope.cleanup() + emptyList() + } catch (e: UncompletedCoroutinesError) { + throw e + } catch (e: Throwable) { + listOf(e) + } + } + } +} + +/** + * Runs a test in a [TestCoroutineScope] based on this one. + * + * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the + * [block] will be different from this one, but will use its [Job] as a parent. + * + * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned + * immediately from the test body. See the docs for [TestResult] for details. + */ +@ExperimentalCoroutinesApi +@Deprecated("Use `TestScope.runTest` instead.", level = DeprecationLevel.WARNING) +public fun TestCoroutineScope.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult = runTestWithLegacyScope(coroutineContext, dispatchTimeoutMs, block) + +private class TestBodyCoroutine( + private val testScope: TestCoroutineScope, +) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope { + + override val testScheduler get() = testScope.testScheduler + + @Deprecated( + "This deprecation is to prevent accidentally calling `cleanupTestCoroutines` in our own code.", + ReplaceWith("this.cleanup()"), + DeprecationLevel.ERROR + ) + override fun cleanupTestCoroutines() = + throw UnsupportedOperationException( + "Calling `cleanupTestCoroutines` inside `runTest` is prohibited: " + + "it will be called at the end of the test in any case." + ) + + fun cleanup() = testScope.cleanupTestCoroutines() +} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt similarity index 100% rename from kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt rename to kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt similarity index 99% rename from kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt rename to kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt index b85f21ee69..f9991496a7 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt @@ -57,7 +57,6 @@ public class TestCoroutineExceptionHandler : synchronized(_lock) { if (_coroutinesCleanedUp) { handleCoroutineExceptionImpl(context, exception) - return } _exceptions += exception } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt similarity index 90% rename from kotlinx-coroutines-test/common/src/TestCoroutineScope.kt rename to kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt index 01a6aa4b88..4a8b54ba69 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt @@ -1,6 +1,7 @@ /* * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:Suppress("DEPRECATION") package kotlinx.coroutines.test @@ -12,6 +13,7 @@ import kotlin.coroutines.* * A scope which provides detailed control over the execution of coroutines for tests. */ @ExperimentalCoroutinesApi +@Deprecated("Use `TestScope` in combination with `runTest` instead") public sealed interface TestCoroutineScope : CoroutineScope { /** * Called after the test completes. @@ -84,7 +86,7 @@ private class TestCoroutineScopeImpl( } } else { testScheduler.runCurrent() - !testScheduler.isIdle() + !testScheduler.isIdle(strict = false) } (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.cleanupTestCoroutines() synchronized(lock) { @@ -107,7 +109,7 @@ private class TestCoroutineScopeImpl( } } -private fun CoroutineContext.activeJobs(): Set { +internal fun CoroutineContext.activeJobs(): Set { return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() } @@ -156,37 +158,22 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) * [UncaughtExceptionCaptor]. */ @ExperimentalCoroutinesApi +@Deprecated( + "This function was introduced in order to help migrate from TestCoroutineScope to TestScope. " + + "Please use TestScope() construction instead, or just runTest(), without creating a scope.", + level = DeprecationLevel.WARNING +) public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { - val scheduler: TestCoroutineScheduler - val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) { - is TestDispatcher -> { - scheduler = dispatcher.scheduler - val ctxScheduler = context[TestCoroutineScheduler] - if (ctxScheduler != null) { - require(dispatcher.scheduler === ctxScheduler) { - "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + - "another scheduler were passed." - } - } - dispatcher - } - null -> StandardTestDispatcher(context[TestCoroutineScheduler]).also { scheduler = it.scheduler } - else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") - } + val ctxWithDispatcher = context.withDelaySkipping() var scope: TestCoroutineScopeImpl? = null - val ownExceptionHandler = run { - val lock = SynchronizedObject() + val ownExceptionHandler = object : AbstractCoroutineContextElement(CoroutineExceptionHandler), TestCoroutineScopeExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { - val reported = synchronized(lock) { - scope!!.reportException(exception) - } - if (!reported) + if (!scope!!.reportException(exception)) throw exception // let this exception crash everything } } - } - val exceptionHandler = when (val exceptionHandler = context[CoroutineExceptionHandler]) { + val exceptionHandler = when (val exceptionHandler = ctxWithDispatcher[CoroutineExceptionHandler]) { is UncaughtExceptionCaptor -> exceptionHandler null -> ownExceptionHandler is TestCoroutineScopeExceptionHandler -> ownExceptionHandler @@ -196,8 +183,8 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo "if uncaught exceptions require special treatment." ) } - val job: Job = context[Job] ?: Job() - return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job).also { + val job: Job = ctxWithDispatcher[Job] ?: Job() + return TestCoroutineScopeImpl(ctxWithDispatcher + exceptionHandler + job).also { scope = it } } @@ -205,7 +192,7 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo /** A marker that shows that this [CoroutineExceptionHandler] was created for [TestCoroutineScope]. With this, * constructing a new [TestCoroutineScope] with the [CoroutineScope.coroutineContext] of an existing one will override * the exception handler, instead of failing. */ -private interface TestCoroutineScopeExceptionHandler: CoroutineExceptionHandler +private interface TestCoroutineScopeExceptionHandler : CoroutineExceptionHandler private inline val CoroutineContext.delayController: DelayController? get() { diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 20e24d448a..e063cdacf1 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -124,7 +124,7 @@ class RunTestTest { /** Tests that passing invalid contexts to [runTest] causes it to fail (on JS, without forking). */ @Test fun testRunTestWithIllegalContext() { - for (ctx in TestCoroutineScopeTest.invalidContexts) { + for (ctx in TestScopeTest.invalidContexts) { assertFailsWith { runTest(ctx) { } } @@ -279,13 +279,13 @@ class RunTestTest { /** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */ @Test fun testScopeRunTestExceptionHandler(): TestResult { - val scope = createTestCoroutineScope() + val scope = TestScope() return testResultMap({ try { it() fail("should not be reached") } catch (e: TestException) { - scope.cleanupTestCoroutines() // should not fail + // expected } }) { scope.runTest { diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt index d00b50d90c..e9b2e179da 100644 --- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -10,10 +10,18 @@ import kotlin.test.* class StandardTestDispatcherTest: OrderedExecutionTestBase() { - private val scope = createTestCoroutineScope(StandardTestDispatcher()) + private val scope = TestScope(StandardTestDispatcher()) + + @BeforeTest + fun init() { + scope.asSpecificImplementation().enter() + } @AfterTest - fun cleanup() = scope.cleanupTestCoroutines() + fun cleanup() { + scope.runCurrent() + assertEquals(listOf(), scope.asSpecificImplementation().leave()) + } /** Tests that the [StandardTestDispatcher] follows an execution order similar to `runBlocking`. */ @Test diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index 5e5a91f6f7..d3e4294a1a 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -5,7 +5,6 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* -import kotlin.coroutines.* import kotlin.test.* class TestCoroutineSchedulerTest { @@ -47,7 +46,7 @@ class TestCoroutineSchedulerTest { @Test fun testAdvanceTimeByEnormousDelays() = forTestDispatchers { assertRunsFast { - with (createTestCoroutineScope(it)) { + with (TestScope(it)) { launch { val initialDelay = 10L delay(initialDelay) @@ -78,30 +77,29 @@ class TestCoroutineSchedulerTest { /** Tests the basic functionality of [TestCoroutineScheduler.advanceTimeBy]. */ @Test - fun testAdvanceTimeBy() = assertRunsFast { - val scheduler = TestCoroutineScheduler() - val scope = createTestCoroutineScope(scheduler) - var stage = 1 - scope.launch { - delay(1_000) - assertEquals(1_000, scheduler.currentTime) - stage = 2 - delay(500) - assertEquals(1_500, scheduler.currentTime) - stage = 3 - delay(501) - assertEquals(2_001, scheduler.currentTime) - stage = 4 + fun testAdvanceTimeBy() = runTest { + assertRunsFast { + var stage = 1 + launch { + delay(1_000) + assertEquals(1_000, currentTime) + stage = 2 + delay(500) + assertEquals(1_500, currentTime) + stage = 3 + delay(501) + assertEquals(2_001, currentTime) + stage = 4 + } + assertEquals(1, stage) + assertEquals(0, currentTime) + advanceTimeBy(2_000) + assertEquals(3, stage) + assertEquals(2_000, currentTime) + advanceTimeBy(2) + assertEquals(4, stage) + assertEquals(2_002, currentTime) } - assertEquals(1, stage) - assertEquals(0, scheduler.currentTime) - scheduler.advanceTimeBy(2_000) - assertEquals(3, stage) - assertEquals(2_000, scheduler.currentTime) - scheduler.advanceTimeBy(2) - assertEquals(4, stage) - assertEquals(2_002, scheduler.currentTime) - scope.cleanupTestCoroutines() } /** Tests the basic functionality of [TestCoroutineScheduler.runCurrent]. */ @@ -135,7 +133,7 @@ class TestCoroutineSchedulerTest { fun testRunCurrentNotDrainingQueue() = forTestDispatchers { assertRunsFast { val scheduler = it.scheduler - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var stage = 1 scope.launch { delay(SLOW) @@ -160,7 +158,7 @@ class TestCoroutineSchedulerTest { fun testNestedAdvanceUntilIdle() = forTestDispatchers { assertRunsFast { val scheduler = it.scheduler - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var executed = false scope.launch { launch { @@ -177,7 +175,7 @@ class TestCoroutineSchedulerTest { /** Tests [yield] scheduling tasks for future execution and not executing immediately. */ @Test fun testYield() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var stage = 0 scope.launch { yield() @@ -197,7 +195,7 @@ class TestCoroutineSchedulerTest { /** Tests that dispatching the delayed tasks is ordered by their waking times. */ @Test fun testDelaysPriority() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var lastMeasurement = 0L fun checkTime(time: Long) { assertTrue(lastMeasurement < time) @@ -234,10 +232,11 @@ class TestCoroutineSchedulerTest { checkTime(201) } - private fun TestCoroutineScope.checkTimeout( + private fun TestScope.checkTimeout( timesOut: Boolean, timeoutMillis: Long = SLOW, block: suspend () -> Unit ) = assertRunsFast { var caughtException = false + asSpecificImplementation().enter() launch { try { withTimeout(timeoutMillis) { @@ -248,7 +247,7 @@ class TestCoroutineSchedulerTest { } } advanceUntilIdle() - cleanupTestCoroutines() + asSpecificImplementation().leave().throwAll() if (timesOut) assertTrue(caughtException) else @@ -258,7 +257,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts get triggered. */ @Test fun testSmallTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) scope.checkTimeout(true) { val half = SLOW / 2 delay(half) @@ -269,7 +268,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts don't get triggered if the code finishes in time. */ @Test fun testLargeTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) scope.checkTimeout(false) { val half = SLOW / 2 delay(half) @@ -280,7 +279,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts get triggered if the code fails to finish in time asynchronously. */ @Test fun testSmallAsynchronousTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) val deferred = CompletableDeferred() scope.launch { val half = SLOW / 2 @@ -296,7 +295,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts don't get triggered if the code finishes in time, even if it does so asynchronously. */ @Test fun testLargeAsynchronousTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) val deferred = CompletableDeferred() scope.launch { val half = SLOW / 2 diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt new file mode 100644 index 0000000000..743dde3ca7 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2016-2019 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 kotlin.coroutines.* +import kotlin.test.* + +class TestScopeTest { + /** Tests failing to create a [TestCoroutineScope] with incorrect contexts. */ + @Test + fun testCreateThrowsOnInvalidArguments() { + for (ctx in invalidContexts) { + assertFailsWith { + TestScope(ctx) + } + } + } + + /** Tests that a newly-created [TestCoroutineScope] provides the correct scheduler. */ + @Test + fun testCreateProvidesScheduler() { + // Creates a new scheduler. + run { + val scope = TestScope() + assertNotNull(scope.coroutineContext[TestCoroutineScheduler]) + } + // Reuses the scheduler that the dispatcher is linked to. + run { + val dispatcher = StandardTestDispatcher() + val scope = TestScope(dispatcher) + assertSame(dispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) + } + // Uses the scheduler passed to it. + run { + val scheduler = TestCoroutineScheduler() + val scope = TestScope(scheduler) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertSame(scheduler, (scope.coroutineContext[ContinuationInterceptor] as TestDispatcher).scheduler) + } + // Doesn't touch the passed dispatcher and the scheduler if they match. + run { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val scope = TestScope(scheduler + dispatcher) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertSame(dispatcher, scope.coroutineContext[ContinuationInterceptor]) + } + // Reuses the scheduler of `Dispatchers.Main` + run { + val scheduler = TestCoroutineScheduler() + val mainDispatcher = StandardTestDispatcher(scheduler) + Dispatchers.setMain(mainDispatcher) + try { + val scope = TestScope() + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher, scope.coroutineContext[ContinuationInterceptor]) + } finally { + Dispatchers.resetMain() + } + } + // Does not reuse the scheduler of `Dispatchers.Main` if one is explicitly passed + run { + val mainDispatcher = StandardTestDispatcher() + Dispatchers.setMain(mainDispatcher) + try { + val scheduler = TestCoroutineScheduler() + val scope = TestScope(scheduler) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher, scope.coroutineContext[ContinuationInterceptor]) + } finally { + Dispatchers.resetMain() + } + } + } + + /** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */ + @Test + fun testPresentDelaysThrowing() { + val scope = TestScope() + var result = false + scope.launch { + delay(5) + result = true + } + assertFalse(result) + scope.asSpecificImplementation().enter() + assertFailsWith { scope.asSpecificImplementation().leave() } + assertFalse(result) + } + + /** Tests that the cleanup procedure throws if there were active jobs by the end. */ + @Test + fun testActiveJobsThrowing() { + val scope = TestScope() + var result = false + val deferred = CompletableDeferred() + scope.launch { + deferred.await() + result = true + } + assertFalse(result) + scope.asSpecificImplementation().enter() + assertFailsWith { scope.asSpecificImplementation().leave() } + assertFalse(result) + } + + /** Tests that the cleanup procedure throws even if it detects that the job is already cancelled. */ + @Test + fun testCancelledDelaysThrowing() { + val scope = TestScope() + var result = false + val deferred = CompletableDeferred() + val job = scope.launch { + deferred.await() + result = true + } + job.cancel() + assertFalse(result) + scope.asSpecificImplementation().enter() + assertFailsWith { scope.asSpecificImplementation().leave() } + assertFalse(result) + } + + /** Tests that uncaught exceptions are thrown at the cleanup. */ + @Test + fun testGetsCancelledOnChildFailure(): TestResult { + val scope = TestScope() + val exception = TestException("test") + scope.launch { + throw exception + } + return testResultMap({ + try { + it() + fail("should not reach") + } catch (e: TestException) { + // expected + } + }) { + scope.runTest { + } + } + } + + /** Tests that, when reporting several exceptions, the first one is thrown, with the rest suppressed. */ + @Test + fun testSuppressedExceptions() { + TestScope().apply { + asSpecificImplementation().enter() + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + runCurrent() + val e = asSpecificImplementation().leave() + assertEquals(3, e.size) + assertEquals("x", e[0].message) + assertEquals("y", e[1].message) + assertEquals("z", e[2].message) + } + } + + companion object { + internal val invalidContexts = listOf( + Dispatchers.Default, // not a [TestDispatcher] + CoroutineExceptionHandler { _, _ -> }, // exception handlers can't be overridden + StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler + ) + } +} diff --git a/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt b/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt new file mode 100644 index 0000000000..174baa0819 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2016-2021 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.flow.* +import kotlin.test.* + +/** Copy of [RunTestTest], but for [runBlockingTestOnTestScope], where applicable. */ +@Suppress("DEPRECATION") +class RunBlockingTestOnTestScopeTest { + + @Test + fun testRunTestWithIllegalContext() { + for (ctx in TestScopeTest.invalidContexts) { + assertFailsWith { + runBlockingTestOnTestScope(ctx) { } + } + } + } + + @Test + fun testThrowingInRunTestBody() { + assertFailsWith { + runBlockingTestOnTestScope { + throw RuntimeException() + } + } + } + + @Test + fun testThrowingInRunTestPendingTask() { + assertFailsWith { + runBlockingTestOnTestScope { + launch { + delay(SLOW) + throw RuntimeException() + } + } + } + } + + @Test + fun reproducer2405() = runBlockingTestOnTestScope { + val dispatcher = StandardTestDispatcher(testScheduler) + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + @Test + fun testChildrenCancellationOnTestBodyFailure() { + var job: Job? = null + assertFailsWith { + runBlockingTestOnTestScope { + job = launch { + while (true) { + delay(1000) + } + } + throw AssertionError() + } + } + assertTrue(job!!.isCancelled) + } + + @Test + fun testTimeout() { + assertFailsWith { + runBlockingTestOnTestScope { + withTimeout(50) { + launch { + delay(1000) + } + } + } + } + } + + @Test + fun testRunTestThrowsRootCause() { + assertFailsWith { + runBlockingTestOnTestScope { + launch { + throw TestException() + } + } + } + } + + @Test + fun testCompletesOwnJob() { + var handlerCalled = false + runBlockingTestOnTestScope { + coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + } + assertTrue(handlerCalled) + } + + @Test + fun testDoesNotCompleteGivenJob() { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + runBlockingTestOnTestScope(job) { + assertTrue(coroutineContext.job in job.children) + } + assertFalse(handlerCalled) + assertEquals(0, job.children.filter { it.isActive }.count()) + } + + @Test + fun testSuppressedExceptions() { + try { + runBlockingTestOnTestScope { + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + throw TestException("w") + } + fail("should not be reached") + } catch (e: TestException) { + assertEquals("w", e.message) + val suppressed = e.suppressedExceptions + + (e.suppressedExceptions.firstOrNull()?.suppressedExceptions ?: emptyList()) + assertEquals(3, suppressed.size) + assertEquals("x", suppressed[0].message) + assertEquals("y", suppressed[1].message) + assertEquals("z", suppressed[2].message) + } + } + + @Test + fun testScopeRunTestExceptionHandler(): TestResult { + val scope = TestCoroutineScope() + return testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + // expected + } + }) { + scope.runTest { + launch(SupervisorJob()) { throw TestException("x") } + } + } + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt b/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt new file mode 100644 index 0000000000..3ea11139d1 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2016-2021 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.flow.* +import kotlin.coroutines.* +import kotlin.test.* + +/** Copy of [RunTestTest], but for [TestCoroutineScope] */ +@Suppress("DEPRECATION") +class RunTestLegacyScopeTest { + + @Test + fun testWithContextDispatching() = runTestWithLegacyScope { + var counter = 0 + withContext(Dispatchers.Default) { + counter += 1 + } + assertEquals(counter, 1) + } + + @Test + fun testJoiningForkedJob() = runTestWithLegacyScope { + var counter = 0 + val job = GlobalScope.launch { + counter += 1 + } + job.join() + assertEquals(counter, 1) + } + + @Test + fun testSuspendCoroutine() = runTestWithLegacyScope { + val answer = suspendCoroutine { + it.resume(42) + } + assertEquals(42, answer) + } + + @Test + fun testNestedRunTestForbidden() = runTestWithLegacyScope { + assertFailsWith { + runTest { } + } + } + + @Test + fun testRunTestWithZeroTimeoutWithControlledDispatches() = runTestWithLegacyScope(dispatchTimeoutMs = 0) { + // below is some arbitrary concurrent code where all dispatches go through the same scheduler. + launch { + delay(2000) + } + val deferred = async { + val job = launch(StandardTestDispatcher(testScheduler)) { + launch { + delay(500) + } + delay(1000) + } + job.join() + } + deferred.await() + } + + @Test + fun testRunTestWithZeroTimeoutWithUncontrolledDispatches() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTestWithLegacyScope(dispatchTimeoutMs = 0) { + withContext(Dispatchers.Default) { + delay(10) + 3 + } + fail("shouldn't be reached") + } + } + + @Test + @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestWithSmallTimeout() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTestWithLegacyScope(dispatchTimeoutMs = 100) { + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + @Test + fun testRunTestWithLargeTimeout() = runTestWithLegacyScope(dispatchTimeoutMs = 5000) { + withContext(Dispatchers.Default) { + delay(50) + } + } + + @Test + @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTestWithLegacyScope(dispatchTimeoutMs = 1) { + coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException()) + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + @Test + fun testRunTestWithIllegalContext() { + for (ctx in TestScopeTest.invalidContexts) { + assertFailsWith { + runTestWithLegacyScope(ctx) { } + } + } + } + + @Test + fun testThrowingInRunTestBody() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + throw RuntimeException() + } + } + + @Test + fun testThrowingInRunTestPendingTask() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + launch { + delay(SLOW) + throw RuntimeException() + } + } + } + + @Test + fun reproducer2405() = runTestWithLegacyScope { + val dispatcher = StandardTestDispatcher(testScheduler) + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + @Test + fun testChildrenCancellationOnTestBodyFailure(): TestResult { + var job: Job? = null + return testResultMap({ + assertFailsWith { it() } + assertTrue(job!!.isCancelled) + }) { + runTestWithLegacyScope { + job = launch { + while (true) { + delay(1000) + } + } + throw AssertionError() + } + } + } + + @Test + fun testTimeout() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + withTimeout(50) { + launch { + delay(1000) + } + } + } + } + + @Test + fun testRunTestThrowsRootCause() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + launch { + throw TestException() + } + } + } + + @Test + fun testCompletesOwnJob(): TestResult { + var handlerCalled = false + return testResultMap({ + it() + assertTrue(handlerCalled) + }) { + runTestWithLegacyScope { + coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + } + } + } + + @Test + fun testDoesNotCompleteGivenJob(): TestResult { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + return testResultMap({ + it() + assertFalse(handlerCalled) + assertEquals(0, job.children.filter { it.isActive }.count()) + }) { + runTestWithLegacyScope(job) { + assertTrue(coroutineContext.job in job.children) + } + } + } + + @Test + fun testSuppressedExceptions() = testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + assertEquals("w", e.message) + val suppressed = e.suppressedExceptions + + (e.suppressedExceptions.firstOrNull()?.suppressedExceptions ?: emptyList()) + assertEquals(3, suppressed.size) + assertEquals("x", suppressed[0].message) + assertEquals("y", suppressed[1].message) + assertEquals("z", suppressed[2].message) + } + }) { + runTestWithLegacyScope { + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + throw TestException("w") + } + } + + @Test + fun testScopeRunTestExceptionHandler(): TestResult { + val scope = TestCoroutineScope() + return testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + // expected + } + }) { + scope.runTest { + launch(SupervisorJob()) { throw TestException("x") } + } + } + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt similarity index 98% rename from kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt index 35d92904c8..1a62613790 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt @@ -1,6 +1,7 @@ /* - * Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:Suppress("DEPRECATION") package kotlinx.coroutines.test @@ -213,4 +214,4 @@ class TestCoroutineScopeTest { StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler ) } -} +} \ No newline at end of file From 96414ca678ee0578565cbf02052ee2b3b7246d4a Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Nov 2021 20:22:16 +0300 Subject: [PATCH 18/22] Don't avoid running tests on Native --- kotlinx-coroutines-test/common/test/TestDispatchersTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 6cfc038345..789744cec0 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -55,7 +55,6 @@ class TestDispatchersTest: OrderedExecutionTestBase() { /** Tests that [Distpachers.setMain] fails when called with [Dispatchers.Main]. */ @Test - @NoNative fun testSelfSet() { assertFailsWith { Dispatchers.setMain(Dispatchers.Main) } } From ded719f39531f7a6815801723a8f9c173ce9d482 Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Tue, 16 Nov 2021 19:44:44 +0300 Subject: [PATCH 19/22] Documentation for the test module (#3023) Fixes #1390 --- kotlinx-coroutines-test/MIGRATION.md | 325 +++++++++++ kotlinx-coroutines-test/README.md | 531 ++++++++---------- .../common/src/TestDispatcher.kt | 2 +- .../common/src/TestDispatchers.kt | 3 + .../common/src/TestScope.kt | 8 +- .../common/test/TestCoroutineSchedulerTest.kt | 2 - .../src/migration/DelayController.kt | 8 + .../src/migration/TestBuildersDeprecated.kt | 7 + .../src/migration/TestCoroutineDispatcher.kt | 1 + .../TestCoroutineExceptionHandler.kt | 2 + .../src/migration/TestCoroutineScope.kt | 15 +- .../RunBlockingTestOnTestScopeTest.kt | 0 .../test/migration/RunTestLegacyScopeTest.kt | 4 +- .../test/migration/TestBuildersTest.kt | 0 .../TestCoroutineDispatcherOrderTest.kt | 0 .../migration/TestCoroutineDispatcherTest.kt | 0 .../TestCoroutineExceptionHandlerTest.kt | 0 .../test/migration/TestCoroutineScopeTest.kt | 0 .../migration/TestRunBlockingOrderTest.kt | 0 .../test/migration/TestRunBlockingTest.kt | 0 20 files changed, 596 insertions(+), 312 deletions(-) create mode 100644 kotlinx-coroutines-test/MIGRATION.md rename kotlinx-coroutines-test/{common => jvm}/src/migration/DelayController.kt (92%) rename kotlinx-coroutines-test/{common => jvm}/src/migration/TestBuildersDeprecated.kt (93%) rename kotlinx-coroutines-test/{common => jvm}/src/migration/TestCoroutineDispatcher.kt (97%) rename kotlinx-coroutines-test/{common => jvm}/src/migration/TestCoroutineExceptionHandler.kt (95%) rename kotlinx-coroutines-test/{common => jvm}/src/migration/TestCoroutineScope.kt (95%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/RunBlockingTestOnTestScopeTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/RunTestLegacyScopeTest.kt (97%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestBuildersTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestCoroutineDispatcherOrderTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestCoroutineDispatcherTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestCoroutineExceptionHandlerTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestCoroutineScopeTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestRunBlockingOrderTest.kt (100%) rename kotlinx-coroutines-test/{common => jvm}/test/migration/TestRunBlockingTest.kt (100%) diff --git a/kotlinx-coroutines-test/MIGRATION.md b/kotlinx-coroutines-test/MIGRATION.md new file mode 100644 index 0000000000..5124864745 --- /dev/null +++ b/kotlinx-coroutines-test/MIGRATION.md @@ -0,0 +1,325 @@ +# Migration to the new kotlinx-coroutines-test API + +In version 1.6.0, the API of the test module changed significantly. +This is a guide for gradually adapting the existing test code to the new API. +This guide is written step-by-step; the idea is to separate the migration into several sets of small changes. + +## Remove custom `UncaughtExceptionCaptor`, `DelayController`, and `TestCoroutineScope` implementations + +We couldn't find any code that defined new implementations of these interfaces, so they are deprecated. It's likely that +you don't need to do anything for this section. + +### `UncaughtExceptionCaptor` + +If the code base has an `UncaughtExceptionCaptor`, its special behavior as opposed to just `CoroutineExceptionHandler` +was that, at the end of `runBlockingTest` or `cleanupTestCoroutines` (or both), its `cleanupTestCoroutines` procedure +was called. + +We currently don't provide a replacement for this. +However, `runTest` follows structured concurrency better than `runBlockingTest` did, so exceptions from child coroutines +are propagated structurally, which makes uncaught exception handlers less useful. + +If you have a use case for this, please tell us about it at the issue tracker. +Meanwhile, it should be possible to use a custom exception captor, which should only implement +`CoroutineExceptionHandler` now, like this: + +```kotlin +@Test +fun testFoo() = runTest { + val customCaptor = MyUncaughtExceptionCaptor() + launch(customCaptor) { + // ... + } + advanceUntilIdle() + customCaptor.cleanupTestCoroutines() +} +``` + +### `DelayController` + +We don't provide a way to define custom dispatching strategies that support virtual time. +That said, we significantly enhanced this mechanism: +* Using multiple test dispatchers simultaneously is supported. + For the dispatchers to have a shared knowledge of the virtual time, either the same `TestCoroutineScheduler` should be + passed to each of them, or all of them should be constructed after `Dispatchers.setMain` is called with some test + dispatcher. +* Both a simple `StandardTestDispatcher` that is always paused, and unconfined `UnconfinedTestDispatcher` are provided. + +If you have a use case for `DelayController` that's not covered by what we provide, please tell us about it in the issue +tracker. + +### `TestCoroutineScope` + +This scope couldn't be meaningfully used in tandem with `runBlockingTest`: according to the definition of +`TestCoroutineScope.runBlockingTest`, only the scope's `coroutineContext` is used. +So, there could be two reasons for defining a custom implementation: + +* Avoiding the restrictions on placed `coroutineContext` in the `TestCoroutineScope` constructor function. + These restrictions consisted of requirements for `CoroutineExceptionHandler` being an `UncaughtExceptionCaptor`, and + `ContinuationInterceptor` being a `DelayController`, so it is also possible to fulfill these restrictions by defining + conforming instances. In this case, follow the instructions about replacing them. +* Using without `runBlockingTest`. In this case, you don't even need to implement `TestCoroutineScope`: nothing else + accepts a `TestCoroutineScope` specifically as an argument. + +## Remove usages of `TestCoroutineExceptionHandler` and `TestCoroutineScope.uncaughtExceptions` + +It is already illegal to use a `TestCoroutineScope` without performing `cleanupTestCoroutines`, so the valid uses of +`TestCoroutineExceptionHandler` include: + +* Accessing `uncaughtExceptions` in the middle of the test to make sure that there weren't any uncaught exceptions + *yet*. + If there are any, they will be thrown by the cleanup procedure anyway. + We don't support this use case, given how comparatively rare it is, but it can be handled in the same way as the + following one. +* Accessing `uncaughtExceptions` when the uncaught exceptions are actually expected. + In this case, `cleanupTestCoroutines` will fail with an exception that is being caught later. + It would be better in this case to use a custom `CoroutineExceptionHandler` so that actual problems that could be + found by the cleanup procedure are not superseded by the exceptions that are expected. + An example is shown below. + +```kotlin +val exceptions = mutableListOf() +val customCaptor = CoroutineExceptionHandler { ctx, throwable -> + exceptions.add(throwable) // add proper synchronization if the test is multithreaded +} + +@Test +fun testFoo() = runTest { + launch(customCaptor) { + // ... + } + advanceUntilIdle() + // check the list of the caught exceptions +} +``` + +## Auto-replace `TestCoroutineScope` constructor function with `createTestCoroutineScope` + +This should not break anything, as `TestCoroutineScope` is now defined in terms of `createTestCoroutineScope`. +If it does break something, it means that you already supplied a `TestCoroutineScheduler` to some scope; in this case, +also pass this scheduler as the argument to the dispatcher. + +## Replace usages of `pauseDispatcher` and `resumeDispatcher` with a `StandardTestDispatcher` + +* In places where `pauseDispatcher` in its block form is called, replace it with a call to + `withContext(StandardTestDispatcher(testScheduler))` + (`testScheduler` is available as a field of `TestCoroutineScope`, + or `scheduler` is available as a field of `TestCoroutineDispatcher`), + followed by `advanceUntilIdle()`. + This is not an automatic replacement, as there can be tricky situations where the test dispatcher is already paused + when `pauseDispatcher { X }` is called. In such cases, simply replace `pauseDispatcher { X }` with `X`. +* Often, `pauseDispatcher()` in a non-block form is used at the start of the test. + Then, attempt to remove `TestCoroutineDispatcher` from the arguments to `createTestCoroutineScope`, + if a standalone `TestCoroutineScope` or the `scope.runBlockingTest` form is used, + or pass a `StandardTestDispatcher` as an argument to `runBlockingTest`. + This will lead to the test using a `StandardTestDispatcher`, which does not allow pausing and resuming, + instead of the deprecated `TestCoroutineDispatcher`. +* Sometimes, `pauseDispatcher()` and `resumeDispatcher()` are employed used throughout the test. + In this case, attempt to wrap everything until the next `resumeDispatcher()` in + a `withContext(StandardTestDispatcher(testScheduler))` block, or try using some other combinations of + `StandardTestDispatcher` (where dispatches are needed) and `UnconfinedTestDispatcher` (where it isn't important where + execution happens). + +## Replace `advanceTimeBy(n)` with `advanceTimeBy(n); runCurrent()` + +For `TestCoroutineScope` and `DelayController`, the `advanceTimeBy` method is deprecated. +It is not deprecated for `TestCoroutineScheduler` and `TestScope`, but has a different meaning: it does not run the +tasks scheduled *at* `currentTime + n`. + +There is an automatic replacement for this deprecation, which produces correct but inelegant code. + +Alternatively, you can wait until replacing `TestCoroutineScope` with `TestScope`: it's possible that you will not +encounter this edge case. + +## Replace `runBlockingTest` with `runTest(UnconfinedTestDispatcher())` + +This is a major change, affecting many things, and can be done in parallel with replacing `TestCoroutineScope` with +`TestScope`. + +Significant differences of `runTest` from `runBlockingTest` are each given a section below. + +### It works properly with other dispatchers and asynchronous completions. + +No action on your part is required, other than replacing `runBlocking` with `runTest` as well. + +### It uses `StandardTestDispatcher` by default, not `TestCoroutineDispatcher`. + +By now, calls to `pauseDispatcher` and `resumeDispatcher` should be purged from the code base, so only the unpaused +variant of `TestCoroutineDispatcher` should be used. +This version of the dispatcher, which can be observed has the property of eagerly entering `launch` and `async` blocks: +code until the first suspension is executed without dispatching. + +We ensured sure that, when run with an `UnconfinedTestDispatcher`, `runTest` also eagerly enters `launch` and `async` +blocks, but *this only works at the top level*: if a child coroutine also called `launch` or `async`, we don't provide +any guarantees about their dispatching order. + +So, using `UnconfinedTestDispatcher` as an argument to `runTest` will probably lead to the test being executed as it +did, but in the possible case that the test relies on the specific dispatching order of `TestCoroutineDispatcher`, it +will need to be tweaked. +If the test expects some code to have run at some point, but it hasn't, use `runCurrent` to force the tasks scheduled +at this moment of time to run. + +### The job hierarchy is completely different. + +- Structured concurrency is used, with the scope provided as the receiver of `runTest` actually being the scope of the + created coroutine. +- Not `SupervisorJob` but a normal `Job` is used for the `TestCoroutineScope`. +- The job passed as an argument is used as a parent job. + +Most tests should not be affected by this. In case your test is, try explicitly launching a child coroutine with a +`SupervisorJob`; this should make the job hierarchy resemble what it used to be. + +```kotlin +@Test +fun testFoo() = runTest { + val deferred = async(SupervisorJob()) { + // test code + } + advanceUntilIdle() + deferred.getCompletionExceptionOrNull()?.let { + throw it + } +} +``` + +### Only a single call to `runTest` is permitted per test. + +In order to work on JS, only a single call to `runTest` must happen during one test, and its result must be returned +immediately: + +```kotlin +@Test +fun testFoo(): TestResult { + // arbitrary code here + return runTest { + // ... + } +} +``` + +When used only on the JVM, `runTest` will work when called repeatedly, but this is not supported. +Please only call `runTest` once per test, and if for some reason you can't, please tell us about in on the issue +tracker. + +### It uses `TestScope`, not `TestCoroutineScope`, by default. + +There is a `runTestWithLegacyScope` method that allows migrating from `runBlockingTest` to `runTest` before migrating +from `TestCoroutineScope` to `TestScope`, if exactly the `TestCoroutineScope` needs to be passed somewhere else and +`TestScope` will not suffice. + +## Replace `TestCoroutineScope.cleanupTestCoroutines` with `runTest` + +Likely can be done together with the next step. + +Remove all calls to `TestCoroutineScope.cleanupTestCoroutines` from the code base. +Instead, as the last step of each test, do `return scope.runTest`; if possible, the whole test body should go inside +the `runTest` block. + +The cleanup procedure in `runTest` will not check that the virtual time doesn't advance during cleanup. +If a test must check that no other delays are remaining after it has finished, the following form may help: +```kotlin +runTest { + testBody() + val timeAfterTest = currentTime() + advanceUntilIdle() // run the remaining tasks + assertEquals(timeAfterTest, currentTime()) // will fail if there were tasks scheduled at a later moment +} +``` +Note that this will report time advancement even if the job scheduled at a later point was cancelled. + +It may be the case that `cleanupTestCoroutines` must be executed after de-initialization in `@AfterTest`, which happens +outside the test itself. +In this case, we propose that you write a wrapper of the form: + +```kotlin +fun runTestAndCleanup(body: TestScope.() -> Unit) = runTest { + try { + body() + } finally { + // the usual cleanup procedures that used to happen before `cleanupTestCoroutines` + } +} +``` + +## Replace `runBlockingTest` with `runBlockingTestOnTestScope`, `createTestCoroutineScope` with `TestScope` + +Also, replace `runTestWithLegacyScope` with just `runTest`. +All of this can be done in parallel with replacing `runBlockingTest` with `runTest`. + +This step should remove all uses of `TestCoroutineScope`, explicit or implicit. + +Replacing `runTestWithLegacyScope` and `runBlockingTest` with `runTest` and `runBlockingTestOnTestScope` should be +straightforward if there is no more code left that requires passing exactly `TestCoroutineScope` to it. +Some tests may fail because `TestCoroutineScope.cleanupTestCoroutines` and the cleanup procedure in `runTest` +handle cancelled tasks differently: if there are *cancelled* jobs pending at the moment of +`TestCoroutineScope.cleanupTestCoroutines`, they are ignored, whereas `runTest` will report them. + +Of all the methods supported by `TestCoroutineScope`, only `cleanupTestCoroutines` is not provided on `TestScope`, +and its usages should have been removed during the previous step. + +## Replace `runBlocking` with `runTest` + +Now that `runTest` works properly with asynchronous completions, `runBlocking` is only occasionally useful. +As is, most uses of `runBlocking` in tests come from the need to interact with dispatchers that execute on other +threads, like `Dispatchers.IO` or `Dispatchers.Default`. + +## Replace `TestCoroutineDispatcher` with `UnconfinedTestDispatcher` and `StandardTestDispatcher` + +`TestCoroutineDispatcher` is a dispatcher with two modes: +* ("unpaused") Almost (but not quite) unconfined, with the ability to eagerly enter `launch` and `async` blocks. +* ("paused") Behaving like a `StandardTestDispatcher`. + +In one of the earlier steps, we replaced `pauseDispatcher` with `StandardTestDispatcher` usage, and replaced the +implicit `TestCoroutineScope` dispatcher in `runBlockingTest` with `UnconfinedTestDispatcher` during migration to +`runTest`. + +Now, the rest of the usages should be replaced with whichever dispatcher is most appropriate. + +## Simplify code by removing unneeded entities + +Likely, now some code has the form + +```kotlin +val dispatcher = StandardTestDispatcher() +val scope = TestScope(dispatcher) + +@BeforeTest +fun setUp() { + Dispatchers.setMain(dispatcher) +} + +@AfterTest +fun tearDown() { + Dispatchers.resetMain() +} + +@Test +fun testFoo() = scope.runTest { + // ... +} +``` + +The point of this pattern is to ensure that the test runs with the same `TestCoroutineScheduler` as the one used for +`Dispatchers.Main`. + +However, now this can be simplified to just + +```kotlin +@BeforeTest +fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) +} + +@AfterTest +fun tearDown() { + Dispatchers.resetMain() +} + +@Test +fun testFoo() = runTest { + // ... +} +``` + +The reason this works is that all entities that depend on `TestCoroutineScheduler` will attempt to acquire one from +the current `Dispatchers.Main`. \ No newline at end of file diff --git a/kotlinx-coroutines-test/README.md b/kotlinx-coroutines-test/README.md index 5130da1167..54450b1e82 100644 --- a/kotlinx-coroutines-test/README.md +++ b/kotlinx-coroutines-test/README.md @@ -2,7 +2,24 @@ Test utilities for `kotlinx.coroutines`. -This package provides testing utilities for effectively testing coroutines. +## Overview + +This package provides utilities for efficiently testing coroutines. + +| Name | Description | +| ---- | ----------- | +| [runTest] | Runs the test code, automatically skipping delays and handling uncaught exceptions. | +| [TestCoroutineScheduler] | The shared source of virtual time, used for controlling execution order and skipping delays. | +| [TestScope] | A [CoroutineScope] that integrates with [runTest], providing access to [TestCoroutineScheduler]. | +| [TestDispatcher] | A [CoroutineDispatcher] that whose delays are controlled by a [TestCoroutineScheduler]. | +| [Dispatchers.setMain] | Mocks the main dispatcher using the provided one. If mocked with a [TestDispatcher], its [TestCoroutineScheduler] is used everywhere by default. | + +Provided [TestDispatcher] implementations: + +| Name | Description | +| ---- | ----------- | +| [StandardTestDispatcher] | A simple dispatcher with no special behavior other than being linked to a [TestCoroutineScheduler]. | +| [UnconfinedTestDispatcher] | A dispatcher that behaves like [Dispatchers.Unconfined]. | ## Using in your project @@ -13,24 +30,26 @@ dependencies { } ``` -**Do not** depend on this project in your main sources, all utilities are intended and designed to be used only from tests. +**Do not** depend on this project in your main sources, all utilities here are intended and designed to be used only from tests. ## Dispatchers.Main Delegation -`Dispatchers.setMain` will override the `Main` dispatcher in test situations. This is helpful when you want to execute a -test in situations where the platform `Main` dispatcher is not available, or you wish to replace `Dispatchers.Main` with a -testing dispatcher. +`Dispatchers.setMain` will override the `Main` dispatcher in test scenarios. +This is helpful when one wants to execute a test in situations where the platform `Main` dispatcher is not available, +or to replace `Dispatchers.Main` with a testing dispatcher. -Once you have this dependency in the runtime, -[`ServiceLoader`](https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html) mechanism will overwrite -[Dispatchers.Main] with a testable implementation. +On the JVM, +the [`ServiceLoader`](https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html) mechanism is responsible +for overwriting [Dispatchers.Main] with a testable implementation, which by default will delegate its calls to the real +`Main` dispatcher, if any. -You can override the `Main` implementation using [setMain][setMain] method with any [CoroutineDispatcher] implementation, e.g.: +The `Main` implementation can be overridden using [Dispatchers.setMain][setMain] method with any [CoroutineDispatcher] +implementation, e.g.: ```kotlin class SomeTest { - + private val mainThreadSurrogate = newSingleThreadContext("UI thread") @Before @@ -40,10 +59,10 @@ class SomeTest { @After fun tearDown() { - Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher + Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher mainThreadSurrogate.close() } - + @Test fun testSomeUI() = runBlocking { launch(Dispatchers.Main) { // Will be launched in the mainThreadSurrogate dispatcher @@ -52,372 +71,289 @@ class SomeTest { } } ``` -Calling `setMain` or `resetMain` immediately changes the `Main` dispatcher globally. The testable version of -`Dispatchers.Main` installed by the `ServiceLoader` will delegate to the dispatcher provided by `setMain`. -## runBlockingTest +Calling `setMain` or `resetMain` immediately changes the `Main` dispatcher globally. -To test regular suspend functions or coroutines started with `launch` or `async` use the [runBlockingTest] coroutine -builder that provides extra test control to coroutines. +If `Main` is overridden with a [TestDispatcher], then its [TestCoroutineScheduler] is used when new [TestDispatcher] or +[TestScope] instances are created without [TestCoroutineScheduler] being passed as an argument. -1. Auto-advancing of time for regular suspend functions -2. Explicit time control for testing multiple coroutines -3. Eager execution of `launch` or `async` code blocks -4. Pause, manually advance, and restart the execution of coroutines in a test -5. Report uncaught exceptions as test failures +## runTest -### Testing regular suspend functions +[runTest] is the way to test code that involves coroutines. `suspend` functions can be called inside it. -To test regular suspend functions, which may have a delay, you can use the [runBlockingTest] builder to start a testing -coroutine. Any calls to `delay` will automatically advance virtual time by the amount delayed. +**IMPORTANT: in order to work with on Kotlin/JS, the result of `runTest` must be immediately `return`-ed from each test.** +The typical invocation of [runTest] thus looks like this: ```kotlin @Test -fun testFoo() = runBlockingTest { // a coroutine with an extra test control - val actual = foo() - // ... +fun testFoo() = runTest { + // code under test } +``` -suspend fun foo() { - delay(1_000) // auto-advances virtual time by 1_000ms due to runBlockingTest - // ... +In more advanced scenarios, it's possible instead to use the following form: +```kotlin +@Test +fun testFoo(): TestResult { + // initialize some test state + return runTest { + // code under test + } } ``` -`runBlockingTest` returns `Unit` so it may be used in a single expression with common testing libraries. +[runTest] is similar to running the code with `runBlocking` on Kotlin/JVM and Kotlin/Native, or launching a new promise +on Kotlin/JS. The main differences are the following: -### Testing `launch` or `async` +* **The calls to `delay` are automatically skipped**, preserving the relative execution order of the tasks. This way, + it's possible to make tests finish more-or-less immediately. +* **Controlling the virtual time**: in case just skipping delays is not sufficient, it's possible to more carefully + guide the execution, advancing the virtual time by a duration, draining the queue of the awaiting tasks, or running + the tasks scheduled at the present moment. +* **Handling uncaught exceptions** spawned in the child coroutines by throwing them at the end of the test. +* **Waiting for asynchronous callbacks**. + Sometimes, especially when working with third-party code, it's impossible to mock all the dispatchers in use. + [runTest] will handle the situations where some code runs in dispatchers not integrated with the test module. -Inside of [runBlockingTest], both [launch] and [async] will start a new coroutine that may run concurrently with the -test case. +## Delay-skipping -To make common testing situations easier, by default the body of the coroutine is executed *eagerly* until -the first call to [delay] or [yield]. +To test regular suspend functions, which may have a delay, just run them inside the [runTest] block. ```kotlin @Test -fun testFooWithLaunch() = runBlockingTest { - foo() - // the coroutine launched by foo() is completed before foo() returns +fun testFoo() = runTest { // a coroutine with an extra test control + val actual = foo() // ... } -fun CoroutineScope.foo() { - // This coroutines `Job` is not shared with the test code - launch { - bar() // executes eagerly when foo() is called due to runBlockingTest - println(1) // executes eagerly when foo() is called due to runBlockingTest - } +suspend fun foo() { + delay(1_000) // when run in `runTest`, will finish immediately instead of delaying + // ... } - -suspend fun bar() {} ``` -`runBlockingTest` will auto-progress virtual time until all coroutines are completed before returning. If any coroutines -are not able to complete, an `AssertionError` will be thrown. - -*Note:* The default eager behavior of [runBlockingTest] will ignore [CoroutineStart] parameters. - -### Testing `launch` or `async` with `delay` +## `launch` and `async` -If the coroutine created by `launch` or `async` calls `delay` then the [runBlockingTest] will not auto-progress time -right away. This allows tests to observe the interaction of multiple coroutines with different delays. +The coroutine dispatcher used for tests is single-threaded, meaning that the child coroutines of the [runTest] block +will run on the thread that started the test, and will never run in parallel. -To control time in the test you can use the [DelayController] interface. The block passed to -[runBlockingTest] can call any method on the `DelayController` interface. +If several coroutines are waiting to be executed next, the one scheduled after the smallest delay will be chosen. +The virtual time will automatically advance to the point of its resumption. ```kotlin @Test -fun testFooWithLaunchAndDelay() = runBlockingTest { - foo() - // the coroutine launched by foo has not completed here, it is suspended waiting for delay(1_000) - advanceTimeBy(1_000) // progress time, this will cause the delay to resume - // the coroutine launched by foo has completed here - // ... -} - -suspend fun CoroutineScope.foo() { +fun testWithMultipleDelays() = runTest { launch { - println(1) // executes eagerly when foo() is called due to runBlockingTest - delay(1_000) // suspends until time is advanced by at least 1_000 - println(2) // executes after advanceTimeBy(1_000) + delay(1_000) + println("1. $currentTime") // 1000 + delay(200) + println("2. $currentTime") // 1200 + delay(2_000) + println("4. $currentTime") // 3200 + } + val deferred = async { + delay(3_000) + println("3. $currentTime") // 3000 + delay(500) + println("5. $currentTime") // 3500 } + deferred.await() } ``` -*Note:* `runBlockingTest` will always attempt to auto-progress time until all coroutines are completed just before -exiting. This is a convenience to avoid having to call [advanceUntilIdle][DelayController.advanceUntilIdle] -as the last line of many common test cases. -If any coroutines cannot complete by advancing time, an `AssertionError` is thrown. +## Controlling the virtual time -### Testing `withTimeout` using `runBlockingTest` - -Time control can be used to test timeout code. To do so, ensure that the function under test is suspended inside a -`withTimeout` block and advance time until the timeout is triggered. - -Depending on the code, causing the code to suspend may need to use different mocking or fake techniques. For this -example an uncompleted `Deferred` is provided to the function under test via parameter injection. +Inside [runTest], the following operations are supported: +* `currentTime` gets the current virtual time. +* `runCurrent()` runs the tasks that are scheduled at this point of virtual time. +* `advanceUntilIdle()` runs all enqueued tasks until there are no more. +* `advanceTimeBy(timeDelta)` runs the enqueued tasks until the current virtual time advances by `timeDelta`. ```kotlin -@Test(expected = TimeoutCancellationException::class) -fun testFooWithTimeout() = runBlockingTest { - val uncompleted = CompletableDeferred() // this Deferred will never complete - foo(uncompleted) - advanceTimeBy(1_000) // advance time, which will cause the timeout to throw an exception - // ... -} - -fun CoroutineScope.foo(resultDeferred: Deferred) { +@Test +fun testFoo() = runTest { launch { - withTimeout(1_000) { - resultDeferred.await() // await() will suspend forever waiting for uncompleted - // ... - } + println(1) // executes during runCurrent() + delay(1_000) // suspends until time is advanced by at least 1_000 + println(2) // executes during advanceTimeBy(2_000) + delay(500) // suspends until the time is advanced by another 500 ms + println(3) // also executes during advanceTimeBy(2_000) + delay(5_000) // will suspend by another 4_500 ms + println(4) // executes during advanceUntilIdle() } + // the child coroutine has not run yet + runCurrent() + // the child coroutine has called println(1), and is suspended on delay(1_000) + advanceTimeBy(2_000) // progress time, this will cause two calls to `delay` to resume + // the child coroutine has called println(2) and println(3) and suspends for another 4_500 virtual milliseconds + advanceUntilIdle() // will run the child coroutine to completion + assertEquals(6500, currentTime) // the child coroutine finished at virtual time of 6_500 milliseconds } ``` -*Note:* Testing timeouts is simpler with a second coroutine that can be suspended (as in this example). If the -call to `withTimeout` is in a regular suspend function, consider calling `launch` or `async` inside your test body to -create a second coroutine. - -### Using `pauseDispatcher` for explicit execution of `runBlockingTest` +## Using multiple test dispatchers -The eager execution of `launch` and `async` bodies makes many tests easier, but some tests need more fine grained -control of coroutine execution. +The virtual time is controlled by an entity called the [TestCoroutineScheduler], which behaves as the shared source of +virtual time. -To disable eager execution, you can call [pauseDispatcher][DelayController.pauseDispatcher] -to pause the [TestCoroutineDispatcher] that [runBlockingTest] uses. +Several dispatchers can be created that use the same [TestCoroutineScheduler], in which case they will share their +knowledge of the virtual time. -When the dispatcher is paused, all coroutines will be added to a queue instead running. In addition, time will never -auto-progress due to `delay` on a paused dispatcher. +To access the scheduler used for this test, use the [TestScope.testScheduler] property. ```kotlin @Test -fun testFooWithPauseDispatcher() = runBlockingTest { - pauseDispatcher { - foo() - // the coroutine started by foo has not run yet - runCurrent() // the coroutine started by foo advances to delay(1_000) - // the coroutine started by foo has called println(1), and is suspended on delay(1_000) - advanceTimeBy(1_000) // progress time, this will cause the delay to resume - // the coroutine started by foo has called println(2) and has completed here - } - // ... -} - -fun CoroutineScope.foo() { - launch { - println(1) // executes after runCurrent() is called - delay(1_000) // suspends until time is advanced by at least 1_000 - println(2) // executes after advanceTimeBy(1_000) +fun testWithMultipleDispatchers() = runTest { + val scheduler = testScheduler // the scheduler used for this test + val dispatcher1 = StandardTestDispatcher(scheduler, name = "IO dispatcher") + val dispatcher2 = StandardTestDispatcher(scheduler, name = "Background dispatcher") + launch(dispatcher1) { + delay(1_000) + println("1. $currentTime") // 1000 + delay(200) + println("2. $currentTime") // 1200 + delay(2_000) + println("4. $currentTime") // 3200 + } + val deferred = async(dispatcher2) { + delay(3_000) + println("3. $currentTime") // 3000 + delay(500) + println("5. $currentTime") // 3500 + } + deferred.await() } -} ``` -Using `pauseDispatcher` gives tests explicit control over the progress of time as well as the ability to enqueue all -coroutines. As a best practice consider adding two tests, one paused and one eager, to test coroutines that have -non-trivial external dependencies and side effects in their launch body. - -*Important:* When passed a lambda block, `pauseDispatcher` will resume eager execution immediately after the block. -This will cause time to auto-progress if there are any outstanding `delay` calls that were not resolved before the -`pauseDispatcher` block returned. In advanced situations tests can call [pauseDispatcher][DelayController.pauseDispatcher] -without a lambda block and then explicitly resume the dispatcher with [resumeDispatcher][DelayController.resumeDispatcher]. +**Note: if [Dispatchers.Main] is replaced by a [TestDispatcher], [runTest] will automatically use its scheduler. +This is done so that there is no need to go through the ceremony of passing the correct scheduler to [runTest].** -## Integrating tests with structured concurrency +## Accessing the test coroutine scope -Code that uses structured concurrency needs a [CoroutineScope] in order to launch a coroutine. In order to integrate -[runBlockingTest] with code that uses common structured concurrency patterns tests can provide one (or both) of these -classes to application code. +Structured concurrency ties coroutines to scopes in which they are launched. +[TestScope] is a special coroutine scope designed for testing coroutines, and a new one is automatically created +for [runTest] and used as the receiver for the test body. - | Name | Description | - | ---- | ----------- | - | [TestCoroutineScope] | A [CoroutineScope] which provides detailed control over the execution of coroutines for tests and integrates with [runBlockingTest]. | - | [TestCoroutineDispatcher] | A [CoroutineDispatcher] which can be used for tests and integrates with [runBlockingTest]. | - - Both classes are provided to allow for various testing needs. Depending on the code that's being - tested, it may be easier to provide a [TestCoroutineDispatcher]. For example [Dispatchers.setMain][setMain] will accept - a [TestCoroutineDispatcher] but not a [TestCoroutineScope]. - - [TestCoroutineScope] will always use a [TestCoroutineDispatcher] to execute coroutines. It - also uses [TestCoroutineExceptionHandler] to convert uncaught exceptions into test failures. +However, it can be convenient to access a `CoroutineScope` before the test has started, for example, to perform mocking +of some +parts of the system in `@BeforeTest` via dependency injection. +In these cases, it is possible to manually create [TestScope], the scope for the test coroutines, in advance, +before the test begins. -By providing [TestCoroutineScope] a test case is able to control execution of coroutines, as well as ensure that -uncaught exceptions thrown by coroutines are converted into test failures. +[TestScope] on its own does not automatically run the code launched in it. +In addition, it is stateful in order to keep track of executing coroutines and uncaught exceptions. +Therefore, it is important to ensure that [TestScope.runTest] is called eventually. -### Providing `TestCoroutineScope` from `runBlockingTest` +```kotlin +val scope = TestScope() -In simple cases, tests can use the [TestCoroutineScope] created by [runBlockingTest] directly. +@BeforeTest +fun setUp() { + Dispatchers.setMain(StandardTestDispatcher(scope.testScheduler)) + TestSubject.setScope(scope) +} -```kotlin -@Test -fun testFoo() = runBlockingTest { - foo() // runBlockingTest passed in a TestCoroutineScope as this +@AfterTest +fun tearDown() { + Dispatchers.resetMain() + TestSubject.resetScope() } -fun CoroutineScope.foo() { - launch { // CoroutineScope for launch is the TestCoroutineScope provided by runBlockingTest - // ... - } +@Test +fun testSubject() = scope.runTest { + // the receiver here is `testScope` } ``` -This style is preferred when the `CoroutineScope` is passed through an extension function style. - -### Providing an explicit `TestCoroutineScope` - -In many cases, the direct style is not preferred because [CoroutineScope] may need to be provided through another means -such as dependency injection or service locators. +## Eagerly entering `launch` and `async` blocks -Tests can declare a [TestCoroutineScope] explicitly in the class to support these use cases. +Some tests only test functionality and don't particularly care about the precise order in which coroutines are +dispatched. +In these cases, it can be cumbersome to always call [runCurrent] or [yield] to observe the effects of the coroutines +after they are launched. -Since [TestCoroutineScope] is stateful in order to keep track of executing coroutines and uncaught exceptions, it is -important to ensure that [cleanupTestCoroutines][TestCoroutineScope.cleanupTestCoroutines] is called after every test case. +If [runTest] executes with an [UnconfinedTestDispatcher], the child coroutines launched at the top level are entered +*eagerly*, that is, they don't go through a dispatch until the first suspension. ```kotlin -class TestClass { - private val testScope = TestCoroutineScope() - private lateinit var subject: Subject - - @Before - fun setup() { - // provide the scope explicitly, in this example using a constructor parameter - subject = Subject(testScope) - } - - @After - fun cleanUp() { - testScope.cleanupTestCoroutines() - } - - @Test - fun testFoo() = testScope.runBlockingTest { - // TestCoroutineScope.runBlockingTest uses the Dispatcher and exception handler provided by `testScope` - subject.foo() - } -} - -class Subject(val scope: CoroutineScope) { - fun foo() { - scope.launch { - // launch uses the testScope injected in setup - } +@Test +fun testEagerlyEnteringChildCoroutines() = runTest(UnconfinedTestDispatcher()) { + var entered = false + val deferred = CompletableDeferred() + var completed = false + launch { + entered = true + deferred.await() + completed = true } + assertTrue(entered) // `entered = true` already executed. + assertFalse(completed) // however, the child coroutine then suspended, so it is enqueued. + deferred.complete(Unit) // resume the coroutine. + assertTrue(completed) // now the child coroutine is immediately completed. } ``` -*Note:* [TestCoroutineScope], [TestCoroutineDispatcher], and [TestCoroutineExceptionHandler] are interfaces to enable -test libraries to provide library specific integrations. For example, a JUnit4 `@Rule` may call -[Dispatchers.setMain][setMain] then expose [TestCoroutineScope] for use in tests. - -### Providing an explicit `TestCoroutineDispatcher` - -While providing a [TestCoroutineScope] is slightly preferred due to the improved uncaught exception handling, there are -many situations where it is easier to provide a [TestCoroutineDispatcher]. For example [Dispatchers.setMain][setMain] -does not accept a [TestCoroutineScope] and requires a [TestCoroutineDispatcher] to control coroutine execution in -tests. - -The main difference between `TestCoroutineScope` and `TestCoroutineDispatcher` is how uncaught exceptions are handled. -When using `TestCoroutineDispatcher` uncaught exceptions thrown in coroutines will use regular -[coroutine exception handling](https://kotlinlang.org/docs/reference/coroutines/exception-handling.html). -`TestCoroutineScope` will always use `TestCoroutineDispatcher` as it's dispatcher. - -A test can use a `TestCoroutineDispatcher` without declaring an explicit `TestCoroutineScope`. This is preferred -when the class under test allows a test to provide a [CoroutineDispatcher] but does not allow the test to provide a -[CoroutineScope]. - -Since [TestCoroutineDispatcher] is stateful in order to keep track of executing coroutines, it is -important to ensure that [cleanupTestCoroutines][DelayController.cleanupTestCoroutines] is called after every test case. +If this behavior is desirable, but some parts of the test still require accurate dispatching, for example, to ensure +that the code executes on the correct thread, then simply `launch` a new coroutine with the [StandardTestDispatcher]. ```kotlin -class TestClass { - private val testDispatcher = TestCoroutineDispatcher() - - @Before - fun setup() { - // provide the scope explicitly, in this example using a constructor parameter - Dispatchers.setMain(testDispatcher) - } - - @After - fun cleanUp() { - Dispatchers.resetMain() - testDispatcher.cleanupTestCoroutines() - } - - @Test - fun testFoo() = testDispatcher.runBlockingTest { - // TestCoroutineDispatcher.runBlockingTest uses `testDispatcher` to run coroutines - foo() +@Test +fun testEagerlyEnteringSomeChildCoroutines() = runTest(UnconfinedTestDispatcher()) { + var entered1 = false + launch { + entered1 = true } -} + assertTrue(entered1) // `entered1 = true` already executed -fun foo() { - MainScope().launch { - // launch will use the testDispatcher provided by setMain + var entered2 = false + launch(StandardTestDispatcher(testScheduler)) { + // this block and every coroutine launched inside it will explicitly go through the needed dispatches + entered2 = true } + assertFalse(entered2) + runCurrent() // need to explicitly run the dispatched continuation + assertTrue(entered2) } ``` -*Note:* Prefer to provide `TestCoroutineScope` when it does not complicate code since it will also elevate exceptions -to test failures. However, exposing a `CoroutineScope` to callers of a function may lead to complicated code, in which -case this is the preferred pattern. - -### Using `TestCoroutineScope` and `TestCoroutineDispatcher` without `runBlockingTest` +### Using `withTimeout` inside `runTest` -It is supported to use both [TestCoroutineScope] and [TestCoroutineDispatcher] without using the [runBlockingTest] -builder. Tests may need to do this in situations such as introducing multiple dispatchers and library writers may do -this to provide alternatives to `runBlockingTest`. +Timeouts are also susceptible to time control, so the code below will immediately finish. ```kotlin @Test -fun testFooWithAutoProgress() { - val scope = TestCoroutineScope() - scope.foo() - // foo is suspended waiting for time to progress - scope.advanceUntilIdle() - // foo's coroutine will be completed before here -} - -fun CoroutineScope.foo() { - launch { - println(1) // executes eagerly when foo() is called due to TestCoroutineScope - delay(1_000) // suspends until time is advanced by at least 1_000 - println(2) // executes after advanceTimeUntilIdle +fun testFooWithTimeout() = runTest { + assertFailsWith { + withTimeout(1_000) { + delay(999) + delay(2) + println("this won't be reached") + } } -} +} ``` -## Using time control with `withContext` - -Calls to `withContext(Dispatchers.IO)` or `withContext(Dispatchers.Default)` are common in coroutines based codebases. -Both dispatchers are not designed to interact with `TestCoroutineDispatcher`. +## Virtual time support with other dispatchers -Tests should provide a `TestCoroutineDispatcher` to replace these dispatchers if the `withContext` calls `delay` in the -function under test. For example, a test that calls `veryExpensiveOne` should provide a `TestCoroutineDispatcher` using -either dependency injection, a service locator, or a default parameter. +Calls to `withContext(Dispatchers.IO)`, `withContext(Dispatchers.Default)` ,and `withContext(Dispatchers.Main)` are +common in coroutines-based code bases. Unfortunately, just executing code in a test will not lead to these dispatchers +using the virtual time source, so delays will not be skipped in them. ```kotlin -suspend fun veryExpensiveOne() = withContext(Dispatchers.Default) { +suspend fun veryExpensiveFunction() = withContext(Dispatchers.Default) { delay(1_000) - 1 // for very expensive values of 1 + 1 } -``` - -In situations where the code inside the `withContext` is very simple, it is not as important to provide a test -dispatcher. The function `veryExpensiveTwo` will behave identically in a `TestCoroutineDispatcher` and -`Dispatchers.Default` after the thread switch for `Dispatchers.Default`. Because `withContext` always returns a value by -directly, there is no need to inject a `TestCoroutineDispatcher` into this function. -```kotlin -suspend fun veryExpensiveTwo() = withContext(Dispatchers.Default) { - 2 // for very expensive values of 2 +fun testExpensiveFunction() = runTest { + val result = veryExpensiveFunction() // will take a whole real-time second to execute + // the virtual time at this point is still 0 } ``` -Tests should provide a `TestCoroutineDispatcher` to code that calls `withContext` to provide time control for -delays, or when execution control is needed to test complex logic. - +Tests should, when possible, replace these dispatchers with a [TestDispatcher] if the `withContext` calls `delay` in the +function under test. For example, `veryExpensiveFunction` above should allow mocking with a [TestDispatcher] using +either dependency injection, a service locator, or a default parameter, if it is to be used with virtual time. ### Status of the API @@ -426,35 +362,32 @@ This API is experimental and it is may change before migrating out of experiment Changes during experimental may have deprecation applied when possible, but it is not advised to use the API in stable code before it leaves experimental due to possible breaking changes. -If you have any suggestions for improvements to this experimental API please share them them on the +If you have any suggestions for improvements to this experimental API please share them them on the [issue tracker](https://github.com/Kotlin/kotlinx.coroutines/issues). -[Dispatchers.Main]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html +[CoroutineScope]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/index.html [CoroutineDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-dispatcher/index.html -[launch]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/launch.html -[async]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html -[delay]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/delay.html +[Dispatchers.Unconfined]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-unconfined.html +[Dispatchers.Main]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-main.html [yield]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/yield.html -[CoroutineStart]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-start/index.html -[CoroutineScope]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/index.html [ExperimentalCoroutinesApi]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-experimental-coroutines-api/index.html +[runTest]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html +[TestCoroutineScheduler]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scheduler/index.html +[TestScope]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-scope/index.html +[TestDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-dispatcher/index.html +[Dispatchers.setMain]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/set-main.html +[StandardTestDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-standard-test-dispatcher.html +[UnconfinedTestDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-unconfined-test-dispatcher.html [setMain]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/set-main.html -[runBlockingTest]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-blocking-test.html -[DelayController]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/index.html -[DelayController.advanceUntilIdle]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/advance-until-idle.html -[DelayController.pauseDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/pause-dispatcher.html -[TestCoroutineDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-dispatcher/index.html -[DelayController.resumeDispatcher]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/resume-dispatcher.html -[TestCoroutineScope]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scope/index.html -[TestCoroutineExceptionHandler]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-exception-handler/index.html -[TestCoroutineScope.cleanupTestCoroutines]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scope/cleanup-test-coroutines.html -[DelayController.cleanupTestCoroutines]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/cleanup-test-coroutines.html +[TestScope.testScheduler]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-scope/test-scheduler.html +[TestScope.runTest]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html +[runCurrent]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-current.html diff --git a/kotlinx-coroutines-test/common/src/TestDispatcher.kt b/kotlinx-coroutines-test/common/src/TestDispatcher.kt index c01e5b4d7b..3b756b19e9 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatcher.kt @@ -12,7 +12,7 @@ import kotlin.jvm.* * A test dispatcher that can interface with a [TestCoroutineScheduler]. */ @ExperimentalCoroutinesApi -public sealed class TestDispatcher: CoroutineDispatcher(), Delay { +public abstract class TestDispatcher internal constructor(): CoroutineDispatcher(), Delay { /** The scheduler that this dispatcher is linked to. */ @ExperimentalCoroutinesApi public abstract val scheduler: TestCoroutineScheduler diff --git a/kotlinx-coroutines-test/common/src/TestDispatchers.kt b/kotlinx-coroutines-test/common/src/TestDispatchers.kt index 8e70050ebb..15b4dade84 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatchers.kt @@ -13,6 +13,9 @@ import kotlin.jvm.* * Sets the given [dispatcher] as an underlying dispatcher of [Dispatchers.Main]. * All subsequent usages of [Dispatchers.Main] will use the given [dispatcher] under the hood. * + * Using [TestDispatcher] as an argument has special behavior: subsequently-called [runTest], as well as + * [TestScope] and test dispatcher constructors, will use the [TestCoroutineScheduler] of the provided dispatcher. + * * It is unsafe to call this method if alive coroutines launched in [Dispatchers.Main] exist. */ @ExperimentalCoroutinesApi diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index b48b273cd9..ffd5c01f7a 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -228,4 +228,10 @@ internal fun TestScope.asSpecificImplementation(): TestScopeImpl = when (this) { internal class UncaughtExceptionsBeforeTest : IllegalStateException( "There were uncaught exceptions in coroutines launched from TestScope before the test started. Please avoid this," + " as such exceptions are also reported in a platform-dependent manner so that they are not lost." -) \ No newline at end of file +) + +/** + * Thrown when a test has completed and there are tasks that are not completed or cancelled. + */ +@ExperimentalCoroutinesApi +internal class UncompletedCoroutinesError(message: String) : AssertionError(message) \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index d3e4294a1a..203ddc4f11 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -311,8 +311,6 @@ class TestCoroutineSchedulerTest { private fun forTestDispatchers(block: (TestDispatcher) -> Unit): Unit = @Suppress("DEPRECATION") listOf( - TestCoroutineDispatcher(), - TestCoroutineDispatcher().also { it.pauseDispatcher() }, StandardTestDispatcher(), UnconfinedTestDispatcher() ).forEach { diff --git a/kotlinx-coroutines-test/common/src/migration/DelayController.kt b/kotlinx-coroutines-test/jvm/src/migration/DelayController.kt similarity index 92% rename from kotlinx-coroutines-test/common/src/migration/DelayController.kt rename to kotlinx-coroutines-test/jvm/src/migration/DelayController.kt index 62c2167177..e0701ae2cd 100644 --- a/kotlinx-coroutines-test/common/src/migration/DelayController.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/DelayController.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.* "Use `TestCoroutineScheduler` to control virtual time.", level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public interface DelayController { /** * Returns the current virtual clock-time as it is known to this Dispatcher. @@ -106,6 +107,7 @@ public interface DelayController { "Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.", level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public suspend fun pauseDispatcher(block: suspend () -> Unit) /** @@ -118,6 +120,7 @@ public interface DelayController { "Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.", level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun pauseDispatcher() /** @@ -131,6 +134,7 @@ public interface DelayController { "Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.", level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun resumeDispatcher() } @@ -143,6 +147,7 @@ internal interface SchedulerAsDelayController : DelayController { ReplaceWith("this.scheduler.currentTime"), level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 override val currentTime: Long get() = scheduler.currentTime @@ -153,6 +158,7 @@ internal interface SchedulerAsDelayController : DelayController { ReplaceWith("this.scheduler.apply { advanceTimeBy(delayTimeMillis); runCurrent() }"), level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 override fun advanceTimeBy(delayTimeMillis: Long): Long { val oldTime = scheduler.currentTime scheduler.advanceTimeBy(delayTimeMillis) @@ -166,6 +172,7 @@ internal interface SchedulerAsDelayController : DelayController { ReplaceWith("this.scheduler.advanceUntilIdle()"), level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 override fun advanceUntilIdle(): Long { val oldTime = scheduler.currentTime scheduler.advanceUntilIdle() @@ -178,6 +185,7 @@ internal interface SchedulerAsDelayController : DelayController { ReplaceWith("this.scheduler.runCurrent()"), level = DeprecationLevel.WARNING ) + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 override fun runCurrent(): Unit = scheduler.runCurrent() /** @suppress */ diff --git a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt similarity index 93% rename from kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt rename to kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt index 68398fb424..4524bf2867 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt @@ -47,6 +47,7 @@ import kotlin.jvm.* * @param testBody The code of the unit-test. */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", 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, testBody: suspend TestCoroutineScope.() -> Unit @@ -67,6 +68,7 @@ public fun runBlockingTest( * A version of [runBlockingTest] that works with [TestScope]. */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun runBlockingTestOnTestScope( context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestScope.() -> Unit @@ -102,6 +104,7 @@ public fun runBlockingTestOnTestScope( * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", 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) @@ -109,6 +112,7 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope. * Convenience method for calling [runBlockingTestOnTestScope] on an existing [TestScope]. */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun TestScope.runBlockingTest(block: suspend TestScope.() -> Unit): Unit = runBlockingTestOnTestScope(coroutineContext, block) @@ -116,6 +120,7 @@ public fun TestScope.runBlockingTest(block: suspend TestScope.() -> Unit): Unit * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", 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) @@ -124,6 +129,7 @@ public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineS */ @ExperimentalCoroutinesApi @Deprecated("Use `runTest` instead.", level = DeprecationLevel.WARNING) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun runTestWithLegacyScope( context: CoroutineContext = EmptyCoroutineContext, dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, @@ -157,6 +163,7 @@ public fun runTestWithLegacyScope( */ @ExperimentalCoroutinesApi @Deprecated("Use `TestScope.runTest` instead.", level = DeprecationLevel.WARNING) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun TestCoroutineScope.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, block: suspend TestCoroutineScope.() -> Unit diff --git a/kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineDispatcher.kt similarity index 97% rename from kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt rename to kotlinx-coroutines-test/jvm/src/migration/TestCoroutineDispatcher.kt index 31249ee6e4..ec2a3046ee 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineDispatcher.kt @@ -24,6 +24,7 @@ import kotlin.coroutines.* @Deprecated("The execution order of `TestCoroutineDispatcher` can be confusing, and the mechanism of " + "pausing is typically misunderstood. Please use `StandardTestDispatcher` or `UnconfinedTestDispatcher` instead.", level = DeprecationLevel.WARNING) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public class TestCoroutineDispatcher(public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()): TestDispatcher(), Delay, SchedulerAsDelayController { diff --git a/kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt similarity index 95% rename from kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt rename to kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt index f9991496a7..9da521f05c 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineExceptionHandler.kt @@ -18,6 +18,7 @@ import kotlin.coroutines.* "please report your use case at https://github.com/Kotlin/kotlinx.coroutines/issues.", level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public interface UncaughtExceptionCaptor { /** * List of uncaught coroutine exceptions. @@ -46,6 +47,7 @@ public interface UncaughtExceptionCaptor { "It may be to define one's own `CoroutineExceptionHandler` if you just need to handle '" + "uncaught exceptions without a special `TestCoroutineScope` integration.", level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public class TestCoroutineExceptionHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler, UncaughtExceptionCaptor { private val _exceptions = mutableListOf() diff --git a/kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt similarity index 95% rename from kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt rename to kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt index 4a8b54ba69..45a3815681 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/jvm/src/migration/TestCoroutineScope.kt @@ -14,6 +14,7 @@ import kotlin.coroutines.* */ @ExperimentalCoroutinesApi @Deprecated("Use `TestScope` in combination with `runTest` instead") +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public sealed interface TestCoroutineScope : CoroutineScope { /** * Called after the test completes. @@ -35,6 +36,8 @@ public sealed interface TestCoroutineScope : CoroutineScope { * @throws IllegalStateException if called more than once. */ @ExperimentalCoroutinesApi + @Deprecated("Please call `runTest`, which automatically performs the cleanup, instead of using this function.") + // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun cleanupTestCoroutines() /** @@ -127,6 +130,7 @@ internal fun CoroutineContext.activeJobs(): Set { ), level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + TestCoroutineExceptionHandler() + context) @@ -163,6 +167,7 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) "Please use TestScope() construction instead, or just runTest(), without creating a scope.", level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { val ctxWithDispatcher = context.withDelaySkipping() var scope: TestCoroutineScopeImpl? = null @@ -222,6 +227,7 @@ public val TestCoroutineScope.currentTime: Long ReplaceWith("this.testScheduler.apply { advanceTimeBy(delayTimeMillis); runCurrent() }"), DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun TestCoroutineScope.advanceTimeBy(delayTimeMillis: Long): Unit = when (val controller = coroutineContext.delayController) { null -> { @@ -265,6 +271,7 @@ public fun TestCoroutineScope.runCurrent() { ), DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public suspend fun TestCoroutineScope.pauseDispatcher(block: suspend () -> Unit) { delayControllerForPausing.pauseDispatcher(block) } @@ -280,6 +287,7 @@ public suspend fun TestCoroutineScope.pauseDispatcher(block: suspend () -> Unit) ), level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun TestCoroutineScope.pauseDispatcher() { delayControllerForPausing.pauseDispatcher() } @@ -295,6 +303,7 @@ public fun TestCoroutineScope.pauseDispatcher() { ), level = DeprecationLevel.WARNING ) +// Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0 public fun TestCoroutineScope.resumeDispatcher() { delayControllerForPausing.resumeDispatcher() } @@ -321,9 +330,3 @@ public val TestCoroutineScope.uncaughtExceptions: List private val TestCoroutineScope.delayControllerForPausing: DelayController get() = coroutineContext.delayController ?: throw IllegalStateException("This scope isn't able to pause its dispatchers") - -/** - * Thrown when a test has completed and there are tasks that are not completed or cancelled. - */ -@ExperimentalCoroutinesApi -internal class UncompletedCoroutinesError(message: String) : AssertionError(message) diff --git a/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt b/kotlinx-coroutines-test/jvm/test/migration/RunBlockingTestOnTestScopeTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/RunBlockingTestOnTestScopeTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt b/kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt similarity index 97% rename from kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt index 3ea11139d1..a76263ddd2 100644 --- a/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt +++ b/kotlinx-coroutines-test/jvm/test/migration/RunTestLegacyScopeTest.kt @@ -79,7 +79,6 @@ class RunTestLegacyScopeTest { } @Test - @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native fun testRunTestWithSmallTimeout() = testResultMap({ fn -> assertFailsWith { fn() } }) { @@ -100,7 +99,6 @@ class RunTestLegacyScopeTest { } @Test - @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> assertFailsWith { fn() } }) { @@ -276,4 +274,4 @@ class RunTestLegacyScopeTest { } } } -} \ No newline at end of file +} diff --git a/kotlinx-coroutines-test/common/test/migration/TestBuildersTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestBuildersTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestBuildersTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestBuildersTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherOrderTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineDispatcherOrderTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherOrderTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestCoroutineDispatcherOrderTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineDispatcherTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestCoroutineDispatcherTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestCoroutineDispatcherTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/TestCoroutineExceptionHandlerTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineExceptionHandlerTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestCoroutineExceptionHandlerTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestCoroutineExceptionHandlerTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestCoroutineScopeTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestCoroutineScopeTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/TestRunBlockingOrderTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestRunBlockingOrderTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestRunBlockingOrderTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestRunBlockingOrderTest.kt diff --git a/kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt b/kotlinx-coroutines-test/jvm/test/migration/TestRunBlockingTest.kt similarity index 100% rename from kotlinx-coroutines-test/common/test/migration/TestRunBlockingTest.kt rename to kotlinx-coroutines-test/jvm/test/migration/TestRunBlockingTest.kt From 9d2a4c8115457049a59b70a855373439b102268a Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 17 Nov 2021 10:40:58 +0300 Subject: [PATCH 20/22] Prevent setting Dispatchers.Main concurrently --- .../common/src/TestCoroutineDispatchers.kt | 12 ++-- .../common/src/TestDispatchers.kt | 2 +- .../common/src/internal/TestMainDispatcher.kt | 65 ++++++++++++++++--- .../common/test/TestDispatchersTest.kt | 6 +- .../js/test/FailingTests.kt | 4 +- 5 files changed, 67 insertions(+), 22 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt index 0152c9a21b..184c673b34 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt @@ -7,7 +7,6 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.channels.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.test.internal.* import kotlinx.coroutines.test.internal.TestMainDispatcher import kotlin.coroutines.* @@ -84,7 +83,8 @@ import kotlin.coroutines.* public fun UnconfinedTestDispatcher( scheduler: TestCoroutineScheduler? = null, name: String? = null -): TestDispatcher = UnconfinedTestDispatcherImpl(scheduler ?: mainTestScheduler ?: TestCoroutineScheduler(), name) +): TestDispatcher = UnconfinedTestDispatcherImpl( + scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name) private class UnconfinedTestDispatcherImpl( override val scheduler: TestCoroutineScheduler, @@ -141,7 +141,8 @@ private class UnconfinedTestDispatcherImpl( public fun StandardTestDispatcher( scheduler: TestCoroutineScheduler? = null, name: String? = null -): TestDispatcher = StandardTestDispatcherImpl(scheduler ?: mainTestScheduler ?: TestCoroutineScheduler(), name) +): TestDispatcher = StandardTestDispatcherImpl( + scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name) private class StandardTestDispatcherImpl( override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(), @@ -154,7 +155,4 @@ private class StandardTestDispatcherImpl( } override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]" -} - -private val mainTestScheduler - get() = ((Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher)?.scheduler \ No newline at end of file +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/TestDispatchers.kt b/kotlinx-coroutines-test/common/src/TestDispatchers.kt index 15b4dade84..4454597ed7 100644 --- a/kotlinx-coroutines-test/common/src/TestDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestDispatchers.kt @@ -21,7 +21,7 @@ import kotlin.jvm.* @ExperimentalCoroutinesApi public fun Dispatchers.setMain(dispatcher: CoroutineDispatcher) { require(dispatcher !is TestMainDispatcher) { "Dispatchers.setMain(Dispatchers.Main) is prohibited, probably Dispatchers.resetMain() should be used instead" } - getTestMainDispatcher().delegate = dispatcher + getTestMainDispatcher().setDispatcher(dispatcher) } /** diff --git a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt index 3810c06536..421af53aed 100644 --- a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt @@ -3,33 +3,41 @@ */ package kotlinx.coroutines.test.internal + +import kotlinx.atomicfu.* import kotlinx.coroutines.* +import kotlinx.coroutines.test.* import kotlin.coroutines.* /** * The testable main dispatcher used by kotlinx-coroutines-test. * It is a [MainCoroutineDispatcher] that delegates all actions to a settable delegate. */ -internal class TestMainDispatcher(var delegate: CoroutineDispatcher): +internal class TestMainDispatcher(delegate: CoroutineDispatcher): MainCoroutineDispatcher(), Delay { - private val mainDispatcher = delegate // the initial value passed to the constructor + private val mainDispatcher = delegate + private var delegate = NonConcurrentlyModifiable(mainDispatcher, "Dispatchers.Main") private val delay - get() = delegate as? Delay ?: defaultDelay + get() = delegate.value as? Delay ?: defaultDelay override val immediate: MainCoroutineDispatcher - get() = (delegate as? MainCoroutineDispatcher)?.immediate ?: this + get() = (delegate.value as? MainCoroutineDispatcher)?.immediate ?: this + + override fun dispatch(context: CoroutineContext, block: Runnable) = delegate.value.dispatch(context, block) - override fun dispatch(context: CoroutineContext, block: Runnable) = delegate.dispatch(context, block) + override fun isDispatchNeeded(context: CoroutineContext): Boolean = delegate.value.isDispatchNeeded(context) - override fun isDispatchNeeded(context: CoroutineContext): Boolean = delegate.isDispatchNeeded(context) + override fun dispatchYield(context: CoroutineContext, block: Runnable) = delegate.value.dispatchYield(context, block) - override fun dispatchYield(context: CoroutineContext, block: Runnable) = delegate.dispatchYield(context, block) + fun setDispatcher(dispatcher: CoroutineDispatcher) { + delegate.value = dispatcher + } fun resetDispatcher() { - delegate = mainDispatcher + delegate.value = mainDispatcher } override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) = @@ -37,6 +45,47 @@ internal class TestMainDispatcher(var delegate: CoroutineDispatcher): override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle = delay.invokeOnTimeout(timeMillis, block, context) + + companion object { + internal val currentTestDispatcher + get() = (Dispatchers.Main as? TestMainDispatcher)?.delegate?.value as? TestDispatcher + + internal val currentTestScheduler + get() = currentTestDispatcher?.scheduler + } + + /** + * A wrapper around a value that attempts to throw when writing happens concurrently with reading. + * + * The read operations never throw. Instead, the failures detected inside them will be remembered and thrown on the + * next modification. + */ + private class NonConcurrentlyModifiable(private val initialValue: T, private val name: String) { + private val readers = atomic(0) // number of concurrent readers + private val isWriting = atomic(false) // a modification is happening currently + private val exceptionWhenReading: AtomicRef = atomic(null) // exception from reading + private val _value = atomic(initialValue) // the backing field for the value + + private fun concurrentWW() = IllegalStateException("$name is modified concurrently") + private fun concurrentRW() = IllegalStateException("$name is used concurrently with setting it") + + var value: T + get() { + readers.incrementAndGet() + if (isWriting.value) exceptionWhenReading.value = concurrentRW() + val result = _value.value + readers.decrementAndGet() + return result + } + set(value: T) { + exceptionWhenReading.getAndSet(null)?.let { throw it } + if (readers.value != 0) throw concurrentRW() + if (!isWriting.compareAndSet(expect = false, update = true)) throw concurrentWW() + _value.value = value + isWriting.value = false + if (readers.value != 0) throw concurrentRW() + } + } } @Suppress("INVISIBLE_MEMBER") diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 789744cec0..5372b9fe5e 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -24,7 +24,7 @@ class TestDispatchersTest: OrderedExecutionTestBase() { @NoJs @Test fun testMainMocking() = runTest { - val mainAtStart = mainTestDispatcher + val mainAtStart = TestMainDispatcher.currentTestDispatcher assertNotNull(mainAtStart) withContext(Dispatchers.Main) { delay(10) @@ -35,7 +35,7 @@ class TestDispatchersTest: OrderedExecutionTestBase() { withContext(Dispatchers.Main) { delay(10) } - assertSame(mainAtStart, mainTestDispatcher) + assertSame(mainAtStart, TestMainDispatcher.currentTestDispatcher) } /** Tests that the mocked [Dispatchers.Main] correctly forwards [Delay] methods. */ @@ -96,5 +96,3 @@ class TestDispatchersTest: OrderedExecutionTestBase() { } } } - -private val mainTestDispatcher get() = ((Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher) diff --git a/kotlinx-coroutines-test/js/test/FailingTests.kt b/kotlinx-coroutines-test/js/test/FailingTests.kt index 54d6aed855..4746a737fa 100644 --- a/kotlinx-coroutines-test/js/test/FailingTests.kt +++ b/kotlinx-coroutines-test/js/test/FailingTests.kt @@ -25,11 +25,11 @@ class FailingTests { @Test fun testAfterTestIsConcurrent() = runTest { try { - val mainAtStart = (Dispatchers.Main as? TestMainDispatcher)?.delegate as? TestDispatcher ?: return@runTest + val mainAtStart = TestMainDispatcher.currentTestDispatcher ?: return@runTest withContext(Dispatchers.Default) { // context switch } - assertNotSame(mainAtStart, (Dispatchers.Main as TestMainDispatcher).delegate) + assertNotSame(mainAtStart, TestMainDispatcher.currentTestDispatcher!!) } finally { assertTrue(tearDownEntered) } From af49d0057fa87836b5f8e56afc63c726e13f7861 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 17 Nov 2021 11:10:20 +0300 Subject: [PATCH 21/22] Fix build on Native --- .../common/test/StandardTestDispatcherTest.kt | 1 + kotlinx-coroutines-test/common/test/TestDispatchersTest.kt | 3 ++- kotlinx-coroutines-test/common/test/TestScopeTest.kt | 6 ++++++ .../common/test/UnconfinedTestDispatcherTest.kt | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt index e9b2e179da..d66be9bdb6 100644 --- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -64,6 +64,7 @@ class StandardTestDispatcherTest: OrderedExecutionTestBase() { /** Tests that the [TestCoroutineScheduler] used for [Dispatchers.Main] gets used by default. */ @Test + @NoNative fun testSchedulerReuse() { val dispatcher1 = StandardTestDispatcher() Dispatchers.setMain(dispatcher1) diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 5372b9fe5e..66a6c24e8f 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.test.internal.* import kotlin.coroutines.* import kotlin.test.* +@NoNative class TestDispatchersTest: OrderedExecutionTestBase() { @BeforeTest @@ -21,8 +22,8 @@ class TestDispatchersTest: OrderedExecutionTestBase() { } /** Tests that asynchronous execution of tests does not happen concurrently with [AfterTest]. */ - @NoJs @Test + @NoJs fun testMainMocking() = runTest { val mainAtStart = TestMainDispatcher.currentTestDispatcher assertNotNull(mainAtStart) diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt index 743dde3ca7..7031056f11 100644 --- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt @@ -48,6 +48,12 @@ class TestScopeTest { assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) assertSame(dispatcher, scope.coroutineContext[ContinuationInterceptor]) } + } + + /** Part of [testCreateProvidesScheduler], disabled for Native */ + @Test + @NoNative + fun testCreateReusesScheduler() { // Reuses the scheduler of `Dispatchers.Main` run { val scheduler = TestCoroutineScheduler() diff --git a/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt index 719698e843..ee63e6d118 100644 --- a/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/UnconfinedTestDispatcherTest.kt @@ -153,6 +153,7 @@ class UnconfinedTestDispatcherTest { /** Tests that the [TestCoroutineScheduler] used for [Dispatchers.Main] gets used by default. */ @Test + @NoNative fun testSchedulerReuse() { val dispatcher1 = StandardTestDispatcher() Dispatchers.setMain(dispatcher1) From 0ffeb0ecf928f42eb251a3e9c3c7d9ad4ebac70e Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 17 Nov 2021 19:02:39 +0300 Subject: [PATCH 22/22] Fixes --- kotlinx-coroutines-test/common/src/TestBuilders.kt | 2 +- .../common/src/TestCoroutineDispatchers.kt | 3 ++- .../common/src/internal/TestMainDispatcher.kt | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index bc591a35a3..e6d0c3970d 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -55,7 +55,7 @@ public expect class TestResult * immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See * [TestResult] for details on this. * - * The test is run in a single thread, unless other [ContinuationInterceptor] are used for child coroutines. + * The test is run in a single thread, unless other [CoroutineDispatcher] are used for child coroutines. * Because of this, child coroutines are not executed in parallel to the test body. * In order to for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the * test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]). diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt index 184c673b34..4cc48f47d0 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt @@ -137,6 +137,7 @@ 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, @@ -155,4 +156,4 @@ private class StandardTestDispatcherImpl( } override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]" -} \ No newline at end of file +} diff --git a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt index 421af53aed..24e093be21 100644 --- a/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/internal/TestMainDispatcher.kt @@ -60,7 +60,7 @@ internal class TestMainDispatcher(delegate: CoroutineDispatcher): * The read operations never throw. Instead, the failures detected inside them will be remembered and thrown on the * next modification. */ - private class NonConcurrentlyModifiable(private val initialValue: T, private val name: String) { + private class NonConcurrentlyModifiable(initialValue: T, private val name: String) { private val readers = atomic(0) // number of concurrent readers private val isWriting = atomic(false) // a modification is happening currently private val exceptionWhenReading: AtomicRef = atomic(null) // exception from reading @@ -77,7 +77,7 @@ internal class TestMainDispatcher(delegate: CoroutineDispatcher): readers.decrementAndGet() return result } - set(value: T) { + set(value) { exceptionWhenReading.getAndSet(null)?.let { throw it } if (readers.value != 0) throw concurrentRW() if (!isWriting.compareAndSet(expect = false, update = true)) throw concurrentWW() @@ -93,4 +93,4 @@ private val defaultDelay inline get() = DefaultDelay @Suppress("INVISIBLE_MEMBER") -internal expect fun Dispatchers.getTestMainDispatcher(): TestMainDispatcher \ No newline at end of file +internal expect fun Dispatchers.getTestMainDispatcher(): TestMainDispatcher