Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
dkhalanskyjb committed Oct 27, 2021
1 parent ba0cccd commit 51950b5
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 34 deletions.
21 changes: 21 additions & 0 deletions kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ public abstract interface class kotlinx/coroutines/test/DelayController {
public abstract fun runCurrent ()V
}

public final class kotlinx/coroutines/test/StandardTestDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay {
public fun <init> ()V
public fun <init> (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)V
public synthetic fun <init> (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun dispatch (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V
public fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
public fun toString ()Ljava/lang/String;
}

public final class kotlinx/coroutines/test/TestBuildersKt {
public static final fun runBlockingTest (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V
public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineDispatcher;Lkotlin/jvm/functions/Function2;)V
Expand Down Expand Up @@ -69,6 +78,8 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V
public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V
public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope;
public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand All @@ -94,3 +105,13 @@ public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor
public abstract fun getUncaughtExceptions ()Ljava/util/List;
}

public final class kotlinx/coroutines/test/UnconfinedTestDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay {
public fun <init> ()V
public fun <init> (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)V
public synthetic fun <init> (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun dispatch (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Runnable;)V
public fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
public fun isDispatchNeeded (Lkotlin/coroutines/CoroutineContext;)Z
public fun toString ()Ljava/lang/String;
}

9 changes: 4 additions & 5 deletions kotlinx-coroutines-test/common/src/TestBuilders.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import kotlin.coroutines.*
*/
@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING)
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context)
val scheduler = scope.testScheduler
val deferred = scope.async {
scope.testBody()
Expand Down Expand Up @@ -167,10 +167,9 @@ public expect class TestResult
*
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
* scope created for the test, [context] also can be used to change how the test is executed.
* See the [TestCoroutineScope] constructor documentation for details.
* See the [createTestCoroutineScope] documentation for details.
*
* @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for
* details.
* @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details.
*/
@DelicateCoroutinesApi
public fun runTest(
Expand All @@ -180,7 +179,7 @@ public fun runTest(
): TestResult {
if (context[RunningInRunTest] != null)
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
val testScope = TestBodyCoroutine<Unit>(TestCoroutineScope(context + RunningInRunTest))
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest))
val scheduler = testScope.testScheduler
return createTestResult {
/** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with
Expand Down
114 changes: 114 additions & 0 deletions kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlin.coroutines.*

/**
* An unconfined [TestDispatcher].
*
* This dispatcher is similar to [Dispatchers.Unconfined]: the tasks that it executes are not confined to any particular
* thread and form an event loop; it's different in that it skips delays, as all [TestDispatcher]s do.
*
* Using this [TestDispatcher] can greatly simplify writing tests where it's not important which thread is used when and
* in which order the queued coroutines are executed. However, please be aware that, like [Dispatchers.Unconfined], this
* is a specific dispatcher with execution order guarantees that are unusual and not shared by most other dispatchers,
* so it can only be used reliably for testing functionality, not the specific order of actions.
* See [Dispatchers.Unconfined] for a discussion of the execution order guarantees.
*
* In order to support delay skipping, this dispatcher is linked to a [TestCoroutineScheduler], which is used to control
* the virtual time and can be shared among many test dispatchers. If no [scheduler] is passed as an argument, a new one
* is created.
*
* Additionally, [name] can be set to distinguish each dispatcher instance when debugging.
*
* @see StandardTestDispatcher for a more predictable [TestDispatcher] that, however, requires interacting with the
* scheduler in order for the tasks to run.
*/
@ExperimentalCoroutinesApi
public class UnconfinedTestDispatcher(
public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
private val name: String? = null
): TestDispatcher(), Delay {

/** @suppress */
override fun processEvent(time: Long, marker: Any) {
check(marker is Runnable)
marker.run()
}

/** @suppress */
override fun isDispatchNeeded(context: CoroutineContext): Boolean = false

/** @suppress */
@Suppress("INVISIBLE_MEMBER")
override fun dispatch(context: CoroutineContext, block: Runnable) {
checkSchedulerInContext(scheduler, context)
scheduler.sendDispatchEvent()

/** copy-pasted from [kotlinx.coroutines.Unconfined.dispatch] */
/** It can only be called by the [yield] function. See also code of [yield] function. */
val yieldContext = context[YieldContext]
if (yieldContext !== null) {
// report to "yield" that it is an unconfined dispatcher and don't call "block.run()"
yieldContext.dispatcherWasUnconfined = true
return
}
throw UnsupportedOperationException(
"Function UnconfinedTestCoroutineDispatcher.dispatch can only be used by " +
"the yield function. If you wrap Unconfined dispatcher in your code, make sure you properly delegate " +
"isDispatchNeeded and dispatch calls."
)
}

/** @suppress */
override fun toString(): String = "${name ?: "UnconfinedTestDispatcher"}[scheduler=$scheduler]"

}

/**
* A [TestDispatcher] instance whose tasks are run inside calls to the [scheduler].
*
* This [TestDispatcher] instance does not itself execute any of the tasks. Instead, it always sends them to its
* [scheduler], which can then be accessed via [TestCoroutineScheduler.runCurrent],
* [TestCoroutineScheduler.advanceUntilIdle], or [TestCoroutineScheduler.advanceTimeBy], which will then execute these
* tasks in a blocking manner.
*
* In practice, this means that [launch] or [async] blocks will not be entered immediately (unless they are
* parameterized with [CoroutineStart.UNDISPATCHED]), and one should either call [TestCoroutineScheduler.runCurrent] to
* run these pending tasks, which will block until there are no more tasks scheduled at this point in time, or, when
* inside [runTest], call [yield] to yield the (only) thread used by [runTest] to the newly-launched coroutines.
*
* If a [scheduler] is not passed as an argument, a new one is created.
*
* One can additionally pass a [name] in order to more easily distinguish this dispatcher during debugging.
*
* @see UnconfinedTestDispatcher for a dispatcher that immediately enters [launch] and [async] blocks and is not
* confined to any particular thread.
*/
@ExperimentalCoroutinesApi
public class StandardTestDispatcher(
public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
private val name: String? = null
): TestDispatcher(), Delay {

/** @suppress */
override fun processEvent(time: Long, marker: Any) {
check(marker is Runnable)
marker.run()
}

/** @suppress */
@Suppress("INVISIBLE_MEMBER")
override fun dispatch(context: CoroutineContext, block: Runnable) {
checkSchedulerInContext(scheduler, context)
scheduler.registerEvent(this, 0, block) { false }
}

/** @suppress */
override fun toString(): String = "${name ?: "ConfinedTestDispatcher"}[scheduler=$scheduler]"

}
49 changes: 39 additions & 10 deletions kotlinx-coroutines-test/common/src/TestCoroutineScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,18 @@ private class TestCoroutineScopeImpl(
override fun cleanupTestCoroutines() {
try {
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
coroutineContext.delayController?.cleanupTestCoroutines()
val delayController = coroutineContext.delayController
if (delayController != null) {
delayController.cleanupTestCoroutines()
} else {
testScheduler.runCurrent()
if (!testScheduler.isIdle()) {
throw UncompletedCoroutinesError(
"Unfinished coroutines during teardown. Ensure all coroutines are" +
" completed or cancelled by your test."
)
}
}
val jobs = coroutineContext.activeJobs()
if ((jobs - initialJobs).isNotEmpty())
throw UncompletedCoroutinesError("Test finished with active jobs: $jobs")
Expand All @@ -63,13 +74,29 @@ private fun CoroutineContext.activeJobs(): Set<Job> {
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
}

/**
* A coroutine scope for launching test coroutines using [TestCoroutineDispatcher].
*
* [createTestCoroutineScope] is a similar function that defaults to [StandardTestDispatcher].
*/
@Deprecated("This constructs a `TestCoroutineScope` with a deprecated `CoroutineDispatcher` by default. " +
"Please use `createTestCoroutineScope` instead.",
ReplaceWith("createTestCoroutineScope(TestCoroutineDispatcher() + context)",
"kotlin.coroutines.EmptyCoroutineContext"),
level = DeprecationLevel.WARNING
)
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
val scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler()
return createTestCoroutineScope(TestCoroutineDispatcher(scheduler) + context)
}

/**
* A coroutine scope for launching test coroutines.
*
* It ensures that all the test module machinery is properly initialized.
* * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping,
* a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used.
* * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created.
* * If [context] doesn't have a [ContinuationInterceptor], a [StandardTestDispatcher] is created.
* * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created
* automatically.
* * If [context] provides a [Job], that job is used for the new scope, but is not completed once the scope completes.
Expand All @@ -82,9 +109,8 @@ private fun CoroutineContext.activeJobs(): Set<Job> {
* @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an
* [UncaughtExceptionCaptor].
*/
@Suppress("FunctionName")
@ExperimentalCoroutinesApi
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
val scheduler: TestCoroutineScheduler
val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) {
is TestDispatcher -> {
Expand All @@ -100,7 +126,7 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext)
}
null -> {
scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler()
TestCoroutineDispatcher(scheduler)
StandardTestDispatcher(scheduler)
}
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
}
Expand Down Expand Up @@ -176,7 +202,7 @@ public fun TestCoroutineScope.advanceTimeBy(delayTimeMillis: Long): Unit =
* @see TestCoroutineScheduler.advanceUntilIdle
*/
@ExperimentalCoroutinesApi
public fun TestCoroutineScope.advanceUntilIdle(): Unit {
public fun TestCoroutineScope.advanceUntilIdle() {
coroutineContext.delayController?.advanceUntilIdle() ?: testScheduler.advanceUntilIdle()
}

Expand All @@ -187,13 +213,14 @@ public fun TestCoroutineScope.advanceUntilIdle(): Unit {
* @see TestCoroutineScheduler.runCurrent
*/
@ExperimentalCoroutinesApi
public fun TestCoroutineScope.runCurrent(): Unit {
public fun TestCoroutineScope.runCurrent() {
coroutineContext.delayController?.runCurrent() ?: testScheduler.runCurrent()
}

@ExperimentalCoroutinesApi
@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " +
"Only `TestCoroutineDispatcher` supports pausing; pause it directly.",
"Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " +
"\"paused\", like `StandardTestDispatcher`.",
ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher(block)",
"kotlin.coroutines.ContinuationInterceptor"),
DeprecationLevel.WARNING)
Expand All @@ -203,7 +230,8 @@ public suspend fun TestCoroutineScope.pauseDispatcher(block: suspend () -> Unit)

@ExperimentalCoroutinesApi
@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " +
"Only `TestCoroutineDispatcher` supports pausing; pause it directly.",
"Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " +
"\"paused\", like `StandardTestDispatcher`.",
ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher()",
"kotlin.coroutines.ContinuationInterceptor"),
level = DeprecationLevel.WARNING)
Expand All @@ -213,7 +241,8 @@ public fun TestCoroutineScope.pauseDispatcher() {

@ExperimentalCoroutinesApi
@Deprecated("The test coroutine scope isn't able to pause its dispatchers in the general case. " +
"Only `TestCoroutineDispatcher` supports pausing; pause it directly.",
"Only `TestCoroutineDispatcher` supports pausing; pause it directly, or use a dispatcher that is always " +
"\"paused\", like `StandardTestDispatcher`.",
ReplaceWith("(this.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher()",
"kotlin.coroutines.ContinuationInterceptor"),
level = DeprecationLevel.WARNING)
Expand Down
2 changes: 1 addition & 1 deletion kotlinx-coroutines-test/common/test/RunTestTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ class RunTestTest {

@Test
fun reproducer2405() = runTest {
val dispatcher = TestCoroutineDispatcher(testScheduler)
val dispatcher = StandardTestDispatcher(testScheduler)
var collectedError = false
withContext(dispatcher) {
flow { emit(1) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class TestCoroutineSchedulerTest {
@Test
fun testAdvanceTimeBy() = assertRunsFast {
val scheduler = TestCoroutineScheduler()
val scope = TestCoroutineScope(scheduler)
val scope = createTestCoroutineScope(scheduler)
var stage = 1
scope.launch {
delay(1_000)
Expand Down Expand Up @@ -126,7 +126,7 @@ class TestCoroutineSchedulerTest {
@Test
fun testRunCurrentNotDrainingQueue() = assertRunsFast {
val scheduler = TestCoroutineScheduler()
val scope = TestCoroutineScope(scheduler)
val scope = createTestCoroutineScope(scheduler)
var stage = 1
scope.launch {
delay(SLOW)
Expand All @@ -149,7 +149,7 @@ class TestCoroutineSchedulerTest {
@Test
fun testNestedAdvanceUntilIdle() = assertRunsFast {
val scheduler = TestCoroutineScheduler()
val scope = TestCoroutineScope(scheduler)
val scope = createTestCoroutineScope(scheduler)
var executed = false
scope.launch {
launch {
Expand All @@ -165,7 +165,7 @@ class TestCoroutineSchedulerTest {
/** Tests [yield] scheduling tasks for future execution and not executing immediately. */
@Test
fun testYield() {
val scope = TestCoroutineScope()
val scope = createTestCoroutineScope()
var stage = 0
scope.launch {
yield()
Expand Down Expand Up @@ -206,7 +206,7 @@ class TestCoroutineSchedulerTest {
/** Tests that timeouts get triggered. */
@Test
fun testSmallTimeouts() {
val scope = TestCoroutineScope()
val scope = createTestCoroutineScope(TestCoroutineDispatcher())
scope.checkTimeout(true) {
val half = SLOW / 2
delay(half)
Expand All @@ -217,7 +217,7 @@ class TestCoroutineSchedulerTest {
/** Tests that timeouts don't get triggered if the code finishes in time. */
@Test
fun testLargeTimeouts() {
val scope = TestCoroutineScope()
val scope = createTestCoroutineScope()
scope.checkTimeout(false) {
val half = SLOW / 2
delay(half)
Expand All @@ -228,7 +228,7 @@ class TestCoroutineSchedulerTest {
/** Tests that timeouts get triggered if the code fails to finish in time asynchronously. */
@Test
fun testSmallAsynchronousTimeouts() {
val scope = TestCoroutineScope()
val scope = createTestCoroutineScope()
val deferred = CompletableDeferred<Unit>()
scope.launch {
val half = SLOW / 2
Expand All @@ -244,7 +244,7 @@ class TestCoroutineSchedulerTest {
/** Tests that timeouts don't get triggered if the code finishes in time, even if it does so asynchronously. */
@Test
fun testLargeAsynchronousTimeouts() {
val scope = TestCoroutineScope()
val scope = createTestCoroutineScope()
val deferred = CompletableDeferred<Unit>()
scope.launch {
val half = SLOW / 2
Expand Down

0 comments on commit 51950b5

Please sign in to comment.