From 9a8842956015a018ee4fa37631052fac82553d36 Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Mon, 11 Feb 2019 15:40:52 -0800 Subject: [PATCH 01/11] Update coroutine test dispatcher to use structured concurrency. --- .../src/TestBuilders.kt | 186 +++++++++++ .../src/TestCoroutineDispatcher.kt | 284 +++++++++++++++++ .../src/TestCoroutineExceptionHandler.kt | 45 +++ .../src/TestCoroutineScope.kt | 47 +++ .../src/internal/Synchronized.kt | 11 + .../src/internal/ThreadSafeHeap.kt | 153 +++++++++ .../test/TestAsyncTest.kt | 298 ++++++++++++++++++ .../test/TestModuleHelpers.kt | 18 ++ .../test/TestRunBlockingTest.kt | 284 +++++++++++++++++ .../test/TestTestBuilders.kt | 130 ++++++++ 10 files changed, 1456 insertions(+) create mode 100644 core/kotlinx-coroutines-test/src/TestBuilders.kt create mode 100644 core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt create mode 100644 core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt create mode 100644 core/kotlinx-coroutines-test/src/TestCoroutineScope.kt create mode 100644 core/kotlinx-coroutines-test/src/internal/Synchronized.kt create mode 100644 core/kotlinx-coroutines-test/src/internal/ThreadSafeHeap.kt create mode 100644 core/kotlinx-coroutines-test/test/TestAsyncTest.kt create mode 100644 core/kotlinx-coroutines-test/test/TestModuleHelpers.kt create mode 100644 core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt create mode 100644 core/kotlinx-coroutines-test/test/TestTestBuilders.kt diff --git a/core/kotlinx-coroutines-test/src/TestBuilders.kt b/core/kotlinx-coroutines-test/src/TestBuilders.kt new file mode 100644 index 0000000000..1bc0827203 --- /dev/null +++ b/core/kotlinx-coroutines-test/src/TestBuilders.kt @@ -0,0 +1,186 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import java.util.concurrent.TimeoutException +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext + +/** + * Executes a [testBody] in a [TestCoroutineScope] which provides detailed control over the execution of coroutines. + * + * This function should be used when you need detailed control over the execution of your test. For most tests consider + * using [runBlockingTest]. + * + * Code executed in a `asyncTest` will dispatch lazily. That means calling builders such as [launch] or [async] will + * not execute the block immediately. You can use methods like [TestCoroutineScope.runCurrent] and + * [TestCoroutineScope.advanceTimeTo] on the [TestCoroutineScope]. For a full list of execution methods see + * [DelayController]. + * + * ``` + * @Test + * fun exampleTest() = asyncTest { + * // 1: launch will execute but not run the body + * launch { + * // 3: the body of launch will execute in response to runCurrent [currentTime = 0ms] + * delay(1_000) + * // 5: After the time is advanced, delay(1_000) will return [currentTime = 1000ms] + * println("Faster delays!") + * } + * + * // 2: use runCurrent() to execute the body of launch [currentTime = 0ms] + * runCurrent() + * + * // 4: advance the dispatcher "time" by 1_000, which will resume after the delay + * advanceTimeTo(1_000) + * + * ``` + * + * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test + * conditions. + * + * In addition any unhandled exceptions thrown in coroutines must be rethrown by + * [TestCoroutineScope.rethrowUncaughtCoroutineException] or cleared via [TestCoroutineScope.exceptions] inside of + * [testBody]. + * + * @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches + * (including coroutines suspended on await). + * @throws Throwable If an uncaught exception was captured by this test it will be rethrown. + * + * @param context An optional dispatcher, during [testBody] execution [DelayController.dispatchImmediately] will be set to false + * @param testBody The code of the unit-test. + * + * @see [runBlockingTest] + */ +fun asyncTest(context: CoroutineContext? = null, testBody: TestCoroutineScope.() -> Unit) { + val (safeContext, dispatcher) = context.checkArguments() + // smart cast dispatcher to expose interface + dispatcher as DelayController + val scope = TestCoroutineScope(safeContext) + + val oldDispatch = dispatcher.dispatchImmediately + dispatcher.dispatchImmediately = false + + try { + scope.testBody() + scope.cleanupTestCoroutines() + + // check for any active child jobs after cleanup (e.g. coroutines suspended on calls to await) + val job = checkNotNull(safeContext[Job]) { "Job required for asyncTest" } + val activeChildren = job.children.filter { it.isActive }.toList() + if (activeChildren.isNotEmpty()) { + throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}") + } + } finally { + dispatcher.dispatchImmediately = oldDispatch + } +} + +/** + * @see [asyncTest] + */ +fun TestCoroutineScope.asyncTest(testBody: TestCoroutineScope.() -> Unit) = + asyncTest(coroutineContext, testBody) + + +/** + * 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. + * + * Compared to [asyncTest], it provides a smaller API for tests that don't need detailed control over execution. + * + * ``` + * @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. + * + * In unhandled exceptions inside coroutines will not fail the test. + * + * @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches + * (including coroutines suspended on await). + * + * @param context An optional context, during [testBody] execution [DelayController.dispatchImmediately] will be set to true + * @param testBody The code of the unit-test. + * + * @see [asyncTest] + */ +fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCoroutineScope.() -> Unit) { + val (safeContext, dispatcher) = context.checkArguments() + // smart cast dispatcher to expose interface + dispatcher as DelayController + + val oldDispatch = dispatcher.dispatchImmediately + dispatcher.dispatchImmediately = true + val scope = TestCoroutineScope(safeContext) + try { + + val deferred = scope.async { + scope.testBody() + } + dispatcher.advanceUntilIdle() + deferred.getCompletionExceptionOrNull()?.let { + throw it + } + scope.cleanupTestCoroutines() + val activeChildren = checkNotNull(safeContext[Job]).children.filter { it.isActive }.toList() + if (activeChildren.isNotEmpty()) { + throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}") + } + } finally { + dispatcher.dispatchImmediately = oldDispatch + } +} + +/** + * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. + */ +fun TestCoroutineScope.runBlockingTest(block: suspend CoroutineScope.() -> Unit) { + runBlockingTest(coroutineContext, block) +} + +/** + * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. + * + */ +fun TestCoroutineDispatcher.runBlockingTest(block: suspend CoroutineScope.() -> Unit) { + runBlockingTest(this, block) +} + +private fun CoroutineContext?.checkArguments(): Pair { + var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher() + + val dispatcher = safeContext[ContinuationInterceptor].run { + this?.let { + require(this is DelayController) { "Dispatcher must implement DelayController" } + } + this ?: TestCoroutineDispatcher() + } + + val exceptionHandler = safeContext[CoroutineExceptionHandler].run { + this?.let { + require(this is ExceptionCaptor) { "coroutineExceptionHandler must implement ExceptionCaptor" } + } + this ?: TestCoroutineExceptionHandler() + } + + val job = safeContext[Job] ?: SupervisorJob() + + safeContext = safeContext + dispatcher + exceptionHandler + job + return Pair(safeContext, dispatcher) +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt new file mode 100644 index 0000000000..c69edac9df --- /dev/null +++ b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt @@ -0,0 +1,284 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.test.internal.ThreadSafeHeap +import kotlinx.coroutines.test.internal.ThreadSafeHeapNode +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +/** + * Control the virtual clock time of a [CoroutineDispatcher]. + * + * Testing libraries may expose this interface to tests instead of [TestCoroutineDispatcher]. + */ +interface DelayController { + /** + * Returns the current virtual clock-time as it is known to this Dispatcher. + * + * @param unit The [TimeUnit] in which the clock-time must be returned. + * @return The virtual clock-time + */ + fun currentTime(unit: TimeUnit = TimeUnit.MILLISECONDS): Long + + /** + * Moves the Dispatcher's virtual clock forward by a specified amount of time. + * + * The amount the clock is progressed may be larger than the requested delayTime if the code under test uses + * blocking coroutines. + * + * @param delayTime The amount of time to move the CoroutineContext's clock forward. + * @param unit The [TimeUnit] in which [delayTime] and the return value is expressed. + * @return The amount of delay-time that this Dispatcher's clock has been forwarded. + */ + fun advanceTimeBy(delayTime: Long, unit: TimeUnit = TimeUnit.MILLISECONDS): Long + + /** + * Moves the current virtual clock forward just far enough so the next delay will return. + * + * @return the amount of delay-time that this Dispatcher's clock has been forwarded. + */ + fun advanceTimeToNextDelayed(): Long + + /** + * Immediately execute all pending tasks and advance the virtual clock-time to the last delay. + * + * @return the amount of delay-time that this Dispatcher's clock has been forwarded. + */ + fun advanceUntilIdle(): Long + + /** + * Run any tasks that are pending at or before the current virtual clock-time. + * + * Calling this function will never advance the clock. + */ + fun runCurrent() + + /** + * Call after a test case completes. + * + * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended + * coroutines that called await. + */ + @Throws(UncompletedCoroutinesError::class) + fun cleanupTestCoroutines() + + /** + * When true, this dispatcher will perform as an immediate executor. + * + * It will immediately run any tasks, which means it will auto-advance the virtual clock-time to the last pending + * delay. + * + * Test code will rarely call this method directly., Instead use a test builder like [asyncTest], [runBlockingTest] or + * the convenience methods [TestCoroutineDispatcher.runBlocking] and [TestCoroutineScope.runBlocking]. + * + * ``` + * @Test + * fun aTest() { + * val scope = TestCoroutineScope() // dispatchImmediately is false + * scope.async { + * // delay will be pending execution (lazy mode) + * delay(1_000) + * } + * + * scope.runBlocking { + * // the pending delay will immediately execute + * // dispatchImmediately is true + * } + * + * // scope is returned to lazy mode + * // dispatchImmediately is false + * } + * ``` + * + * Setting this to true will immediately execute any pending tasks and advance the virtual clock-time to the last + * pending delay. While true, dispatch will continue to execute immediately, auto-advancing the virtual clock-time. + * + * Setting it to false will resume lazy execution. + */ + var dispatchImmediately: Boolean +} + +/** + * Thrown when a test has completed by there are tasks that are not completed or cancelled. + */ +class UncompletedCoroutinesError(message: String, cause: Throwable? = null): AssertionError(message, cause) + +/** + * [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines. + * + * By default, [TestCoroutineDispatcher] will be lazy. That means 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 + * methods on [DelayController]. + * + * When switched to immediate mode, any tasks will be immediately executed. If they were scheduled with a delay, + * the virtual clock-time will auto-advance to the last submitted delay. + * + * @see DelayController + */ +class TestCoroutineDispatcher: + CoroutineDispatcher(), + Delay, + DelayController { + + override var dispatchImmediately = false + set(value) { + field = value + if (value) { + // there may already be tasks from setup code we need to run + advanceUntilIdle() + } + } + + // The ordered queue for the runnable tasks. + private val queue = ThreadSafeHeap() + + // The per-scheduler global order counter. + private var counter = 0L + + // Storing time in nanoseconds internally. + private var time = 0L + + override fun dispatch(context: CoroutineContext, block: Runnable) { + if (dispatchImmediately) { + block.run() + } else { + post(block) + } + } + + override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { + postDelayed(CancellableContinuationRunnable(continuation) { resumeUndispatched(Unit) }, timeMillis) + if (dispatchImmediately) { +// advanceTimeBy(timeMillis, TimeUnit.MILLISECONDS) + } + } + + override fun invokeOnTimeout(timeMillis: Long, block: Runnable): DisposableHandle { + val node = postDelayed(block, timeMillis) + return object : DisposableHandle { + override fun dispose() { + queue.remove(node) + } + } + } + +// override fun processNextEvent(): Long { +// val current = queue.peek() +// if (current != null) { +// // Automatically advance time for EventLoop callbacks +// triggerActions(current.time) +// } +// return if (queue.isEmpty) Long.MAX_VALUE else 0L +// } + + override fun toString(): String = "TestCoroutineDispatcher[time=$time ns]" + + private fun post(block: Runnable) = + queue.addLast(TimedRunnable(block, counter++)) + + private fun postDelayed(block: Runnable, delayTime: Long) = + TimedRunnable(block, counter++, time + TimeUnit.MILLISECONDS.toNanos(delayTime)) + .also { + queue.addLast(it) + } + + + private fun triggerActions(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 = current.time + current.run() + } + } + + override fun currentTime(unit: TimeUnit)= + unit.convert(time, TimeUnit.NANOSECONDS) + + override fun advanceTimeBy(delayTime: Long, unit: TimeUnit): Long { + val oldTime = time + advanceTimeTo(oldTime + unit.toNanos(delayTime), TimeUnit.NANOSECONDS) + return unit.convert(time - oldTime, TimeUnit.NANOSECONDS) + } + + /** + * 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. + * @param unit The [TimeUnit] in which [targetTime] is expressed. + */ + private fun advanceTimeTo(targetTime: Long, unit: TimeUnit) { + val nanoTime = unit.toNanos(targetTime) + triggerActions(nanoTime) + if (nanoTime > time) time = nanoTime + } + + override fun advanceTimeToNextDelayed(): Long { + val oldTime = time + runCurrent() + val next = queue.peek() ?: return 0 + advanceTimeTo(next.time, TimeUnit.NANOSECONDS) + return time - oldTime + } + + override fun advanceUntilIdle(): Long { + val oldTime = time + while(!queue.isEmpty) { + advanceTimeToNextDelayed() + } + return time - oldTime + } + + override fun cleanupTestCoroutines() { + // process any pending cancellations or completions, but don't advance time + triggerActions(time) + + // 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.map { it.runnable as? CancellableContinuationRunnable<*> } + .filterNotNull() + .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.") + } + } + + override fun runCurrent() = triggerActions(time) +} + + +/** + * This class exists to allow cleanup code to avoid throwing for cancelled continuations scheduled + * in the future. + */ +private class CancellableContinuationRunnable( + val continuation: CancellableContinuation, + private val block: CancellableContinuation.() -> Unit) : Runnable { + override fun run() = continuation.block() +} + +/** + * A Runnable for our event loop that represents a task to perform at a time. + */ +private class TimedRunnable( + val runnable: Runnable, + private val count: Long = 0, + @JvmField internal 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)" +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt new file mode 100644 index 0000000000..70877fd7af --- /dev/null +++ b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt @@ -0,0 +1,45 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.CoroutineExceptionHandler +import java.util.* +import kotlin.coroutines.CoroutineContext + +/** + * Access uncaught coroutines exceptions captured during test execution. + * + * Note, tests executed via [runBlockingTest] or [TestCoroutineScope.runBlocking] will not trigger uncaught exception + * handling and should use [Deferred.await] or [Job.getCancellationException] to test exceptions. + */ +interface ExceptionCaptor { + /** + * List of uncaught coroutine exceptions. + * + * During [cleanupTestCoroutines] the first element of this list will be rethrown if it is not empty. + */ + val exceptions: MutableList + + /** + * Call after the test completes. + * + * @throws Throwable the first uncaught exception, if there are any uncaught exceptions + */ + fun cleanupTestCoroutines() +} + +/** + * An exception handler that can be used to capture uncaught exceptions in tests. + */ +class TestCoroutineExceptionHandler: ExceptionCaptor, CoroutineExceptionHandler { + override fun handleException(context: CoroutineContext, exception: Throwable) { + exceptions += exception + } + + override val key = CoroutineExceptionHandler + + override val exceptions = LinkedList() + + override fun cleanupTestCoroutines() { + val exception = exceptions.firstOrNull() ?: return + throw exception + } +} diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt new file mode 100644 index 0000000000..cf1ddf40b4 --- /dev/null +++ b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2016-2018 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.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext + +/** + * A scope which provides detailed control over the execution of coroutines for tests. + * + * @param context an optional context that must provide delegates [ExceptionCaptor] and [DelayController] + */ +class TestCoroutineScope( + context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineExceptionHandler()): + CoroutineScope, + ExceptionCaptor by context.exceptionDelegate, + DelayController by context.delayDelegate +{ + override fun cleanupTestCoroutines() { + coroutineContext.exceptionDelegate.cleanupTestCoroutines() + coroutineContext.delayDelegate.cleanupTestCoroutines() + } + + override val coroutineContext = context +} + +fun TestCoroutineScope(dispatcher: TestCoroutineDispatcher) = + TestCoroutineScope(dispatcher + TestCoroutineExceptionHandler()) + +private inline val CoroutineContext.exceptionDelegate: ExceptionCaptor + get() { + val handler = this[CoroutineExceptionHandler] + return handler as? ExceptionCaptor ?: throw + IllegalArgumentException("TestCoroutineScope requires a ExceptionCaptor as the " + + "CoroutineExceptionHandler") + } + +private inline val CoroutineContext.delayDelegate: DelayController + get() { + val handler = this[ContinuationInterceptor] + return handler as? DelayController ?: throw + IllegalArgumentException("TestCoroutineScope requires a DelayController as the " + + "ContinuationInterceptor (Dispatcher)") + } \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/src/internal/Synchronized.kt b/core/kotlinx-coroutines-test/src/internal/Synchronized.kt new file mode 100644 index 0000000000..baca376a43 --- /dev/null +++ b/core/kotlinx-coroutines-test/src/internal/Synchronized.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.internal + +@Suppress("ACTUAL_WITHOUT_EXPECT") // visibility +internal actual typealias SynchronizedObject = Any + +internal inline fun synchronized(lock: SynchronizedObject, block: () -> T): T = + kotlin.synchronized(lock, block) \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/src/internal/ThreadSafeHeap.kt b/core/kotlinx-coroutines-test/src/internal/ThreadSafeHeap.kt new file mode 100644 index 0000000000..a510e9feb2 --- /dev/null +++ b/core/kotlinx-coroutines-test/src/internal/ThreadSafeHeap.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test.internal + +import kotlinx.coroutines.internal.SynchronizedObject +import java.util.* + +internal interface ThreadSafeHeapNode { + public var heap: ThreadSafeHeap<*>? + public var index: Int +} + +/** + * Synchronized binary heap. + */ +internal class ThreadSafeHeap : SynchronizedObject() where T: ThreadSafeHeapNode, T: Comparable { + private var a: Array? = null + + @JvmField @PublishedApi @Volatile + internal var size = 0 + + public val isEmpty: Boolean get() = size == 0 + + @Synchronized + public fun clear() { + Arrays.fill(a, 0, size, null) + size = 0 + } + + @Synchronized + public fun peek(): T? = firstImpl() + + @Synchronized + public fun removeFirstOrNull(): T? = + if (size > 0) { + removeAtImpl(0) + } else { + null + } + + // @Synchronized // NOTE! NOTE! NOTE! inline fun cannot be @Synchronized + public inline fun removeFirstIf(predicate: (T) -> Boolean): T? = kotlinx.coroutines.internal.synchronized(this) { + val first = firstImpl() ?: return null + if (predicate(first)) { + removeAtImpl(0) + } else { + null + } + } + + @Synchronized + public fun addLast(node: T) = addImpl(node) + + // @Synchronized // NOTE! NOTE! NOTE! inline fun cannot be @Synchronized + public inline fun addLastIf(node: T, cond: () -> Boolean): Boolean = kotlinx.coroutines.internal.synchronized(this) { + if (cond()) { + addImpl(node) + true + } else { + false + } + } + + @Synchronized + public fun remove(node: T): Boolean { + return if (node.heap == null) { + false + } else { + val index = node.index + check(index >= 0) + removeAtImpl(index) + true + } + } + + @PublishedApi + internal fun firstImpl(): T? = a?.get(0) + + @PublishedApi + internal fun removeAtImpl(index: Int): T { + check(size > 0) + val a = this.a!! + size-- + if (index < size) { + swap(index, size) + val j = (index - 1) / 2 + if (index > 0 && a[index]!! < a[j]!!) { + swap(index, j) + siftUpFrom(j) + } else { + siftDownFrom(index) + } + } + val result = a[size]!! + check(result.heap === this) + result.heap = null + result.index = -1 + a[size] = null + return result + } + + @PublishedApi + internal fun addImpl(node: T) { + check(node.heap == null) + node.heap = this + val a = realloc() + val i = size++ + a[i] = node + node.index = i + siftUpFrom(i) + } + + private tailrec fun siftUpFrom(i: Int) { + if (i <= 0) return + val a = a!! + val j = (i - 1) / 2 + if (a[j]!! <= a[i]!!) return + swap(i, j) + siftUpFrom(j) + } + + private tailrec fun siftDownFrom(i: Int) { + var j = 2 * i + 1 + if (j >= size) return + val a = a!! + if (j + 1 < size && a[j + 1]!! < a[j]!!) j++ + if (a[i]!! <= a[j]!!) return + swap(i, j) + siftDownFrom(j) + } + + @Suppress("UNCHECKED_CAST") + private fun realloc(): Array { + val a = this.a + return when { + a == null -> (arrayOfNulls(4) as Array).also { this.a = it } + size >= a.size -> a.copyOf(size * 2).also { this.a = it } + else -> a + } + } + + private fun swap(i: Int, j: Int) { + val a = a!! + val ni = a[j]!! + val nj = a[i]!! + a[i] = ni + a[j] = nj + ni.index = i + nj.index = j + } +} diff --git a/core/kotlinx-coroutines-test/test/TestAsyncTest.kt b/core/kotlinx-coroutines-test/test/TestAsyncTest.kt new file mode 100644 index 0000000000..5f0d06e7eb --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestAsyncTest.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2016-2018 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 org.junit.* +import org.junit.Assert.* +import java.lang.IllegalArgumentException +import kotlin.coroutines.ContinuationInterceptor + +class TestAsyncTest { + @Test + fun testDelayWithLaunch() = asyncTest { + val delay = 1000L + + var executed = false + launch { + suspendedDelayedAction(delay) { + executed = true + } + } + + advanceTimeBy(delay / 2) + assertFalse(executed) + + advanceTimeBy(delay / 2) + assertTrue(executed) + } + + @Test + fun testDelayWithAsync() = asyncTest { + val delay = 1000L + + var executed = false + async { + suspendedDelayedAction(delay) { + executed = true + } + } + + advanceTimeBy(delay / 2) + assertFalse(executed) + + advanceTimeBy(delay / 2) + assertTrue(executed) + } + + + private suspend fun suspendedDelayedAction(delay: Long, action: () -> Unit) { + delay(delay) + action() + } + + + @Test + fun testDelayedFunctionWithAsync() = asyncTest { + val delay = 1000L + val expectedValue = 16 + + val deferred = async { + suspendedDelayedFunction(delay) { + expectedValue + } + } + + advanceTimeBy(delay / 2) + try { + deferred.getCompleted() + fail("The Job should not have been completed yet.") + } catch (e: Exception) { + // Success. + } + + advanceTimeBy(delay / 2) + assertEquals(expectedValue, deferred.getCompleted()) + } + + private suspend fun CoroutineScope.suspendedDelayedFunction(delay: Long, function: () -> T): T { + delay(delay / 4) + return async { + delay((delay / 4) * 3) + function() + }.await() + } + + + @Test + fun testTimingOutFunctionWithAsyncAndNoTimeout() = asyncTest { + val delay = 1000L + val expectedValue = 67 + + val result = async { + suspendedTimingOutFunction(delay, delay + 1) { + expectedValue + } + } + + advanceUntilIdle() + assertEquals(expectedValue, result.getCompleted()) + } + + @Test + fun testTimingOutFunctionWithAsyncAndTimeout() = asyncTest { + val delay = 1000L + val expectedValue = 67 + + val result = async { + suspendedTimingOutFunction(delay, delay) { + expectedValue + } + } + + advanceUntilIdle() + assertTrue(result.getCompletionExceptionOrNull() is TimeoutCancellationException) + } + + private suspend fun CoroutineScope.suspendedTimingOutFunction(delay: Long, timeOut: Long, function: () -> T): T { + return withTimeout(timeOut) { + delay(delay / 2) + val ret = function() + delay(delay / 2) + ret + } + } + + @Test(expected = IllegalAccessError::class) + fun testWithTestContextThrowingAnAssertionError() = asyncTest { + val expectedError = IllegalAccessError("hello") + + val job = launch { + throw expectedError + } + + runCurrent() + // don't rethrow or handle the exception + } + + @Test(expected = IllegalAccessError::class) + fun testExceptionHandlingWithLaunch() = asyncTest { + val expectedError = IllegalAccessError("hello") + + launch { + throw expectedError + } + + runCurrent() + } + + @Test(expected = IllegalArgumentException::class) + fun testExceptionHandlingWithLaunchingChildCoroutines() = asyncTest { + val delay = 1000L + val expectedError = IllegalAccessError("hello") + val expectedValue = 12 + + val job = launch { + suspendedAsyncWithExceptionAfterDelay(delay, expectedError, expectedValue, true) + } + + advanceTimeBy(delay) + assertTrue(job.isCancelled) + } + + @Test + fun testExceptionHandlingWithAsyncAndDontWaitForException() = asyncTest { + val delay = 1000L + val expectedError = IllegalAccessError("hello") + val expectedValue = 12 + + val result = async { + suspendedAsyncWithExceptionAfterDelay(delay, expectedError, expectedValue, false) + } + + advanceTimeBy(delay) + assertEquals(expectedError, result.getCompletionExceptionOrNull()?.cause) + } + + @Test + fun testExceptionHandlingWithAsyncAndWaitForException() = asyncTest { + val delay = 1000L + val expectedError = IllegalAccessError("hello") + val expectedValue = 12 + + val result = async { + suspendedAsyncWithExceptionAfterDelay(delay, expectedError, expectedValue, true) + } + + advanceTimeBy(delay) + + val e = result.getCompletionExceptionOrNull() + assertTrue("Expected to be thrown: '$expectedError' but was '$e'", expectedError === e?.cause) + } + + private suspend fun CoroutineScope.suspendedAsyncWithExceptionAfterDelay(delay: Long, exception: Throwable, value: T, await: Boolean): T { + val deferred = async { + delay(delay - 1) + throw IllegalArgumentException(exception) + } + + if (await) { + deferred.await() + } + return value + } + + @Test + fun testCancellationException() = asyncTest { + var actual: CancellationException? = null + val job = launch { + actual = kotlin.runCatching { delay(1000) }.exceptionOrNull() as? CancellationException + } + + runCurrent() + assertNull(actual) + + job.cancel() + runCurrent() + assertNotNull(actual) + } + + @Test() + fun testCancellationExceptionNotThrownByWithTestContext() = asyncTest { + val job = launch { + delay(1000) + } + + runCurrent() + job.cancel() + } + + @Test(expected = UncompletedCoroutinesError::class) + fun asyncTest_withUnfinishedCoroutines_failTest() { + val unfinished = CompletableDeferred() + asyncTest { + launch { unfinished.await() } + } + } + + @Test(expected = IllegalArgumentException::class) + fun asyncTest_withUnhandledExceptions_failsTest() { + asyncTest { + launch { + throw IllegalArgumentException("IAE") + } + runCurrent() + } + } + + @Test + fun scopeExtensionBuilder_passesContext() { + val scope = TestCoroutineScope() + scope.asyncTest { + async { + delay(5_000) + } + advanceTimeToNextDelayed() + + assertSame(scope.coroutineContext[ContinuationInterceptor], + coroutineContext[ContinuationInterceptor]) + assertSame(scope.coroutineContext[CoroutineExceptionHandler], + coroutineContext[CoroutineExceptionHandler]) + } + } + + @Test(expected = IllegalArgumentException::class) + fun asyncTestBuilder_throwsOnBadDispatcher() { + asyncTest(newSingleThreadContext("name")) { + + } + } + + @Test(expected = IllegalArgumentException::class) + fun asyncTestBuilder_throwsOnBadHandler() { + asyncTest(CoroutineExceptionHandler { _, _ -> Unit} ) { + } + } + + @Test + fun withContext_usingSharedInjectedDispatcher_runsFasts() { + val dispatcher = TestCoroutineDispatcher() + val scope = TestCoroutineScope(dispatcher) + + scope.asyncTest { + val deferred = async { + // share the same dispatcher (via e.g. injection or service locator) + withContext(dispatcher) { + assertRunsFast { + delay(SLOW) + } + 3 + } + } + advanceUntilIdle() + assertEquals(3, deferred.getCompleted()) + } + } +} diff --git a/core/kotlinx-coroutines-test/test/TestModuleHelpers.kt b/core/kotlinx-coroutines-test/test/TestModuleHelpers.kt new file mode 100644 index 0000000000..68f3abe0c2 --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestModuleHelpers.kt @@ -0,0 +1,18 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.CoroutineScope +import org.junit.Assert +import java.time.Instant + +const val SLOW = 10_000L + +/** + * Assert a block completes within a second or fail the suite + */ +suspend fun CoroutineScope.assertRunsFast(block: suspend CoroutineScope.() -> Unit) { + val start = Instant.now().toEpochMilli() + // don''t need to be fancy with timeouts here since anything longer than a few ms is an error + this.block() + val duration = Instant.now().minusMillis(start).toEpochMilli() + Assert.assertTrue("All tests must complete within 2000ms (use longer timeouts to cause failure)", duration < 2_000) +} diff --git a/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt b/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt new file mode 100644 index 0000000000..1946fc2956 --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt @@ -0,0 +1,284 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Test +import kotlin.coroutines.ContinuationInterceptor +import kotlin.test.assertFails + +class TestRunBlockingTest { + + @Test + fun delay_advancesTimeAutomatically() = runBlockingTest { + assertRunsFast { + delay(SLOW) + } + } + + @Test + fun callingSuspendWithDelay_advancesAutomatically() = runBlockingTest { + suspend fun withDelay(): Int { + delay(SLOW) + return 3 + } + + assertRunsFast { + assertEquals(3, withDelay()) + } + } + + @Test + fun launch_advancesAutomatically() = runBlockingTest { + val job = launch { + delay(SLOW) + } + assertRunsFast { + job.join() + assertTrue(job.isCompleted) + } + } + + @Test + fun async_advancesAutomatically() = runBlockingTest { + val deferred = async { + delay(SLOW) + 3 + } + + assertRunsFast { + assertEquals(3, deferred.await()) + } + } + + @Test + 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 + val outerInterceptor = coroutineContext[ContinuationInterceptor] + // runBlocking always requires an argument to pass the context in tests + runBlocking { + assertNotSame(coroutineContext[ContinuationInterceptor], outerInterceptor) + } + } + + @Test(expected = TimeoutCancellationException::class) + fun whenUsingTimeout_triggersWhenDelayed() = runBlockingTest { + assertRunsFast { + withTimeout(SLOW) { + delay(SLOW) + } + } + } + + @Test + fun whenUsingTimeout_doesNotTriggerWhenFast() = runBlockingTest { + assertRunsFast { + withTimeout(SLOW) { + delay(0) + } + } + } + + @Test(expected = TimeoutCancellationException::class) + fun whenUsingTimeout_triggersWhenWaiting() = runBlockingTest { + val uncompleted = CompletableDeferred() + assertRunsFast { + withTimeout(SLOW) { + uncompleted.await() + } + } + } + + @Test + fun whenUsingTimeout_doesNotTriggerWhenComplete() = runBlockingTest { + val completed = CompletableDeferred() + assertRunsFast { + completed.complete(Unit) + withTimeout(SLOW) { + completed.await() + } + } + } + + @Test(expected = TimeoutCancellationException::class) + fun whenUsingTimeout_inAsync_triggersWhenDelayed() = runBlockingTest { + val deferred = async { + withTimeout(SLOW) { + delay(SLOW) + } + } + + assertRunsFast { + deferred.await() + } + } + + @Test + fun whenUsingTimeout_inAsync_doesNotTriggerWhenNotDelayed() = runBlockingTest { + val testScope = this + val deferred = async { + withTimeout(SLOW) { + delay(0) + } + } + + assertRunsFast { + deferred.await() + } + } + + @Test(expected = TimeoutCancellationException::class) + fun whenUsingTimeout_inLaunch_triggersWhenDelayed() = runBlockingTest { + val job= launch { + withTimeout(1) { + delay(SLOW + 1) + 3 + } + } + + assertRunsFast { + job.join() + throw job.getCancellationException() + } + } + + @Test + fun whenUsingTimeout_inLaunch_doesNotTriggerWhenNotDelayed() = runBlockingTest { + val job = launch { + withTimeout(SLOW) { + delay(0) + } + } + + assertRunsFast { + job.join() + assertTrue(job.isCompleted) + } + } + + @Test(expected = IllegalArgumentException::class) + fun throwingException_throws() = runBlockingTest { + assertRunsFast { + delay(SLOW) + throw IllegalArgumentException("Test") + } + } + + @Test(expected = IllegalArgumentException::class) + fun throwingException_inLaunch_throws() = runBlockingTest { + val job = launch { + delay(SLOW) + throw IllegalArgumentException("Test") + } + + assertRunsFast { + job.join() + throw job.getCancellationException().cause ?: assertFails { "expected exception" } + } + } + + @Test(expected = IllegalArgumentException::class) + fun throwingException__inAsync_throws() = runBlockingTest { + val deferred = async { + delay(SLOW) + throw IllegalArgumentException("Test") + } + + assertRunsFast { + deferred.await() + } + } + + @Test + fun callingLaunchFunction_executesLaunchBlockImmediately() = runBlockingTest { + assertRunsFast { + var executed = false + launch { + delay(SLOW) + executed = true + } + + delay(SLOW) + assertTrue(executed) + } + } + + @Test + fun callingAsyncFunction_executesAsyncBlockImmediately() = runBlockingTest { + assertRunsFast { + var executed = false + async { + delay(SLOW) + executed = true + } + advanceTimeBy(SLOW) + + assertTrue(executed) + } + } + + @Test + fun nestingBuilders_executesSecondLevelImmediately() = runBlockingTest { + assertRunsFast { + var levels = 0 + launch { + delay(SLOW) + levels++ + launch { + delay(SLOW) + levels++ + } + } + advanceUntilIdle() + + assertEquals(2, levels) + } + } + + @Test + fun testCancellationException() = runBlockingTest { + var actual: CancellationException? = null + val uncompleted = CompletableDeferred() + val job = launch { + actual = kotlin.runCatching { uncompleted.await() }.exceptionOrNull() as? CancellationException + } + + assertNull(actual) + job.cancel() + assertNotNull(actual) + } + + @Test + fun testCancellationException_notThrown() = runBlockingTest { + val uncompleted = CompletableDeferred() + val job = launch { + uncompleted.await() + } + + job.cancel() + job.join() + } + + @Test(expected = UncompletedCoroutinesError::class) + fun whenACoroutineLeaks_errorIsThrown() = runBlockingTest { + val uncompleted = CompletableDeferred() + launch { + uncompleted.await() + } + } + + @Test(expected = java.lang.IllegalArgumentException::class) + fun runBlockingTestBuilder_throwsOnBadDispatcher() { + runBlockingTest(newSingleThreadContext("name")) { + + } + } + + @Test(expected = java.lang.IllegalArgumentException::class) + fun runBlockingTestBuilder_throwsOnBadHandler() { + runBlockingTest(CoroutineExceptionHandler { _, _ -> Unit} ) { + + } + } +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/test/TestTestBuilders.kt b/core/kotlinx-coroutines-test/test/TestTestBuilders.kt new file mode 100644 index 0000000000..4b1eb0dc3c --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestTestBuilders.kt @@ -0,0 +1,130 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.junit.* +import org.junit.Assert.* +import kotlin.coroutines.ContinuationInterceptor + +class TestTestBuilders { + + @Test + fun scopeRunBlocking_passesDispatcher() { + val scope = TestCoroutineScope() + scope.runBlockingTest { + assertSame(scope.coroutineContext[ContinuationInterceptor], coroutineContext[ContinuationInterceptor]) + } + } + + @Test + fun dispatcherRunBlocking_passesDispatcher() { + val dispatcher = TestCoroutineDispatcher() + dispatcher.runBlockingTest { + assertSame(dispatcher, coroutineContext[ContinuationInterceptor]) + } + } + + @Test + fun scopeRunBlocking_advancesPreviousDelay() { + val scope = TestCoroutineScope() + val deferred = scope.async { + delay(SLOW) + 3 + } + + scope.runBlockingTest { + assertRunsFast { + assertEquals(3, deferred.await()) + } + } + } + + @Test + fun dispatcherRunBlocking_advancesPreviousDelay() { + val dispatcher = TestCoroutineDispatcher() + val scope = CoroutineScope(dispatcher) + val deferred = scope.async { + delay(SLOW) + 3 + } + + dispatcher.runBlockingTest { + assertRunsFast { + assertEquals(3, deferred.await()) + } + } + } + + @Test + fun scopeRunBlocking_disablesImmedateOnExit() { + val scope = TestCoroutineScope() + scope.runBlockingTest { + assertRunsFast { + delay(SLOW) + } + } + + val deferred = scope.async { + delay(SLOW) + 3 + } + scope.runCurrent() + assertTrue(deferred.isActive) + + scope.advanceTimeToNextDelayed() + assertEquals(3, deferred.getCompleted()) + } + + @Test + fun whenInAsync_runBlocking_nestsProperly() { + // this is not a supported use case, but it is possible so ensure it works + + val scope = TestCoroutineScope() + val deferred = scope.async { + delay(1_000) + runBlockingTest { + delay(1_000) + } + 3 + } + + assertFalse(scope.dispatchImmediately) + + scope.advanceTimeToNextDelayed() + scope.launch { + assertRunsFast { + assertEquals(3, deferred.getCompleted()) + } + } + scope.runCurrent() // execute the launch without changing to immediate dispatch (testing internals) + scope.cleanupTestCoroutines() + } + + @Test + fun whenInrunBlocking_asyncTest_nestsProperly() { + // this is not a supported use case, but it is possible so ensure it works + + val scope = TestCoroutineScope() + var calls = 0 + + scope.runBlockingTest { + delay(1_000) + calls++ + asyncTest { + val job = launch { + delay(1_000) + calls++ + } + assertTrue(job.isActive) + advanceUntilIdle() + assertFalse(job.isActive) + calls++ + } + ++calls + } + + assertEquals(4, calls) + } +} \ No newline at end of file From 200161622a35726d237f758e59aa85fe72edc7c1 Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Mon, 11 Feb 2019 17:28:00 -0800 Subject: [PATCH 02/11] Add deprecations to TestCoroutineContext. Add deprecations to new API for changes. --- .../src/test_/TestCoroutineContext.kt | 10 +++++--- .../src/TestBuilders.kt | 15 ++++++++++-- .../src/TestCoroutineDispatcher.kt | 24 ++++++++++++++++--- .../src/TestCoroutineScope.kt | 10 ++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/core/kotlinx-coroutines-core/src/test_/TestCoroutineContext.kt b/core/kotlinx-coroutines-core/src/test_/TestCoroutineContext.kt index 983af6d17d..eda1df0bb5 100644 --- a/core/kotlinx-coroutines-core/src/test_/TestCoroutineContext.kt +++ b/core/kotlinx-coroutines-core/src/test_/TestCoroutineContext.kt @@ -30,7 +30,9 @@ import kotlin.coroutines.* * * @param name A user-readable name for debugging purposes. */ -@ObsoleteCoroutinesApi +@Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("TestCoroutineScope", "kotlin.coroutines.test"), + level = DeprecationLevel.WARNING) class TestCoroutineContext(private val name: String? = null) : CoroutineContext { private val uncaughtExceptions = mutableListOf() @@ -281,7 +283,9 @@ private class TimedRunnable( * provided instead. * @param testBody The code of the unit-test. */ -@ObsoleteCoroutinesApi +@Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("testContext.runBlockingTest(testBody)", "kotlin.coroutines.test"), + level = DeprecationLevel.WARNING) public fun withTestContext(testContext: TestCoroutineContext = TestCoroutineContext(), testBody: TestCoroutineContext.() -> Unit) { with (testContext) { testBody() @@ -289,4 +293,4 @@ public fun withTestContext(testContext: TestCoroutineContext = TestCoroutineCont throw AssertionError("Coroutine encountered unhandled exceptions:\n$exceptions") } } -} +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/src/TestBuilders.kt b/core/kotlinx-coroutines-test/src/TestBuilders.kt index 1bc0827203..d6a0fbe3a9 100644 --- a/core/kotlinx-coroutines-test/src/TestBuilders.kt +++ b/core/kotlinx-coroutines-test/src/TestBuilders.kt @@ -82,6 +82,17 @@ fun asyncTest(context: CoroutineContext? = null, testBody: TestCoroutineScope.() fun TestCoroutineScope.asyncTest(testBody: TestCoroutineScope.() -> Unit) = asyncTest(coroutineContext, testBody) +/** + * This method is deprecated. + * + * @see [cleanupTestCoroutines] + */ +@Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("scope.runBlockingTest(testBody)", "kotlinx.coroutines.test"), + level = DeprecationLevel.ERROR) +fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineScope.() -> Unit) { + scope.runBlockingTest(testBody) +} /** * Executes a [testBody] inside an immediate execution dispatcher. @@ -150,7 +161,7 @@ fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCor /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ -fun TestCoroutineScope.runBlockingTest(block: suspend CoroutineScope.() -> Unit) { +fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) { runBlockingTest(coroutineContext, block) } @@ -158,7 +169,7 @@ fun TestCoroutineScope.runBlockingTest(block: suspend CoroutineScope.() -> Unit) * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. * */ -fun TestCoroutineDispatcher.runBlockingTest(block: suspend CoroutineScope.() -> Unit) { +fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) { runBlockingTest(this, block) } diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt index c69edac9df..de4051af82 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt @@ -96,6 +96,24 @@ interface DelayController { * Setting it to false will resume lazy execution. */ var dispatchImmediately: Boolean + + @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("if (targetTime > currentTime(unit)) { advanceTimeBy(targetTime - currentTime(unit), unit) }", + "kotlinx.coroutines.test"), + level = DeprecationLevel.WARNING) + fun advanceTimeTo(targetTime: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) { + advanceTimeBy(targetTime - currentTime(unit), unit) + } + + @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("currentTime(unit)", "kotlinx.coroutines.test"), + level = DeprecationLevel.WARNING) + fun now(unit: TimeUnit = TimeUnit.MILLISECONDS) = currentTime(unit) + + @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("runCurrent()", "kotlinx.coroutines.test"), + level = DeprecationLevel.WARNING) + fun triggerActions() = runCurrent() } /** @@ -197,7 +215,7 @@ class TestCoroutineDispatcher: override fun advanceTimeBy(delayTime: Long, unit: TimeUnit): Long { val oldTime = time - advanceTimeTo(oldTime + unit.toNanos(delayTime), TimeUnit.NANOSECONDS) + advanceUntilTime(oldTime + unit.toNanos(delayTime), TimeUnit.NANOSECONDS) return unit.convert(time - oldTime, TimeUnit.NANOSECONDS) } @@ -207,7 +225,7 @@ class TestCoroutineDispatcher: * @param targetTime The point in time to which to move the CoroutineContext's clock. * @param unit The [TimeUnit] in which [targetTime] is expressed. */ - private fun advanceTimeTo(targetTime: Long, unit: TimeUnit) { + private fun advanceUntilTime(targetTime: Long, unit: TimeUnit) { val nanoTime = unit.toNanos(targetTime) triggerActions(nanoTime) if (nanoTime > time) time = nanoTime @@ -217,7 +235,7 @@ class TestCoroutineDispatcher: val oldTime = time runCurrent() val next = queue.peek() ?: return 0 - advanceTimeTo(next.time, TimeUnit.NANOSECONDS) + advanceUntilTime(next.time, TimeUnit.NANOSECONDS) return time - oldTime } diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt index cf1ddf40b4..5604094c89 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt @@ -25,6 +25,16 @@ class TestCoroutineScope( } override val coroutineContext = context + + /** + * This method is deprecated. + * + * @see [cleanupTestCoroutines] + */ + @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", + ReplaceWith("cleanupTestCoroutines()"), + level = DeprecationLevel.WARNING) + fun cancelAllActions() = cleanupTestCoroutines() } fun TestCoroutineScope(dispatcher: TestCoroutineDispatcher) = From eba24c5f9a20f1663611520bf7e2f846f7175959 Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Mon, 11 Feb 2019 17:33:34 -0800 Subject: [PATCH 03/11] Add thread saftey to exceptions --- .../src/TestCoroutineExceptionHandler.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt index 70877fd7af..fc55990be5 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt @@ -16,7 +16,7 @@ interface ExceptionCaptor { * * During [cleanupTestCoroutines] the first element of this list will be rethrown if it is not empty. */ - val exceptions: MutableList + val exceptions: List /** * Call after the test completes. @@ -30,16 +30,25 @@ interface ExceptionCaptor { * An exception handler that can be used to capture uncaught exceptions in tests. */ class TestCoroutineExceptionHandler: ExceptionCaptor, CoroutineExceptionHandler { + val lock = Object() + override fun handleException(context: CoroutineContext, exception: Throwable) { - exceptions += exception + synchronized(lock) { + _exceptions += exception + } } override val key = CoroutineExceptionHandler - override val exceptions = LinkedList() + private val _exceptions = mutableListOf() + + override val exceptions + get() = _exceptions.toList() override fun cleanupTestCoroutines() { - val exception = exceptions.firstOrNull() ?: return - throw exception + synchronized(lock) { + val exception = _exceptions.firstOrNull() ?: return + throw exception + } } } From 8328a39b39d60fe4e44b6e1e5796d37ab02cbbae Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Mon, 11 Feb 2019 18:46:06 -0800 Subject: [PATCH 04/11] API Cleanup based on review - removed asyncTest - added pauseDispatcher(), pausDispatcher(block), and resumeDispatcher() API to delayController - removed dispatchImmediately (the name was fairly opaque in an actual test case) - added exception handling to runBlockingTest --- .../src/TestBuilders.kt | 147 ++------- .../src/TestCoroutineDispatcher.kt | 98 +++--- .../test/TestAsyncTest.kt | 298 ------------------ .../test/TestRunBlockingTest.kt | 86 +++++ .../test/TestTestBuilders.kt | 13 +- 5 files changed, 174 insertions(+), 468 deletions(-) delete mode 100644 core/kotlinx-coroutines-test/test/TestAsyncTest.kt diff --git a/core/kotlinx-coroutines-test/src/TestBuilders.kt b/core/kotlinx-coroutines-test/src/TestBuilders.kt index d6a0fbe3a9..1e47dedd69 100644 --- a/core/kotlinx-coroutines-test/src/TestBuilders.kt +++ b/core/kotlinx-coroutines-test/src/TestBuilders.kt @@ -1,98 +1,9 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* -import java.util.concurrent.TimeoutException import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.coroutineContext -/** - * Executes a [testBody] in a [TestCoroutineScope] which provides detailed control over the execution of coroutines. - * - * This function should be used when you need detailed control over the execution of your test. For most tests consider - * using [runBlockingTest]. - * - * Code executed in a `asyncTest` will dispatch lazily. That means calling builders such as [launch] or [async] will - * not execute the block immediately. You can use methods like [TestCoroutineScope.runCurrent] and - * [TestCoroutineScope.advanceTimeTo] on the [TestCoroutineScope]. For a full list of execution methods see - * [DelayController]. - * - * ``` - * @Test - * fun exampleTest() = asyncTest { - * // 1: launch will execute but not run the body - * launch { - * // 3: the body of launch will execute in response to runCurrent [currentTime = 0ms] - * delay(1_000) - * // 5: After the time is advanced, delay(1_000) will return [currentTime = 1000ms] - * println("Faster delays!") - * } - * - * // 2: use runCurrent() to execute the body of launch [currentTime = 0ms] - * runCurrent() - * - * // 4: advance the dispatcher "time" by 1_000, which will resume after the delay - * advanceTimeTo(1_000) - * - * ``` - * - * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test - * conditions. - * - * In addition any unhandled exceptions thrown in coroutines must be rethrown by - * [TestCoroutineScope.rethrowUncaughtCoroutineException] or cleared via [TestCoroutineScope.exceptions] inside of - * [testBody]. - * - * @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches - * (including coroutines suspended on await). - * @throws Throwable If an uncaught exception was captured by this test it will be rethrown. - * - * @param context An optional dispatcher, during [testBody] execution [DelayController.dispatchImmediately] will be set to false - * @param testBody The code of the unit-test. - * - * @see [runBlockingTest] - */ -fun asyncTest(context: CoroutineContext? = null, testBody: TestCoroutineScope.() -> Unit) { - val (safeContext, dispatcher) = context.checkArguments() - // smart cast dispatcher to expose interface - dispatcher as DelayController - val scope = TestCoroutineScope(safeContext) - - val oldDispatch = dispatcher.dispatchImmediately - dispatcher.dispatchImmediately = false - - try { - scope.testBody() - scope.cleanupTestCoroutines() - - // check for any active child jobs after cleanup (e.g. coroutines suspended on calls to await) - val job = checkNotNull(safeContext[Job]) { "Job required for asyncTest" } - val activeChildren = job.children.filter { it.isActive }.toList() - if (activeChildren.isNotEmpty()) { - throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}") - } - } finally { - dispatcher.dispatchImmediately = oldDispatch - } -} - -/** - * @see [asyncTest] - */ -fun TestCoroutineScope.asyncTest(testBody: TestCoroutineScope.() -> Unit) = - asyncTest(coroutineContext, testBody) - -/** - * This method is deprecated. - * - * @see [cleanupTestCoroutines] - */ -@Deprecated("This API has been deprecated to integrate with Structured Concurrency.", - ReplaceWith("scope.runBlockingTest(testBody)", "kotlinx.coroutines.test"), - level = DeprecationLevel.ERROR) -fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineScope.() -> Unit) { - scope.runBlockingTest(testBody) -} /** * Executes a [testBody] inside an immediate execution dispatcher. @@ -100,9 +11,7 @@ fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineSc * 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. - * - * Compared to [asyncTest], it provides a smaller API for tests that don't need detailed control over execution. - * + ** * ``` * @Test * fun exampleTest() = runBlockingTest { @@ -121,43 +30,39 @@ fun withTestContext(scope: TestCoroutineScope, testBody: suspend TestCoroutineSc * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test * conditions. * - * In unhandled exceptions inside coroutines will not fail the test. + * 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 - * (including coroutines suspended on await). + * (including coroutines suspended on join/await). * - * @param context An optional context, during [testBody] execution [DelayController.dispatchImmediately] will be set to true + * @param context An optional context that MAY contain a [DelayController] and/or [TestCoroutineExceptionHandler] * @param testBody The code of the unit-test. - * - * @see [asyncTest] */ fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCoroutineScope.() -> Unit) { val (safeContext, dispatcher) = context.checkArguments() // smart cast dispatcher to expose interface dispatcher as DelayController - val oldDispatch = dispatcher.dispatchImmediately - dispatcher.dispatchImmediately = true - val scope = TestCoroutineScope(safeContext) - try { + val startingJobs = safeContext.activeJobs() - val deferred = scope.async { - scope.testBody() - } - dispatcher.advanceUntilIdle() - deferred.getCompletionExceptionOrNull()?.let { - throw it - } - scope.cleanupTestCoroutines() - val activeChildren = checkNotNull(safeContext[Job]).children.filter { it.isActive }.toList() - if (activeChildren.isNotEmpty()) { - throw UncompletedCoroutinesError("Test finished with active jobs: ${activeChildren}") - } - } finally { - dispatcher.dispatchImmediately = oldDispatch + val scope = TestCoroutineScope(safeContext) + val deferred = scope.async { + scope.testBody() + } + dispatcher.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() = + checkNotNull(this[Job]).children.filter { it.isActive }.toSet() + /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ @@ -194,4 +99,16 @@ private fun CoroutineContext?.checkArguments(): Pair Unit) { + scope.runBlockingTest(testBody) } \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt index de4051af82..d31c728668 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt @@ -57,45 +57,38 @@ interface DelayController { * Call after a test case completes. * * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended - * coroutines that called await. + * coroutines. */ @Throws(UncompletedCoroutinesError::class) fun cleanupTestCoroutines() /** - * When true, this dispatcher will perform as an immediate executor. + * Run a block of code in a paused dispatcher. * - * It will immediately run any tasks, which means it will auto-advance the virtual clock-time to the last pending - * delay. + * By pausing the dispatcher any new coroutines will not execute immediately. After block executes, the dispatcher + * will resume auto-advancing. * - * Test code will rarely call this method directly., Instead use a test builder like [asyncTest], [runBlockingTest] or - * the convenience methods [TestCoroutineDispatcher.runBlocking] and [TestCoroutineScope.runBlocking]. - * - * ``` - * @Test - * fun aTest() { - * val scope = TestCoroutineScope() // dispatchImmediately is false - * scope.async { - * // delay will be pending execution (lazy mode) - * delay(1_000) - * } - * - * scope.runBlocking { - * // the pending delay will immediately execute - * // dispatchImmediately is true - * } - * - * // scope is returned to lazy mode - * // dispatchImmediately is false - * } - * ``` + * This is useful when testing functions that that start a coroutine. By pausing the dispatcher assertions or + * setup may be done between the time the coroutine is created and started. + */ + suspend fun pauseDispatcher(block: suspend () -> Unit) + + /** + * Pause the dispatcher. * - * Setting this to true will immediately execute any pending tasks and advance the virtual clock-time to the last - * pending delay. While true, dispatch will continue to execute immediately, auto-advancing the virtual clock-time. + * When paused the dispatcher will not execute any coroutines automatically, and you must call [runCurrent], or one + * of [advanceTimeBy], [advanceTimeToNextDelayed], or [advanceUntilIdle] to execute coroutines. + */ + fun pauseDispatcher() + + /** + * Resume the dispatcher from a paused state. * - * Setting it to false will resume lazy execution. + * Resumed dispatchers will automatically progress through all coroutines scheduled at the current time. To advance + * time and execute coroutines scheduled in the future use one of [advanceTimeBy], [advanceTimeToNextDelayed], + * or [advanceUntilIdle]. */ - var dispatchImmediately: Boolean + fun resumeDispatcher() @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", ReplaceWith("if (targetTime > currentTime(unit)) { advanceTimeBy(targetTime - currentTime(unit), unit) }", @@ -122,15 +115,16 @@ interface DelayController { class UncompletedCoroutinesError(message: String, cause: Throwable? = null): AssertionError(message, cause) /** - * [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines. + * [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines. + * + * By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run immediately will + * be immediately executed. If they were scheduled with a delay, the virtual clock-time must be advanced via one of the + * methods on [DelayController] * - * By default, [TestCoroutineDispatcher] will be lazy. That means any coroutines started via [launch] or [async] will + * When swiched to lazy execution 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 * methods on [DelayController]. * - * When switched to immediate mode, any tasks will be immediately executed. If they were scheduled with a delay, - * the virtual clock-time will auto-advance to the last submitted delay. - * * @see DelayController */ class TestCoroutineDispatcher: @@ -138,7 +132,7 @@ class TestCoroutineDispatcher: Delay, DelayController { - override var dispatchImmediately = false + private var dispatchImmediately = true set(value) { field = value if (value) { @@ -166,9 +160,6 @@ class TestCoroutineDispatcher: override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation) { postDelayed(CancellableContinuationRunnable(continuation) { resumeUndispatched(Unit) }, timeMillis) - if (dispatchImmediately) { -// advanceTimeBy(timeMillis, TimeUnit.MILLISECONDS) - } } override fun invokeOnTimeout(timeMillis: Long, block: Runnable): DisposableHandle { @@ -180,15 +171,6 @@ class TestCoroutineDispatcher: } } -// override fun processNextEvent(): Long { -// val current = queue.peek() -// if (current != null) { -// // Automatically advance time for EventLoop callbacks -// triggerActions(current.time) -// } -// return if (queue.isEmpty) Long.MAX_VALUE else 0L -// } - override fun toString(): String = "TestCoroutineDispatcher[time=$time ns]" private fun post(block: Runnable) = @@ -247,6 +229,26 @@ class TestCoroutineDispatcher: return time - oldTime } + override fun runCurrent() = triggerActions(time) + + override suspend fun pauseDispatcher(block: suspend () -> Unit) { + val previous = dispatchImmediately + dispatchImmediately = false + try { + block() + } finally { + dispatchImmediately = previous + } + } + + override fun pauseDispatcher() { + dispatchImmediately = false + } + + override fun resumeDispatcher() { + dispatchImmediately = true + } + override fun cleanupTestCoroutines() { // process any pending cancellations or completions, but don't advance time triggerActions(time) @@ -266,8 +268,6 @@ class TestCoroutineDispatcher: " completed or cancelled by your test.") } } - - override fun runCurrent() = triggerActions(time) } diff --git a/core/kotlinx-coroutines-test/test/TestAsyncTest.kt b/core/kotlinx-coroutines-test/test/TestAsyncTest.kt deleted file mode 100644 index 5f0d06e7eb..0000000000 --- a/core/kotlinx-coroutines-test/test/TestAsyncTest.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2016-2018 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 org.junit.* -import org.junit.Assert.* -import java.lang.IllegalArgumentException -import kotlin.coroutines.ContinuationInterceptor - -class TestAsyncTest { - @Test - fun testDelayWithLaunch() = asyncTest { - val delay = 1000L - - var executed = false - launch { - suspendedDelayedAction(delay) { - executed = true - } - } - - advanceTimeBy(delay / 2) - assertFalse(executed) - - advanceTimeBy(delay / 2) - assertTrue(executed) - } - - @Test - fun testDelayWithAsync() = asyncTest { - val delay = 1000L - - var executed = false - async { - suspendedDelayedAction(delay) { - executed = true - } - } - - advanceTimeBy(delay / 2) - assertFalse(executed) - - advanceTimeBy(delay / 2) - assertTrue(executed) - } - - - private suspend fun suspendedDelayedAction(delay: Long, action: () -> Unit) { - delay(delay) - action() - } - - - @Test - fun testDelayedFunctionWithAsync() = asyncTest { - val delay = 1000L - val expectedValue = 16 - - val deferred = async { - suspendedDelayedFunction(delay) { - expectedValue - } - } - - advanceTimeBy(delay / 2) - try { - deferred.getCompleted() - fail("The Job should not have been completed yet.") - } catch (e: Exception) { - // Success. - } - - advanceTimeBy(delay / 2) - assertEquals(expectedValue, deferred.getCompleted()) - } - - private suspend fun CoroutineScope.suspendedDelayedFunction(delay: Long, function: () -> T): T { - delay(delay / 4) - return async { - delay((delay / 4) * 3) - function() - }.await() - } - - - @Test - fun testTimingOutFunctionWithAsyncAndNoTimeout() = asyncTest { - val delay = 1000L - val expectedValue = 67 - - val result = async { - suspendedTimingOutFunction(delay, delay + 1) { - expectedValue - } - } - - advanceUntilIdle() - assertEquals(expectedValue, result.getCompleted()) - } - - @Test - fun testTimingOutFunctionWithAsyncAndTimeout() = asyncTest { - val delay = 1000L - val expectedValue = 67 - - val result = async { - suspendedTimingOutFunction(delay, delay) { - expectedValue - } - } - - advanceUntilIdle() - assertTrue(result.getCompletionExceptionOrNull() is TimeoutCancellationException) - } - - private suspend fun CoroutineScope.suspendedTimingOutFunction(delay: Long, timeOut: Long, function: () -> T): T { - return withTimeout(timeOut) { - delay(delay / 2) - val ret = function() - delay(delay / 2) - ret - } - } - - @Test(expected = IllegalAccessError::class) - fun testWithTestContextThrowingAnAssertionError() = asyncTest { - val expectedError = IllegalAccessError("hello") - - val job = launch { - throw expectedError - } - - runCurrent() - // don't rethrow or handle the exception - } - - @Test(expected = IllegalAccessError::class) - fun testExceptionHandlingWithLaunch() = asyncTest { - val expectedError = IllegalAccessError("hello") - - launch { - throw expectedError - } - - runCurrent() - } - - @Test(expected = IllegalArgumentException::class) - fun testExceptionHandlingWithLaunchingChildCoroutines() = asyncTest { - val delay = 1000L - val expectedError = IllegalAccessError("hello") - val expectedValue = 12 - - val job = launch { - suspendedAsyncWithExceptionAfterDelay(delay, expectedError, expectedValue, true) - } - - advanceTimeBy(delay) - assertTrue(job.isCancelled) - } - - @Test - fun testExceptionHandlingWithAsyncAndDontWaitForException() = asyncTest { - val delay = 1000L - val expectedError = IllegalAccessError("hello") - val expectedValue = 12 - - val result = async { - suspendedAsyncWithExceptionAfterDelay(delay, expectedError, expectedValue, false) - } - - advanceTimeBy(delay) - assertEquals(expectedError, result.getCompletionExceptionOrNull()?.cause) - } - - @Test - fun testExceptionHandlingWithAsyncAndWaitForException() = asyncTest { - val delay = 1000L - val expectedError = IllegalAccessError("hello") - val expectedValue = 12 - - val result = async { - suspendedAsyncWithExceptionAfterDelay(delay, expectedError, expectedValue, true) - } - - advanceTimeBy(delay) - - val e = result.getCompletionExceptionOrNull() - assertTrue("Expected to be thrown: '$expectedError' but was '$e'", expectedError === e?.cause) - } - - private suspend fun CoroutineScope.suspendedAsyncWithExceptionAfterDelay(delay: Long, exception: Throwable, value: T, await: Boolean): T { - val deferred = async { - delay(delay - 1) - throw IllegalArgumentException(exception) - } - - if (await) { - deferred.await() - } - return value - } - - @Test - fun testCancellationException() = asyncTest { - var actual: CancellationException? = null - val job = launch { - actual = kotlin.runCatching { delay(1000) }.exceptionOrNull() as? CancellationException - } - - runCurrent() - assertNull(actual) - - job.cancel() - runCurrent() - assertNotNull(actual) - } - - @Test() - fun testCancellationExceptionNotThrownByWithTestContext() = asyncTest { - val job = launch { - delay(1000) - } - - runCurrent() - job.cancel() - } - - @Test(expected = UncompletedCoroutinesError::class) - fun asyncTest_withUnfinishedCoroutines_failTest() { - val unfinished = CompletableDeferred() - asyncTest { - launch { unfinished.await() } - } - } - - @Test(expected = IllegalArgumentException::class) - fun asyncTest_withUnhandledExceptions_failsTest() { - asyncTest { - launch { - throw IllegalArgumentException("IAE") - } - runCurrent() - } - } - - @Test - fun scopeExtensionBuilder_passesContext() { - val scope = TestCoroutineScope() - scope.asyncTest { - async { - delay(5_000) - } - advanceTimeToNextDelayed() - - assertSame(scope.coroutineContext[ContinuationInterceptor], - coroutineContext[ContinuationInterceptor]) - assertSame(scope.coroutineContext[CoroutineExceptionHandler], - coroutineContext[CoroutineExceptionHandler]) - } - } - - @Test(expected = IllegalArgumentException::class) - fun asyncTestBuilder_throwsOnBadDispatcher() { - asyncTest(newSingleThreadContext("name")) { - - } - } - - @Test(expected = IllegalArgumentException::class) - fun asyncTestBuilder_throwsOnBadHandler() { - asyncTest(CoroutineExceptionHandler { _, _ -> Unit} ) { - } - } - - @Test - fun withContext_usingSharedInjectedDispatcher_runsFasts() { - val dispatcher = TestCoroutineDispatcher() - val scope = TestCoroutineScope(dispatcher) - - scope.asyncTest { - val deferred = async { - // share the same dispatcher (via e.g. injection or service locator) - withContext(dispatcher) { - assertRunsFast { - delay(SLOW) - } - 3 - } - } - advanceUntilIdle() - assertEquals(3, deferred.getCompleted()) - } - } -} diff --git a/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt b/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt index 1946fc2956..76cd5e0cb3 100644 --- a/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt +++ b/core/kotlinx-coroutines-test/test/TestRunBlockingTest.kt @@ -101,6 +101,17 @@ class TestRunBlockingTest { } } + @Test + fun testDelayInAsync_withAwait() = runBlockingTest { + assertRunsFast { + val deferred = async { + delay(SLOW) + 3 + } + assertEquals(3, deferred.await()) + } + } + @Test(expected = TimeoutCancellationException::class) fun whenUsingTimeout_inAsync_triggersWhenDelayed() = runBlockingTest { val deferred = async { @@ -281,4 +292,79 @@ class TestRunBlockingTest { } } + + @Test + fun pauseDispatcher_disablesAutoAdvance_forCurrent() = runBlockingTest { + var mutable = 0 + pauseDispatcher { + launch { + mutable++ + } + assertEquals(0, mutable) + runCurrent() + assertEquals(1, mutable) + } + } + + @Test + fun pauseDispatcher_disablesAutoAdvance_forDelay() = runBlockingTest { + var mutable = 0 + pauseDispatcher { + launch { + mutable++ + delay(SLOW) + mutable++ + } + assertEquals(0, mutable) + runCurrent() + assertEquals(1, mutable) + advanceTimeBy(SLOW) + assertEquals(2, mutable) + } + } + + @Test + fun pauseDispatcher_withDelay_resumesAfterPause() = runBlockingTest { + var mutable = 0 + assertRunsFast { + pauseDispatcher { + delay(1_000) + mutable++ + } + } + assertEquals(1, mutable) + } + + + @Test(expected = IllegalAccessError::class) + fun testWithTestContextThrowingAnAssertionError() = runBlockingTest { + val expectedError = IllegalAccessError("hello") + + val job = launch { + throw expectedError + } + + // don't rethrow or handle the exception + } + + @Test(expected = IllegalAccessError::class) + fun testExceptionHandlingWithLaunch() = runBlockingTest { + val expectedError = IllegalAccessError("hello") + + launch { + throw expectedError + } + } + + @Test(expected = IllegalAccessError::class) + fun testExceptions_notThrownImmediately() = runBlockingTest { + val expectedException = IllegalAccessError("hello") + val result = runCatching { + launch { + throw expectedException + } + } + runCurrent() + assertEquals(true, result.isSuccess) + } } \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/test/TestTestBuilders.kt b/core/kotlinx-coroutines-test/test/TestTestBuilders.kt index 4b1eb0dc3c..35c6da1a7f 100644 --- a/core/kotlinx-coroutines-test/test/TestTestBuilders.kt +++ b/core/kotlinx-coroutines-test/test/TestTestBuilders.kt @@ -81,17 +81,18 @@ class TestTestBuilders { fun whenInAsync_runBlocking_nestsProperly() { // this is not a supported use case, but it is possible so ensure it works - val scope = TestCoroutineScope() + val dispatcher = TestCoroutineDispatcher() + val scope = TestCoroutineScope(dispatcher) val deferred = scope.async { delay(1_000) + var retval = 2 runBlockingTest { delay(1_000) + retval++ } - 3 + retval } - assertFalse(scope.dispatchImmediately) - scope.advanceTimeToNextDelayed() scope.launch { assertRunsFast { @@ -103,7 +104,7 @@ class TestTestBuilders { } @Test - fun whenInrunBlocking_asyncTest_nestsProperly() { + fun whenInrunBlocking_runBlockingTest_nestsProperly() { // this is not a supported use case, but it is possible so ensure it works val scope = TestCoroutineScope() @@ -112,7 +113,7 @@ class TestTestBuilders { scope.runBlockingTest { delay(1_000) calls++ - asyncTest { + runBlockingTest { val job = launch { delay(1_000) calls++ From 9c1d3fd5cd082fa6e203bad65c32b1eb2fe0047a Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Mon, 11 Feb 2019 18:52:44 -0800 Subject: [PATCH 05/11] Add more comments. --- .../src/TestCoroutineExceptionHandler.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt index fc55990be5..4387464ed7 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt @@ -14,6 +14,8 @@ interface ExceptionCaptor { /** * List of uncaught coroutine exceptions. * + * The returned list will be a copy of the currently caught exceptions. + * * During [cleanupTestCoroutines] the first element of this list will be rethrown if it is not empty. */ val exceptions: List From e64ac09ae08f3a69c1d7cd0e04522c8a1e7896cd Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Sun, 24 Feb 2019 19:47:24 -0800 Subject: [PATCH 06/11] - Finished making TestCoroutineExceptionHandler threadsafe - Added missing defaults to ctor of TestCoroutineScope - Docs cleanup & tests for coverage --- .../src/TestBuilders.kt | 20 ++- ...TestCoroutineCoroutineExceptionHandler.kt} | 24 ++- .../src/TestCoroutineDispatcher.kt | 16 +- .../src/TestCoroutineScope.kt | 67 ++++--- ...estTestBuilders.kt => TestBuildersTest.kt} | 2 +- .../test/TestCoroutineDispatcherTest.kt | 163 ++++++++++++++++++ .../test/TestCoroutineExceptionHandlerTest.kt | 13 ++ .../test/TestCoroutineScopeTest.kt | 27 +++ 8 files changed, 285 insertions(+), 47 deletions(-) rename core/kotlinx-coroutines-test/src/{TestCoroutineExceptionHandler.kt => TestCoroutineCoroutineExceptionHandler.kt} (64%) rename core/kotlinx-coroutines-test/test/{TestTestBuilders.kt => TestBuildersTest.kt} (99%) create mode 100644 core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt create mode 100644 core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt create mode 100644 core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt diff --git a/core/kotlinx-coroutines-test/src/TestBuilders.kt b/core/kotlinx-coroutines-test/src/TestBuilders.kt index 1e47dedd69..31c81f7d81 100644 --- a/core/kotlinx-coroutines-test/src/TestBuilders.kt +++ b/core/kotlinx-coroutines-test/src/TestBuilders.kt @@ -1,10 +1,11 @@ +@file:JvmName("TestBuilders") + package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext - /** * Executes a [testBody] inside an immediate execution dispatcher. * @@ -35,9 +36,10 @@ import kotlin.coroutines.CoroutineContext * @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches * (including coroutines suspended on join/await). * - * @param context An optional context that MAY contain a [DelayController] and/or [TestCoroutineExceptionHandler] + * @param context An optional context that MUST contain a [DelayController] and/or [TestCoroutineCoroutineExceptionHandler] * @param testBody The code of the unit-test. */ +@ExperimentalCoroutinesApi fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCoroutineScope.() -> Unit) { val (safeContext, dispatcher) = context.checkArguments() // smart cast dispatcher to expose interface @@ -60,26 +62,28 @@ fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend TestCor } } -private fun CoroutineContext.activeJobs() = - checkNotNull(this[Job]).children.filter { it.isActive }.toSet() +private fun CoroutineContext.activeJobs(): Set { + return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() +} /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ +@ExperimentalCoroutinesApi fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) { runBlockingTest(coroutineContext, block) } /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. - * */ +@ExperimentalCoroutinesApi fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit) { runBlockingTest(this, block) } private fun CoroutineContext?.checkArguments(): Pair { - var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher() + var safeContext= this ?: TestCoroutineCoroutineExceptionHandler() + TestCoroutineDispatcher() val dispatcher = safeContext[ContinuationInterceptor].run { this?.let { @@ -90,9 +94,9 @@ private fun CoroutineContext?.checkArguments(): Pair + val uncaughtExceptions: List /** * Call after the test completes. @@ -31,11 +29,11 @@ interface ExceptionCaptor { /** * An exception handler that can be used to capture uncaught exceptions in tests. */ -class TestCoroutineExceptionHandler: ExceptionCaptor, CoroutineExceptionHandler { - val lock = Object() +@ExperimentalCoroutinesApi +class TestCoroutineCoroutineExceptionHandler: UncaughtExceptionCaptor, CoroutineExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { - synchronized(lock) { + synchronized(_exceptions) { _exceptions += exception } } @@ -44,11 +42,11 @@ class TestCoroutineExceptionHandler: ExceptionCaptor, CoroutineExceptionHandler private val _exceptions = mutableListOf() - override val exceptions - get() = _exceptions.toList() + override val uncaughtExceptions + get() = synchronized(_exceptions) { _exceptions.toList() } override fun cleanupTestCoroutines() { - synchronized(lock) { + synchronized(_exceptions) { val exception = _exceptions.firstOrNull() ?: return throw exception } diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt index d31c728668..65d86948e8 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt @@ -11,6 +11,7 @@ import kotlin.coroutines.CoroutineContext * * Testing libraries may expose this interface to tests instead of [TestCoroutineDispatcher]. */ +@ExperimentalCoroutinesApi interface DelayController { /** * Returns the current virtual clock-time as it is known to this Dispatcher. @@ -18,6 +19,7 @@ interface DelayController { * @param unit The [TimeUnit] in which the clock-time must be returned. * @return The virtual clock-time */ + @ExperimentalCoroutinesApi fun currentTime(unit: TimeUnit = TimeUnit.MILLISECONDS): Long /** @@ -30,6 +32,7 @@ interface DelayController { * @param unit The [TimeUnit] in which [delayTime] and the return value is expressed. * @return The amount of delay-time that this Dispatcher's clock has been forwarded. */ + @ExperimentalCoroutinesApi fun advanceTimeBy(delayTime: Long, unit: TimeUnit = TimeUnit.MILLISECONDS): Long /** @@ -37,6 +40,7 @@ interface DelayController { * * @return the amount of delay-time that this Dispatcher's clock has been forwarded. */ + @ExperimentalCoroutinesApi fun advanceTimeToNextDelayed(): Long /** @@ -44,6 +48,7 @@ interface DelayController { * * @return the amount of delay-time that this Dispatcher's clock has been forwarded. */ + @ExperimentalCoroutinesApi fun advanceUntilIdle(): Long /** @@ -51,6 +56,7 @@ interface DelayController { * * Calling this function will never advance the clock. */ + @ExperimentalCoroutinesApi fun runCurrent() /** @@ -59,6 +65,7 @@ interface DelayController { * @throws UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended * coroutines. */ + @ExperimentalCoroutinesApi @Throws(UncompletedCoroutinesError::class) fun cleanupTestCoroutines() @@ -71,6 +78,7 @@ interface DelayController { * This is useful when testing functions that that start a coroutine. By pausing the dispatcher assertions or * setup may be done between the time the coroutine is created and started. */ + @ExperimentalCoroutinesApi suspend fun pauseDispatcher(block: suspend () -> Unit) /** @@ -79,6 +87,7 @@ interface DelayController { * When paused the dispatcher will not execute any coroutines automatically, and you must call [runCurrent], or one * of [advanceTimeBy], [advanceTimeToNextDelayed], or [advanceUntilIdle] to execute coroutines. */ + @ExperimentalCoroutinesApi fun pauseDispatcher() /** @@ -88,6 +97,7 @@ interface DelayController { * time and execute coroutines scheduled in the future use one of [advanceTimeBy], [advanceTimeToNextDelayed], * or [advanceUntilIdle]. */ + @ExperimentalCoroutinesApi fun resumeDispatcher() @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", @@ -112,12 +122,13 @@ interface DelayController { /** * Thrown when a test has completed by there are tasks that are not completed or cancelled. */ +@ExperimentalCoroutinesApi class UncompletedCoroutinesError(message: String, cause: Throwable? = null): AssertionError(message, cause) /** * [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines. * - * By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run immediately will + * By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run without delay will * be immediately executed. If they were scheduled with a delay, the virtual clock-time must be advanced via one of the * methods on [DelayController] * @@ -127,6 +138,7 @@ class UncompletedCoroutinesError(message: String, cause: Throwable? = null): Ass * * @see DelayController */ +@ExperimentalCoroutinesApi class TestCoroutineDispatcher: CoroutineDispatcher(), Delay, @@ -171,7 +183,7 @@ class TestCoroutineDispatcher: } } - override fun toString(): String = "TestCoroutineDispatcher[time=$time ns]" + override fun toString(): String = "TestCoroutineDispatcher[time=${time}ns]" private fun post(block: Runnable) = queue.addLast(TimedRunnable(block, counter++)) diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt index 5604094c89..9e156c16e2 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt @@ -8,24 +8,12 @@ import kotlinx.coroutines.* import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext + /** * A scope which provides detailed control over the execution of coroutines for tests. - * - * @param context an optional context that must provide delegates [ExceptionCaptor] and [DelayController] */ -class TestCoroutineScope( - context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineExceptionHandler()): - CoroutineScope, - ExceptionCaptor by context.exceptionDelegate, - DelayController by context.delayDelegate -{ - override fun cleanupTestCoroutines() { - coroutineContext.exceptionDelegate.cleanupTestCoroutines() - coroutineContext.delayDelegate.cleanupTestCoroutines() - } - - override val coroutineContext = context - +@ExperimentalCoroutinesApi +interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, DelayController { /** * This method is deprecated. * @@ -37,21 +25,54 @@ class TestCoroutineScope( fun cancelAllActions() = cleanupTestCoroutines() } -fun TestCoroutineScope(dispatcher: TestCoroutineDispatcher) = - TestCoroutineScope(dispatcher + TestCoroutineExceptionHandler()) +private class TestCoroutineScopeImpl ( + context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineCoroutineExceptionHandler()): + TestCoroutineScope, + UncaughtExceptionCaptor by context.uncaughtExceptionDelegate, + DelayController by context.delayDelegate +{ + + override fun cleanupTestCoroutines() { + coroutineContext.uncaughtExceptionDelegate.cleanupTestCoroutines() + coroutineContext.delayDelegate.cleanupTestCoroutines() + } + + override val coroutineContext = context +} + +/** + * A scope which provides detailed control over the execution of coroutines for tests. + * + * If the provided context does not provide a [ContinuationInterceptor] (Dispatcher) or [CoroutineExceptionHandler], the + * scope will add [TestCoroutineDispatcher] and [TestCoroutineExceptionHandler] automatically. + * + * @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController] + */ +@ExperimentalCoroutinesApi +fun TestCoroutineScope(context: CoroutineContext? = null): TestCoroutineScope { + var safeContext = context ?: return TestCoroutineScopeImpl() + if (context[ContinuationInterceptor] == null) { + safeContext += TestCoroutineDispatcher() + } + if (context[CoroutineExceptionHandler] == null) { + safeContext += TestCoroutineCoroutineExceptionHandler() + } + + return TestCoroutineScopeImpl(safeContext) +} -private inline val CoroutineContext.exceptionDelegate: ExceptionCaptor +private inline val CoroutineContext.uncaughtExceptionDelegate: UncaughtExceptionCaptor get() { val handler = this[CoroutineExceptionHandler] - return handler as? ExceptionCaptor ?: throw - IllegalArgumentException("TestCoroutineScope requires a ExceptionCaptor as the " + - "CoroutineExceptionHandler") + return handler as? UncaughtExceptionCaptor ?: throw + IllegalArgumentException("TestCoroutineScope requires a UncaughtExceptionCaptor such as " + + "TestCoroutineCoroutineExceptionHandler as the CoroutineExceptionHandler") } private inline val CoroutineContext.delayDelegate: DelayController get() { val handler = this[ContinuationInterceptor] return handler as? DelayController ?: throw - IllegalArgumentException("TestCoroutineScope requires a DelayController as the " + - "ContinuationInterceptor (Dispatcher)") + IllegalArgumentException("TestCoroutineScope requires a DelayController such as TestCoroutineDispatcher as " + + "the ContinuationInterceptor (Dispatcher)") } \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/test/TestTestBuilders.kt b/core/kotlinx-coroutines-test/test/TestBuildersTest.kt similarity index 99% rename from core/kotlinx-coroutines-test/test/TestTestBuilders.kt rename to core/kotlinx-coroutines-test/test/TestBuildersTest.kt index 35c6da1a7f..7f09576b1b 100644 --- a/core/kotlinx-coroutines-test/test/TestTestBuilders.kt +++ b/core/kotlinx-coroutines-test/test/TestBuildersTest.kt @@ -8,7 +8,7 @@ import org.junit.* import org.junit.Assert.* import kotlin.coroutines.ContinuationInterceptor -class TestTestBuilders { +class TestBuildersTest { @Test fun scopeRunBlocking_passesDispatcher() { diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt new file mode 100644 index 0000000000..0167c41955 --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt @@ -0,0 +1,163 @@ +package kotlinx.coroutines.test + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.junit.Test +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals + +class TestCoroutineDispatcherTest { + @Test + fun whenStringCalled_itReturnsString() { + val subject = TestCoroutineDispatcher() + assertEquals("TestCoroutineDispatcher[time=0ns]", subject.toString()) + } + + @Test + fun whenStringCalled_itReturnsCurrentTime() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeBy(1000, TimeUnit.NANOSECONDS) + assertEquals("TestCoroutineDispatcher[time=1000ns]", subject.toString()) + } + + @Test + fun whenCurrentTimeCalled_returnsTimeAsSpecified() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeBy(1000, TimeUnit.MILLISECONDS) + + assertEquals(1_000_000_000, subject.currentTime(TimeUnit.NANOSECONDS)) + assertEquals(1_000, subject.currentTime(TimeUnit.MILLISECONDS)) + assertEquals(1, subject.currentTime(TimeUnit.SECONDS)) + + assertEquals(1_000, subject.currentTime()) + } + + @Test + fun whenAdvanceTimeCalled_defaultsToMilliseconds() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeBy(1_000) + + assertEquals(1_000, subject.currentTime(TimeUnit.MILLISECONDS)) + } + + @Test + fun whenAdvanceTimeCalled_respectsTimeUnit() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeBy(1, TimeUnit.SECONDS) + + assertEquals(1_000, subject.currentTime(TimeUnit.MILLISECONDS)) + } + + @Test + fun whenDispatcherPaused_doesntAutoProgressCurrent() { + val subject = TestCoroutineDispatcher() + subject.pauseDispatcher() + val scope = CoroutineScope(subject) + var executed = 0 + scope.launch { + executed++ + } + assertEquals(0, executed) + } + + @Test + fun whenDispatcherResumed_doesAutoProgressCurrent() { + val subject = TestCoroutineDispatcher() + val scope = CoroutineScope(subject) + var executed = 0 + scope.launch { + executed++ + } + + assertEquals(1, executed) + } + + @Test + fun whenDispatcherResumed_doesNotAutoProgressTime() { + val subject = TestCoroutineDispatcher() + val scope = CoroutineScope(subject) + var executed = 0 + scope.launch { + delay(1_000) + executed++ + } + + assertEquals(0, executed) + subject.advanceUntilIdle() + assertEquals(1, executed) + } + + @Test + fun whenDispatcherPaused_thenResume_itDoesDispatchCurrent() { + val subject = TestCoroutineDispatcher() + subject.pauseDispatcher() + val scope = CoroutineScope(subject) + var executed = 0 + scope.launch { + executed++ + } + + assertEquals(0, executed) + subject.resumeDispatcher() + assertEquals(1, executed) + } + + @Test(expected = UncompletedCoroutinesError::class) + fun whenDispatcherHasUncompletedCoroutines_itThrowsErrorInCleanup() { + val subject = TestCoroutineDispatcher() + subject.pauseDispatcher() + val scope = CoroutineScope(subject) + scope.launch { + delay(1_000) + } + subject.cleanupTestCoroutines() + } + + @Test + fun whenCallingAdvanceTimeTo_itAdvancesToTheFuture() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeTo(1_000) + subject.advanceTimeTo(500) + + assertEquals(1_000, subject.currentTime()) + } + + @Test + fun whenCallingAdvanceTimeTo_itOnlyAdvancesByDelta() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeTo(1000) + subject.advanceTimeTo(2000) + + assertEquals(2_000, subject.currentTime()) + } + + @Test + fun whenNowCalled_returnsCurrentTime() { + val subject = TestCoroutineDispatcher() + subject.advanceTimeBy(1000) + + assertEquals(1_000, subject.now()) + } + + @Test + fun whenCallingTriggerActions_currentActionsRun() { + val subject = TestCoroutineDispatcher() + subject.pauseDispatcher() + val scope = CoroutineScope(subject) + var executed = 0 + + scope.launch { + executed++ + delay(1000) + executed++ + } + + assertEquals(0, executed) + subject.triggerActions() + assertEquals(1, executed) + subject.advanceUntilIdle() + assertEquals(2, executed) + } + +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt new file mode 100644 index 0000000000..6c48e32f48 --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt @@ -0,0 +1,13 @@ +import kotlinx.coroutines.test.TestCoroutineCoroutineExceptionHandler +import org.junit.Test +import kotlin.test.assertEquals + +class TestCoroutineExceptionHandlerTest { + @Test + fun whenExceptionsCaught_avaliableViaProperty() { + val subject = TestCoroutineCoroutineExceptionHandler() + val expected = IllegalArgumentException() + subject.handleException(subject, expected) + assertEquals(listOf(expected), subject.uncaughtExceptions) + } +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt new file mode 100644 index 0000000000..376d7cd80e --- /dev/null +++ b/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt @@ -0,0 +1,27 @@ +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineCoroutineExceptionHandler +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.Test + +class TestCoroutineScopeTest { + @Test(expected = IllegalArgumentException::class) + fun whenGivenInvalidExceptionHandler_throwsException() { + val handler = CoroutineExceptionHandler { _, _ -> Unit } + val subject = TestCoroutineScope(handler) + } + + @Test(expected = IllegalArgumentException::class) + fun whenGivenInvalidDispatcher_throwsException() { + val subject = TestCoroutineScope(newSingleThreadContext("incorrect call")) + } + + @Test(expected = IllegalArgumentException::class) + fun whenCancelAllActions_callsCleanupTestCoroutines() { + val handler = TestCoroutineCoroutineExceptionHandler() + val subject = TestCoroutineScope(handler + TestCoroutineDispatcher()) + handler.handleException(handler, IllegalArgumentException()) + subject.cancelAllActions() + } +} \ No newline at end of file From 3feddfffcbfc3a2fa1e30d6862ff5eeadff30771 Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Sun, 24 Feb 2019 20:41:29 -0800 Subject: [PATCH 07/11] Add API binary compatibility listing - Removed some added-as-deprecated APIs that might have helped with automatic refactoring via ReplaceWith. Not sure if these should be shipped. --- .../kotlinx-coroutines-test.txt | 74 +++++++++++++++++++ .../src/TestBuilders.kt | 8 +- .../src/TestCoroutineDispatcher.kt | 26 +------ ...er.kt => TestCoroutineExceptionHandler.kt} | 2 +- .../src/TestCoroutineScope.kt | 19 ++--- .../test/TestCoroutineDispatcherTest.kt | 47 ------------ .../test/TestCoroutineExceptionHandlerTest.kt | 4 +- .../test/TestCoroutineScopeTest.kt | 25 +++---- 8 files changed, 100 insertions(+), 105 deletions(-) rename core/kotlinx-coroutines-test/src/{TestCoroutineCoroutineExceptionHandler.kt => TestCoroutineExceptionHandler.kt} (93%) diff --git a/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-test.txt b/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-test.txt index 20a1392a0f..94d4d0c8af 100644 --- a/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-test.txt +++ b/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-test.txt @@ -1,5 +1,79 @@ +public abstract interface class kotlinx/coroutines/test/DelayController { + public abstract fun advanceTimeBy (JLjava/util/concurrent/TimeUnit;)J + public abstract fun advanceTimeToNextDelayed ()J + public abstract fun advanceUntilIdle ()J + public abstract fun cleanupTestCoroutines ()V + public abstract fun currentTime (Ljava/util/concurrent/TimeUnit;)J + 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/DelayController$DefaultImpls { + public static synthetic fun advanceTimeBy$default (Lkotlinx/coroutines/test/DelayController;JLjava/util/concurrent/TimeUnit;ILjava/lang/Object;)J + public static synthetic fun currentTime$default (Lkotlinx/coroutines/test/DelayController;Ljava/util/concurrent/TimeUnit;ILjava/lang/Object;)J +} + +public final class kotlinx/coroutines/test/TestBuilders { + 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 synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun withTestContext (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V +} + +public final class kotlinx/coroutines/test/TestCoroutineCoroutineExceptionHandler : kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor { + public fun ()V + public fun cleanupTestCoroutines ()V + public fun fold (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Ljava/lang/Object; + public fun get (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element; + public synthetic fun getKey ()Lkotlin/coroutines/CoroutineContext$Key; + public fun getKey ()Lkotlinx/coroutines/CoroutineExceptionHandler$Key; + public fun getUncaughtExceptions ()Ljava/util/List; + public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V + public fun minusKey (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext; + public fun plus (Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; +} + +public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/DelayController { + public fun ()V + public fun advanceTimeBy (JLjava/util/concurrent/TimeUnit;)J + public fun advanceTimeToNextDelayed ()J + public fun advanceUntilIdle ()J + public fun cleanupTestCoroutines ()V + public fun currentTime (Ljava/util/concurrent/TimeUnit;)J + public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun dispatch (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V + public fun invokeOnTimeout (JLjava/lang/Runnable;)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; +} + +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/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 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 } +public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor { + public abstract fun cleanupTestCoroutines ()V + public abstract fun getUncaughtExceptions ()Ljava/util/List; +} + +public final class kotlinx/coroutines/test/UncompletedCoroutinesError : java/lang/AssertionError { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + diff --git a/core/kotlinx-coroutines-test/src/TestBuilders.kt b/core/kotlinx-coroutines-test/src/TestBuilders.kt index 31c81f7d81..deb28276b6 100644 --- a/core/kotlinx-coroutines-test/src/TestBuilders.kt +++ b/core/kotlinx-coroutines-test/src/TestBuilders.kt @@ -36,7 +36,7 @@ import kotlin.coroutines.CoroutineContext * @throws UncompletedCoroutinesError If the [testBody] does not complete (or cancel) all coroutines that it launches * (including coroutines suspended on join/await). * - * @param context An optional context that MUST contain a [DelayController] and/or [TestCoroutineCoroutineExceptionHandler] + * @param context An optional context that MUST contain a [DelayController] and/or [TestCoroutineExceptionHandler] * @param testBody The code of the unit-test. */ @ExperimentalCoroutinesApi @@ -83,7 +83,7 @@ fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() } private fun CoroutineContext?.checkArguments(): Pair { - var safeContext= this ?: TestCoroutineCoroutineExceptionHandler() + TestCoroutineDispatcher() + var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher() val dispatcher = safeContext[ContinuationInterceptor].run { this?.let { @@ -96,7 +96,7 @@ private fun CoroutineContext?.checkArguments(): Pair currentTime(unit)) { advanceTimeBy(targetTime - currentTime(unit), unit) }", - "kotlinx.coroutines.test"), - level = DeprecationLevel.WARNING) - fun advanceTimeTo(targetTime: Long, unit: TimeUnit = TimeUnit.MILLISECONDS) { - advanceTimeBy(targetTime - currentTime(unit), unit) - } - - @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", - ReplaceWith("currentTime(unit)", "kotlinx.coroutines.test"), - level = DeprecationLevel.WARNING) - fun now(unit: TimeUnit = TimeUnit.MILLISECONDS) = currentTime(unit) - - @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", - ReplaceWith("runCurrent()", "kotlinx.coroutines.test"), - level = DeprecationLevel.WARNING) - fun triggerActions() = runCurrent() } /** @@ -195,7 +177,7 @@ class TestCoroutineDispatcher: } - private fun triggerActions(targetTime: Long) { + 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 @@ -221,7 +203,7 @@ class TestCoroutineDispatcher: */ private fun advanceUntilTime(targetTime: Long, unit: TimeUnit) { val nanoTime = unit.toNanos(targetTime) - triggerActions(nanoTime) + doActionsUntil(nanoTime) if (nanoTime > time) time = nanoTime } @@ -241,7 +223,7 @@ class TestCoroutineDispatcher: return time - oldTime } - override fun runCurrent() = triggerActions(time) + override fun runCurrent() = doActionsUntil(time) override suspend fun pauseDispatcher(block: suspend () -> Unit) { val previous = dispatchImmediately @@ -263,7 +245,7 @@ class TestCoroutineDispatcher: override fun cleanupTestCoroutines() { // process any pending cancellations or completions, but don't advance time - triggerActions(time) + doActionsUntil(time) // run through all pending tasks, ignore any submitted coroutines that are not active val pendingTasks = mutableListOf() diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineCoroutineExceptionHandler.kt b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt similarity index 93% rename from core/kotlinx-coroutines-test/src/TestCoroutineCoroutineExceptionHandler.kt rename to core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt index 35545307c8..dbe659145a 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineCoroutineExceptionHandler.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt @@ -30,7 +30,7 @@ interface UncaughtExceptionCaptor { * An exception handler that can be used to capture uncaught exceptions in tests. */ @ExperimentalCoroutinesApi -class TestCoroutineCoroutineExceptionHandler: UncaughtExceptionCaptor, CoroutineExceptionHandler { +class TestCoroutineExceptionHandler: UncaughtExceptionCaptor, CoroutineExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { synchronized(_exceptions) { diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt index 9e156c16e2..5918a18040 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineScope.kt @@ -1,3 +1,4 @@ + /* * Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ @@ -13,20 +14,10 @@ import kotlin.coroutines.CoroutineContext * A scope which provides detailed control over the execution of coroutines for tests. */ @ExperimentalCoroutinesApi -interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, DelayController { - /** - * This method is deprecated. - * - * @see [cleanupTestCoroutines] - */ - @Deprecated("This API has been deprecated to integrate with Structured Concurrency.", - ReplaceWith("cleanupTestCoroutines()"), - level = DeprecationLevel.WARNING) - fun cancelAllActions() = cleanupTestCoroutines() -} +interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor, DelayController private class TestCoroutineScopeImpl ( - context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineCoroutineExceptionHandler()): + context: CoroutineContext = TestCoroutineDispatcher() + TestCoroutineExceptionHandler()): TestCoroutineScope, UncaughtExceptionCaptor by context.uncaughtExceptionDelegate, DelayController by context.delayDelegate @@ -55,7 +46,7 @@ fun TestCoroutineScope(context: CoroutineContext? = null): TestCoroutineScope { safeContext += TestCoroutineDispatcher() } if (context[CoroutineExceptionHandler] == null) { - safeContext += TestCoroutineCoroutineExceptionHandler() + safeContext += TestCoroutineExceptionHandler() } return TestCoroutineScopeImpl(safeContext) @@ -66,7 +57,7 @@ private inline val CoroutineContext.uncaughtExceptionDelegate: UncaughtException val handler = this[CoroutineExceptionHandler] return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException("TestCoroutineScope requires a UncaughtExceptionCaptor such as " + - "TestCoroutineCoroutineExceptionHandler as the CoroutineExceptionHandler") + "TestCoroutineExceptionHandler as the CoroutineExceptionHandler") } private inline val CoroutineContext.delayDelegate: DelayController diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt index 0167c41955..3454b94b00 100644 --- a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt +++ b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt @@ -113,51 +113,4 @@ class TestCoroutineDispatcherTest { } subject.cleanupTestCoroutines() } - - @Test - fun whenCallingAdvanceTimeTo_itAdvancesToTheFuture() { - val subject = TestCoroutineDispatcher() - subject.advanceTimeTo(1_000) - subject.advanceTimeTo(500) - - assertEquals(1_000, subject.currentTime()) - } - - @Test - fun whenCallingAdvanceTimeTo_itOnlyAdvancesByDelta() { - val subject = TestCoroutineDispatcher() - subject.advanceTimeTo(1000) - subject.advanceTimeTo(2000) - - assertEquals(2_000, subject.currentTime()) - } - - @Test - fun whenNowCalled_returnsCurrentTime() { - val subject = TestCoroutineDispatcher() - subject.advanceTimeBy(1000) - - assertEquals(1_000, subject.now()) - } - - @Test - fun whenCallingTriggerActions_currentActionsRun() { - val subject = TestCoroutineDispatcher() - subject.pauseDispatcher() - val scope = CoroutineScope(subject) - var executed = 0 - - scope.launch { - executed++ - delay(1000) - executed++ - } - - assertEquals(0, executed) - subject.triggerActions() - assertEquals(1, executed) - subject.advanceUntilIdle() - assertEquals(2, executed) - } - } \ No newline at end of file diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt index 6c48e32f48..8e49746d42 100644 --- a/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt +++ b/core/kotlinx-coroutines-test/test/TestCoroutineExceptionHandlerTest.kt @@ -1,11 +1,11 @@ -import kotlinx.coroutines.test.TestCoroutineCoroutineExceptionHandler +import kotlinx.coroutines.test.TestCoroutineExceptionHandler import org.junit.Test import kotlin.test.assertEquals class TestCoroutineExceptionHandlerTest { @Test fun whenExceptionsCaught_avaliableViaProperty() { - val subject = TestCoroutineCoroutineExceptionHandler() + val subject = TestCoroutineExceptionHandler() val expected = IllegalArgumentException() subject.handleException(subject, expected) assertEquals(listOf(expected), subject.uncaughtExceptions) diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt index 376d7cd80e..43e24a846d 100644 --- a/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt +++ b/core/kotlinx-coroutines-test/test/TestCoroutineScopeTest.kt @@ -1,27 +1,22 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.newSingleThreadContext -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineCoroutineExceptionHandler import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Test +import kotlin.test.assertFails class TestCoroutineScopeTest { - @Test(expected = IllegalArgumentException::class) + @Test fun whenGivenInvalidExceptionHandler_throwsException() { val handler = CoroutineExceptionHandler { _, _ -> Unit } - val subject = TestCoroutineScope(handler) + assertFails { + TestCoroutineScope(handler) + } } - @Test(expected = IllegalArgumentException::class) + @Test fun whenGivenInvalidDispatcher_throwsException() { - val subject = TestCoroutineScope(newSingleThreadContext("incorrect call")) + assertFails { + TestCoroutineScope(newSingleThreadContext("incorrect call")) + } } - - @Test(expected = IllegalArgumentException::class) - fun whenCancelAllActions_callsCleanupTestCoroutines() { - val handler = TestCoroutineCoroutineExceptionHandler() - val subject = TestCoroutineScope(handler + TestCoroutineDispatcher()) - handler.handleException(handler, IllegalArgumentException()) - subject.cancelAllActions() - } -} \ No newline at end of file +} From 1382050a12febef7be8ad6f28bb682634be0928a Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Thu, 14 Mar 2019 16:35:26 -0700 Subject: [PATCH 08/11] Handled PR notes from Yigit --- core/kotlinx-coroutines-test/src/TestBuilders.kt | 2 +- core/kotlinx-coroutines-test/test/TestBuildersTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/kotlinx-coroutines-test/src/TestBuilders.kt b/core/kotlinx-coroutines-test/src/TestBuilders.kt index deb28276b6..63f76fe731 100644 --- a/core/kotlinx-coroutines-test/src/TestBuilders.kt +++ b/core/kotlinx-coroutines-test/src/TestBuilders.kt @@ -83,7 +83,7 @@ fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() } private fun CoroutineContext?.checkArguments(): Pair { - var safeContext= this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher() + var safeContext = this ?: TestCoroutineExceptionHandler() + TestCoroutineDispatcher() val dispatcher = safeContext[ContinuationInterceptor].run { this?.let { diff --git a/core/kotlinx-coroutines-test/test/TestBuildersTest.kt b/core/kotlinx-coroutines-test/test/TestBuildersTest.kt index 7f09576b1b..bad4d5f542 100644 --- a/core/kotlinx-coroutines-test/test/TestBuildersTest.kt +++ b/core/kotlinx-coroutines-test/test/TestBuildersTest.kt @@ -73,7 +73,7 @@ class TestBuildersTest { scope.runCurrent() assertTrue(deferred.isActive) - scope.advanceTimeToNextDelayed() + scope.advanceUntilIdle() assertEquals(3, deferred.getCompleted()) } @@ -93,7 +93,7 @@ class TestBuildersTest { retval } - scope.advanceTimeToNextDelayed() + scope.advanceTimeBy(1_000) scope.launch { assertRunsFast { assertEquals(3, deferred.getCompleted()) From f1f7057523ad7cdee4bdb580aab850921f1691cb Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Thu, 14 Mar 2019 16:37:29 -0700 Subject: [PATCH 09/11] Handled PR notes from Yigit (rest of commit) --- .../src/TestCoroutineDispatcher.kt | 102 +++++++++--------- .../src/TestCoroutineExceptionHandler.kt | 5 +- .../test/TestCoroutineDispatcherTest.kt | 41 +++---- 3 files changed, 68 insertions(+), 80 deletions(-) diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt index 8c27c18c75..58f47cfa7f 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineDispatcher.kt @@ -3,8 +3,9 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.test.internal.ThreadSafeHeap import kotlinx.coroutines.test.internal.ThreadSafeHeapNode -import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.CoroutineContext +import kotlin.math.max /** * Control the virtual clock time of a [CoroutineDispatcher]. @@ -16,37 +17,28 @@ interface DelayController { /** * Returns the current virtual clock-time as it is known to this Dispatcher. * - * @param unit The [TimeUnit] in which the clock-time must be returned. * @return The virtual clock-time */ @ExperimentalCoroutinesApi - fun currentTime(unit: TimeUnit = TimeUnit.MILLISECONDS): Long + fun currentTime(): Long /** * Moves the Dispatcher's virtual clock forward by a specified amount of time. * - * The amount the clock is progressed may be larger than the requested delayTime if the code under test uses + * The amount the clock is progressed may be larger than the requested delayTimeMillis if the code under test uses * blocking coroutines. * - * @param delayTime The amount of time to move the CoroutineContext's clock forward. - * @param unit The [TimeUnit] in which [delayTime] and the return value is expressed. + * @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 - fun advanceTimeBy(delayTime: Long, unit: TimeUnit = TimeUnit.MILLISECONDS): Long + fun advanceTimeBy(delayTimeMillis: Long): Long - /** - * Moves the current virtual clock forward just far enough so the next delay will return. - * - * @return the amount of delay-time that this Dispatcher's clock has been forwarded. - */ - @ExperimentalCoroutinesApi - fun advanceTimeToNextDelayed(): Long /** * Immediately execute all pending tasks and advance the virtual clock-time to the last delay. * - * @return the amount of delay-time that this Dispatcher's clock has been forwarded. + * @return the amount of delay-time that this Dispatcher's clock has been forwarded in milliseconds. */ @ExperimentalCoroutinesApi fun advanceUntilIdle(): Long @@ -60,7 +52,7 @@ interface DelayController { fun runCurrent() /** - * Call after a test case completes. + * Test code must call this 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 * coroutines. @@ -75,7 +67,7 @@ interface DelayController { * By pausing the dispatcher any new coroutines will not execute immediately. After block executes, the dispatcher * will resume auto-advancing. * - * This is useful when testing functions that that start a coroutine. By pausing the dispatcher assertions or + * 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 @@ -84,8 +76,8 @@ interface DelayController { /** * Pause the dispatcher. * - * When paused the dispatcher will not execute any coroutines automatically, and you must call [runCurrent], or one - * of [advanceTimeBy], [advanceTimeToNextDelayed], or [advanceUntilIdle] to execute coroutines. + * When paused, the dispatcher will not execute any coroutines automatically, and you must call [runCurrent] or + * [advanceTimeBy], or [advanceUntilIdle] to execute coroutines. */ @ExperimentalCoroutinesApi fun pauseDispatcher() @@ -94,7 +86,7 @@ interface DelayController { * Resume the dispatcher from a paused state. * * Resumed dispatchers will automatically progress through all coroutines scheduled at the current time. To advance - * time and execute coroutines scheduled in the future use one of [advanceTimeBy], [advanceTimeToNextDelayed], + * time and execute coroutines scheduled in the future use, one of [advanceTimeBy], * or [advanceUntilIdle]. */ @ExperimentalCoroutinesApi @@ -102,7 +94,7 @@ interface DelayController { } /** - * Thrown when a test has completed by there are tasks that are not completed or cancelled. + * Thrown when a test has completed and there are tasks that are not completed or cancelled. */ @ExperimentalCoroutinesApi class UncompletedCoroutinesError(message: String, cause: Throwable? = null): AssertionError(message, cause) @@ -139,10 +131,10 @@ class TestCoroutineDispatcher: private val queue = ThreadSafeHeap() // The per-scheduler global order counter. - private var counter = 0L + private var counter = AtomicLong(0) // Storing time in nanoseconds internally. - private var time = 0L + private var time = AtomicLong(0) override fun dispatch(context: CoroutineContext, block: Runnable) { if (dispatchImmediately) { @@ -165,13 +157,15 @@ class TestCoroutineDispatcher: } } - override fun toString(): String = "TestCoroutineDispatcher[time=${time}ns]" + override fun toString(): String { + return "TestCoroutineDispatcher[currentTime=${time}ms, queued=${queue.size}]" + } private fun post(block: Runnable) = - queue.addLast(TimedRunnable(block, counter++)) + queue.addLast(TimedRunnable(block, counter.getAndIncrement())) private fun postDelayed(block: Runnable, delayTime: Long) = - TimedRunnable(block, counter++, time + TimeUnit.MILLISECONDS.toNanos(delayTime)) + TimedRunnable(block, counter.getAndIncrement(), time.get() + delayTime) .also { queue.addLast(it) } @@ -181,49 +175,53 @@ class TestCoroutineDispatcher: 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 = current.time + time.getAndAccumulate(current.time) { currentValue: Long, proposedValue: Long -> + if (proposedValue != 0L) { + proposedValue + } else { + currentValue + } + } current.run() } } - override fun currentTime(unit: TimeUnit)= - unit.convert(time, TimeUnit.NANOSECONDS) + override fun currentTime() = time.get() - override fun advanceTimeBy(delayTime: Long, unit: TimeUnit): Long { - val oldTime = time - advanceUntilTime(oldTime + unit.toNanos(delayTime), TimeUnit.NANOSECONDS) - return unit.convert(time - oldTime, TimeUnit.NANOSECONDS) + override fun advanceTimeBy(delayTimeMillis: Long): Long { + val oldTime = time.get() + advanceUntilTime(oldTime + delayTimeMillis) + return time.get() - 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. - * @param unit The [TimeUnit] in which [targetTime] is expressed. + * @param targetTime The point in time to which to move the CoroutineContext's clock (milliseconds). */ - private fun advanceUntilTime(targetTime: Long, unit: TimeUnit) { - val nanoTime = unit.toNanos(targetTime) - doActionsUntil(nanoTime) - if (nanoTime > time) time = nanoTime - } - - override fun advanceTimeToNextDelayed(): Long { - val oldTime = time - runCurrent() - val next = queue.peek() ?: return 0 - advanceUntilTime(next.time, TimeUnit.NANOSECONDS) - return time - oldTime + private fun advanceUntilTime(targetTime: Long) { + doActionsUntil(targetTime) + time.getAndAccumulate(targetTime) { currentValue: Long, proposedValue: Long -> + if (currentValue < proposedValue) { + proposedValue + } else { + currentValue + } + } + if (targetTime > time.get()) time } override fun advanceUntilIdle(): Long { - val oldTime = time + val oldTime = time.get() while(!queue.isEmpty) { - advanceTimeToNextDelayed() + runCurrent() + val next = queue.peek() ?: break + advanceUntilTime(next.time) } - return time - oldTime + return time.get() - oldTime } - override fun runCurrent() = doActionsUntil(time) + override fun runCurrent() = doActionsUntil(time.get()) override suspend fun pauseDispatcher(block: suspend () -> Unit) { val previous = dispatchImmediately @@ -245,7 +243,7 @@ class TestCoroutineDispatcher: override fun cleanupTestCoroutines() { // process any pending cancellations or completions, but don't advance time - doActionsUntil(time) + doActionsUntil(time.get()) // run through all pending tasks, ignore any submitted coroutines that are not active val pendingTasks = mutableListOf() diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt index dbe659145a..e35c4ff684 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt @@ -12,7 +12,8 @@ interface UncaughtExceptionCaptor { /** * List of uncaught coroutine exceptions. * - * The returned list will be a copy of the currently caught exceptions. + * The returned list will be a copy of the currently caught exceptions. All other exceptions will + * be printed using [Throwable.printStackTrace] * * During [cleanupTestCoroutines] the first element of this list will be rethrown if it is not empty. */ @@ -48,6 +49,8 @@ class TestCoroutineExceptionHandler: UncaughtExceptionCaptor, CoroutineException override fun cleanupTestCoroutines() { synchronized(_exceptions) { val exception = _exceptions.firstOrNull() ?: return + // log the rest + _exceptions.drop(1).forEach { it.printStackTrace() } throw exception } } diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt index 3454b94b00..f7f30139b6 100644 --- a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt +++ b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt @@ -11,42 +11,29 @@ class TestCoroutineDispatcherTest { @Test fun whenStringCalled_itReturnsString() { val subject = TestCoroutineDispatcher() - assertEquals("TestCoroutineDispatcher[time=0ns]", subject.toString()) + assertEquals("TestCoroutineDispatcher[currentTime=0ms, queued=0]", subject.toString()) } @Test fun whenStringCalled_itReturnsCurrentTime() { val subject = TestCoroutineDispatcher() - subject.advanceTimeBy(1000, TimeUnit.NANOSECONDS) - assertEquals("TestCoroutineDispatcher[time=1000ns]", subject.toString()) + subject.advanceTimeBy(1000) + assertEquals("TestCoroutineDispatcher[currentTime=1000ms, queued=0]", subject.toString()) } @Test - fun whenCurrentTimeCalled_returnsTimeAsSpecified() { + fun whenStringCalled_itShowsQueuedJobs() { val subject = TestCoroutineDispatcher() - subject.advanceTimeBy(1000, TimeUnit.MILLISECONDS) - - assertEquals(1_000_000_000, subject.currentTime(TimeUnit.NANOSECONDS)) - assertEquals(1_000, subject.currentTime(TimeUnit.MILLISECONDS)) - assertEquals(1, subject.currentTime(TimeUnit.SECONDS)) - - assertEquals(1_000, subject.currentTime()) - } - - @Test - fun whenAdvanceTimeCalled_defaultsToMilliseconds() { - val subject = TestCoroutineDispatcher() - subject.advanceTimeBy(1_000) - - assertEquals(1_000, subject.currentTime(TimeUnit.MILLISECONDS)) - } - - @Test - fun whenAdvanceTimeCalled_respectsTimeUnit() { - val subject = TestCoroutineDispatcher() - subject.advanceTimeBy(1, TimeUnit.SECONDS) - - assertEquals(1_000, subject.currentTime(TimeUnit.MILLISECONDS)) + 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 From a69f73613977bda2c25523cfcae93d6f74bd9cc0 Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Thu, 14 Mar 2019 16:47:26 -0700 Subject: [PATCH 10/11] Add test about threading for immediate dispatch. --- .../test/TestCoroutineDispatcherTest.kt | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt index f7f30139b6..d10f465887 100644 --- a/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt +++ b/core/kotlinx-coroutines-test/test/TestCoroutineDispatcherTest.kt @@ -1,11 +1,11 @@ package kotlinx.coroutines.test -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import org.junit.Test import java.util.concurrent.TimeUnit import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame class TestCoroutineDispatcherTest { @Test @@ -100,4 +100,42 @@ class TestCoroutineDispatcherTest { } subject.cleanupTestCoroutines() } + + @Test + fun whenDispatchCalled_runsOnCurrentThread() { + val currentThread = Thread.currentThread() + val subject = TestCoroutineDispatcher() + val scope = TestCoroutineScope(subject) + + val deferred = scope.async(Dispatchers.Default) { + withContext(subject) { + assertNotSame(currentThread, Thread.currentThread()) + 3 + } + } + + runBlocking { + // just to ensure the above code terminates + assertEquals(3, deferred.await()) + } + } + + @Test + fun whenAllDispatchersMocked_runsOnSameThread() { + val currentThread = Thread.currentThread() + val subject = TestCoroutineDispatcher() + val scope = TestCoroutineScope(subject) + + val deferred = scope.async(subject) { + withContext(subject) { + assertSame(currentThread, Thread.currentThread()) + 3 + } + } + + runBlocking { + // just to ensure the above code terminates + assertEquals(3, deferred.await()) + } + } } \ No newline at end of file From bd025ede23e662d42db5d9671be775f5bdd214ca Mon Sep 17 00:00:00 2001 From: Sean McQuillan Date: Fri, 15 Mar 2019 14:49:45 -0700 Subject: [PATCH 11/11] Some minor comment cleanup --- .../src/TestCoroutineExceptionHandler.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt index e35c4ff684..8b731cac78 100644 --- a/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt +++ b/core/kotlinx-coroutines-test/src/TestCoroutineExceptionHandler.kt @@ -2,6 +2,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.ExperimentalCoroutinesApi +import java.util.Collections.synchronizedList import kotlin.coroutines.CoroutineContext /** @@ -12,8 +13,7 @@ interface UncaughtExceptionCaptor { /** * List of uncaught coroutine exceptions. * - * The returned list will be a copy of the currently caught exceptions. All other exceptions will - * be printed using [Throwable.printStackTrace] + * The returned list will be a copy of the currently caught exceptions. * * During [cleanupTestCoroutines] the first element of this list will be rethrown if it is not empty. */ @@ -22,6 +22,9 @@ interface UncaughtExceptionCaptor { /** * Call after the test completes. * + * The first exception in uncaughtExceptions will be rethrown. All other exceptions will + * be printed using [Throwable.printStackTrace]. + * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions */ fun cleanupTestCoroutines()