Skip to content

Commit

Permalink
Implement new test dispatchers (#2986)
Browse files Browse the repository at this point in the history
Defines two test dispatchers:
* StandardTestDispatcher, which, combined with runTest,
  gives an illusion of an event loop;
* UnconfinedTestDispatcher, which is like
  Dispatchers.Unconfined, but skips delays.

By default, StandardTestDispatcher is used due to the somewhat
chaotic execution order of Dispatchers.Unconfined.
TestCoroutineDispatcher is deprecated.

Fixes #1626
Fixes #1742
Fixes #2082
Fixes #2102
Fixes #2405
Fixes #2462
  • Loading branch information
dkhalanskyjb committed Nov 19, 2021
1 parent d9bc7ac commit 5c7d034
Show file tree
Hide file tree
Showing 23 changed files with 629 additions and 147 deletions.
9 changes: 9 additions & 0 deletions kotlinx-coroutines-core/api/kotlinx-coroutines-core.api
Expand Up @@ -556,6 +556,15 @@ public final class kotlinx/coroutines/TimeoutKt {
public static final fun withTimeoutOrNull-KLykuaI (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class kotlinx/coroutines/YieldContext : kotlin/coroutines/AbstractCoroutineContextElement {
public static final field Key Lkotlinx/coroutines/YieldContext$Key;
public field dispatcherWasUnconfined Z
public fun <init> ()V
}

public final class kotlinx/coroutines/YieldContext$Key : kotlin/coroutines/CoroutineContext$Key {
}

public final class kotlinx/coroutines/YieldKt {
public static final fun yield (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
Expand Down
1 change: 1 addition & 0 deletions kotlinx-coroutines-core/common/src/Unconfined.kt
Expand Up @@ -38,6 +38,7 @@ internal object Unconfined : CoroutineDispatcher() {
/**
* Used to detect calls to [Unconfined.dispatch] from [yield] function.
*/
@PublishedApi
internal class YieldContext : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<YieldContext>

Expand Down
10 changes: 9 additions & 1 deletion kotlinx-coroutines-test/api/kotlinx-coroutines-test.api
Expand Up @@ -40,6 +40,13 @@ public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/cor
public fun toString ()Ljava/lang/String;
}

public final class kotlinx/coroutines/test/TestCoroutineDispatchersKt {
public static final fun StandardTestDispatcher (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)Lkotlinx/coroutines/test/TestDispatcher;
public static synthetic fun StandardTestDispatcher$default (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestDispatcher;
public static final fun UnconfinedTestDispatcher (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;)Lkotlinx/coroutines/test/TestDispatcher;
public static synthetic fun UnconfinedTestDispatcher$default (Lkotlinx/coroutines/test/TestCoroutineScheduler;Ljava/lang/String;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestDispatcher;
}

public final class kotlinx/coroutines/test/TestCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler, kotlinx/coroutines/test/UncaughtExceptionCaptor {
public fun <init> ()V
public fun cleanupTestCoroutinesCaptor ()V
Expand Down Expand Up @@ -69,6 +76,8 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
public static synthetic fun TestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestCoroutineScope;J)V
public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestCoroutineScope;)V
public static final fun createTestCoroutineScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestCoroutineScope;
public static synthetic fun createTestCoroutineScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestCoroutineScope;
public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestCoroutineScope;)J
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;)V
public static final fun pauseDispatcher (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand All @@ -77,7 +86,6 @@ public final class kotlinx/coroutines/test/TestCoroutineScopeKt {
}

public abstract class kotlinx/coroutines/test/TestDispatcher : kotlinx/coroutines/CoroutineDispatcher, kotlinx/coroutines/Delay {
public fun <init> ()V
public fun delay (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun getScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
public fun invokeOnTimeout (JLjava/lang/Runnable;Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/DisposableHandle;
Expand Down
20 changes: 15 additions & 5 deletions kotlinx-coroutines-test/common/src/DelayController.kt
@@ -1,6 +1,7 @@
/*
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/
@file:Suppress("DEPRECATION")

package kotlinx.coroutines.test

Expand Down Expand Up @@ -102,7 +103,10 @@ public interface DelayController {
* This is useful when testing functions that start a coroutine. By pausing the dispatcher assertions or
* setup may be done between the time the coroutine is created and started.
*/
@ExperimentalCoroutinesApi
@Deprecated(
"Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.",
level = DeprecationLevel.WARNING
)
public suspend fun pauseDispatcher(block: suspend () -> Unit)

/**
Expand All @@ -111,7 +115,10 @@ public interface DelayController {
* When paused, the dispatcher will not execute any coroutines automatically, and you must call [runCurrent] or
* [advanceTimeBy], or [advanceUntilIdle] to execute coroutines.
*/
@ExperimentalCoroutinesApi
@Deprecated(
"Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.",
level = DeprecationLevel.WARNING
)
public fun pauseDispatcher()

/**
Expand All @@ -121,12 +128,15 @@ public interface DelayController {
* time and execute coroutines scheduled in the future use, one of [advanceTimeBy],
* or [advanceUntilIdle].
*/
@ExperimentalCoroutinesApi
@Deprecated(
"Please use a dispatcher that is paused by default, like `StandardTestDispatcher`.",
level = DeprecationLevel.WARNING
)
public fun resumeDispatcher()
}

internal interface SchedulerAsDelayController : DelayController {
public val scheduler: TestCoroutineScheduler
val scheduler: TestCoroutineScheduler

/** @suppress */
@Deprecated(
Expand Down Expand Up @@ -178,7 +188,7 @@ internal interface SchedulerAsDelayController : DelayController {
scheduler.runCurrent()
if (!scheduler.isIdle()) {
throw UncompletedCoroutinesError(
"Unfinished coroutines during teardown. Ensure all coroutines are" +
"Unfinished coroutines during tear-down. Ensure all coroutines are" +
" completed or cancelled by your test."
)
}
Expand Down
9 changes: 4 additions & 5 deletions kotlinx-coroutines-test/common/src/TestBuilders.kt
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 @@ -197,10 +197,9 @@ public expect class TestResult
*
* [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine
* scope created for the test, [context] also can be used to change how the test is executed.
* See the [TestCoroutineScope] constructor documentation for details.
* See the [createTestCoroutineScope] documentation for details.
*
* @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for
* details.
* @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details.
*/
@ExperimentalCoroutinesApi
public fun runTest(
Expand All @@ -210,7 +209,7 @@ public fun runTest(
): TestResult {
if (context[RunningInRunTest] != null)
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
val testScope = TestBodyCoroutine<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
10 changes: 3 additions & 7 deletions kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt
Expand Up @@ -21,7 +21,9 @@ import kotlin.coroutines.*
*
* @see DelayController
*/
@ExperimentalCoroutinesApi
@Deprecated("The execution order of `TestCoroutineDispatcher` can be confusing, and the mechanism of " +
"pausing is typically misunderstood. Please use `StandardTestDispatcher` or `UnconfinedTestDispatcher` instead.",
level = DeprecationLevel.WARNING)
public class TestCoroutineDispatcher(public override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler()):
TestDispatcher(), Delay, SchedulerAsDelayController
{
Expand All @@ -34,12 +36,6 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin
}
}

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

/** @suppress */
override fun dispatch(context: CoroutineContext, block: Runnable) {
checkSchedulerInContext(scheduler, context)
Expand Down
129 changes: 129 additions & 0 deletions kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt
@@ -0,0 +1,129 @@
/*
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.flow.*
import kotlin.coroutines.*

/**
* Creates an instance of an unconfined [TestDispatcher].
*
* This dispatcher is similar to [Dispatchers.Unconfined]: the tasks that it executes are not confined to any particular
* thread and form an event loop; it's different in that it skips delays, as all [TestDispatcher]s do.
*
* Using this [TestDispatcher] can greatly simplify writing tests where it's not important which thread is used when and
* in which order the queued coroutines are executed.
* The typical use case for this is launching child coroutines that are resumed immediately, without going through a
* dispatch; this can be helpful for testing [Channel] and [StateFlow] usages.
*
* ```
* @Test
* fun testUnconfinedDispatcher() = runTest {
* val values = mutableListOf<Int>()
* val stateFlow = MutableStateFlow(0)
* val job = launch(UnconfinedTestDispatcher(testScheduler)) {
* stateFlow.collect {
* values.add(it)
* }
* }
* stateFlow.value = 1
* stateFlow.value = 2
* stateFlow.value = 3
* job.cancel()
* // each assignment will immediately resume the collecting child coroutine,
* // so no values will be skipped.
* assertEquals(listOf(0, 1, 2, 3), values)
* }
* ```
*
* However, please be aware that, like [Dispatchers.Unconfined], this is a specific dispatcher with execution order
* guarantees that are unusual and not shared by most other dispatchers, so it can only be used reliably for testing
* functionality, not the specific order of actions.
* See [Dispatchers.Unconfined] for a discussion of the execution order guarantees.
*
* In order to support delay skipping, this dispatcher is linked to a [TestCoroutineScheduler], which is used to control
* the virtual time and can be shared among many test dispatchers. If no [scheduler] is passed as an argument, a new one
* is created.
*
* Additionally, [name] can be set to distinguish each dispatcher instance when debugging.
*
* @see StandardTestDispatcher for a more predictable [TestDispatcher].
*/
@ExperimentalCoroutinesApi
@Suppress("FunctionName")
public fun UnconfinedTestDispatcher(
scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
name: String? = null
): TestDispatcher = UnconfinedTestDispatcherImpl(scheduler, name)

private class UnconfinedTestDispatcherImpl(
override val scheduler: TestCoroutineScheduler,
private val name: String? = null
): TestDispatcher() {

override fun isDispatchNeeded(context: CoroutineContext): Boolean = false

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

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

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

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

private class StandardTestDispatcherImpl(
override val scheduler: TestCoroutineScheduler = TestCoroutineScheduler(),
private val name: String? = null
): TestDispatcher() {

override fun dispatch(context: CoroutineContext, block: Runnable) {
checkSchedulerInContext(scheduler, context)
scheduler.registerEvent(this, 0, block) { false }
}

override fun toString(): String = "${name ?: "StandardTestDispatcher"}[scheduler=$scheduler]"
}

0 comments on commit 5c7d034

Please sign in to comment.