From d08b86013c5d02256164941e50e4ba55c4de1e43 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 11 Nov 2021 18:15:43 +0300 Subject: [PATCH 1/8] Implement TestScope --- .../api/kotlinx-coroutines-test.api | 31 ++- .../common/src/TestBuilders.kt | 247 ++++++------------ .../common/src/TestCoroutineScheduler.kt | 6 +- .../common/src/TestScope.kt | 219 ++++++++++++++++ .../src/{ => migration}/DelayController.kt | 5 +- .../src/migration/TestBuildersDeprecated.kt | 173 ++++++++++++ .../TestCoroutineDispatcher.kt | 0 .../TestCoroutineExceptionHandler.kt | 1 - .../src/{ => migration}/TestCoroutineScope.kt | 45 ++-- .../common/test/RunTestTest.kt | 6 +- .../common/test/StandardTestDispatcherTest.kt | 7 +- .../common/test/TestCoroutineSchedulerTest.kt | 69 +++-- .../common/test/TestScopeTest.kt | 173 ++++++++++++ .../{ => migration}/TestCoroutineScopeTest.kt | 5 +- 14 files changed, 740 insertions(+), 247 deletions(-) create mode 100644 kotlinx-coroutines-test/common/src/TestScope.kt rename kotlinx-coroutines-test/common/src/{ => migration}/DelayController.kt (98%) create mode 100644 kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt rename kotlinx-coroutines-test/common/src/{ => migration}/TestCoroutineDispatcher.kt (100%) rename kotlinx-coroutines-test/common/src/{ => migration}/TestCoroutineExceptionHandler.kt (99%) rename kotlinx-coroutines-test/common/src/{ => migration}/TestCoroutineScope.kt (90%) create mode 100644 kotlinx-coroutines-test/common/test/TestScopeTest.kt rename kotlinx-coroutines-test/common/test/{ => migration}/TestCoroutineScopeTest.kt (98%) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index f3a69b9f1c..9e652411df 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -9,17 +9,25 @@ public abstract interface class kotlinx/coroutines/test/DelayController { public abstract fun runCurrent ()V } -public final class kotlinx/coroutines/test/TestBuildersKt { +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 final fun runBlockingTest (Lkotlinx/coroutines/test/TestScope;Lkotlin/jvm/functions/Function2;)V public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V - public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static final fun runBlockingTestOnTestScope (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V + public static synthetic fun runBlockingTestOnTestScope$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V - public static final fun runTest (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;)V - public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V - public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V +} + +public final class kotlinx/coroutines/test/TestBuildersKt { + public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static final fun runTest (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController { @@ -98,6 +106,19 @@ public final class kotlinx/coroutines/test/TestDispatchers { public static final fun setMain (Lkotlinx/coroutines/Dispatchers;Lkotlinx/coroutines/CoroutineDispatcher;)V } +public abstract interface class kotlinx/coroutines/test/TestScope : kotlinx/coroutines/CoroutineScope { + public abstract fun getTestScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; +} + +public final class kotlinx/coroutines/test/TestScopeKt { + public static final fun TestScope (Lkotlin/coroutines/CoroutineContext;)Lkotlinx/coroutines/test/TestScope; + public static synthetic fun TestScope$default (Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)Lkotlinx/coroutines/test/TestScope; + public static final fun advanceTimeBy (Lkotlinx/coroutines/test/TestScope;J)V + public static final fun advanceUntilIdle (Lkotlinx/coroutines/test/TestScope;)V + public static final fun getCurrentTime (Lkotlinx/coroutines/test/TestScope;)J + public static final fun runCurrent (Lkotlinx/coroutines/test/TestScope;)V +} + public abstract interface class kotlinx/coroutines/test/UncaughtExceptionCaptor { public abstract fun cleanupTestCoroutines ()V public abstract fun getUncaughtExceptions ()Ljava/util/List; diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index e1f7c0a908..012caf2767 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -8,72 +8,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.selects.* import kotlin.coroutines.* -/** - * Executes a [testBody] inside an immediate execution dispatcher. - * - * This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks. - * You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take - * extra time. - * - * ``` - * @Test - * fun exampleTest() = runBlockingTest { - * val deferred = async { - * delay(1_000) - * async { - * delay(1_000) - * }.await() - * } - * - * deferred.await() // result available immediately - * } - * - * ``` - * - * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test - * conditions. - * - * Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test. - * - * @throws AssertionError If the [testBody] does not complete (or cancel) all coroutines that it launches - * (including coroutines suspended on join/await). - * - * @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler], - * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively. - * @param testBody The code of the unit-test. - */ -@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) -public fun runBlockingTest( - context: CoroutineContext = EmptyCoroutineContext, - testBody: suspend TestCoroutineScope.() -> Unit -) { - val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) - val scheduler = scope.testScheduler - val deferred = scope.async { - scope.testBody() - } - scheduler.advanceUntilIdle() - deferred.getCompletionExceptionOrNull()?.let { - throw it - } - scope.cleanupTestCoroutines() -} - -/** - * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. - */ -// todo: need documentation on how this extension is supposed to be used -@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) -public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = - runBlockingTest(coroutineContext, block) - -/** - * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. - */ -@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) -public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = - runBlockingTest(this, block) - /** * A test result. * @@ -96,7 +30,7 @@ public expect class TestResult /** * Executes [testBody] as a test in a new coroutine, returning [TestResult]. * - * On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs + * On JVM and Native, this function behaves similarly to `runBlocking`, with the difference that the code that it runs * will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary. * On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior. * @@ -154,7 +88,7 @@ public expect class TestResult * then its [TestCoroutineScheduler] is used; * otherwise, a new one is automatically created (or taken from [context] in some way) and can be used to control * the virtual time, advancing it, running the tasks scheduled at a specific time etc. - * Some convenience methods are available on [TestCoroutineScope] to control the scheduler. + * Some convenience methods are available on [TestScope] to control the scheduler. * * Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped: * ``` @@ -202,105 +136,43 @@ public expect class TestResult * * [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine * scope created for the test, [context] also can be used to change how the test is executed. - * See the [createTestCoroutineScope] documentation for details. + * See the [TestScope] constructor function documentation for details. * - * @throws IllegalArgumentException if the [context] is invalid. See the [createTestCoroutineScope] docs for details. + * @throws IllegalArgumentException if the [context] is invalid. See the [TestScope] constructor docs for details. */ @ExperimentalCoroutinesApi public fun runTest( context: CoroutineContext = EmptyCoroutineContext, dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, - testBody: suspend TestCoroutineScope.() -> Unit + testBody: suspend TestScope.() -> Unit ): TestResult { if (context[RunningInRunTest] != null) throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") - val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest)) - val scheduler = testScope.testScheduler - return createTestResult { - /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with - * [TestCoroutineDispatcher], because the event loop is not started. */ - testScope.start(CoroutineStart.UNDISPATCHED, testScope) { - testBody() - } - var completed = false - while (!completed) { - scheduler.advanceUntilIdle() - if (testScope.isCompleted) { - /* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no - non-trivial dispatches. */ - completed = true - continue - } - select { - testScope.onJoin { - completed = true - } - scheduler.onDispatchEvent { - // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout - } - onTimeout(dispatchTimeoutMs) { - try { - testScope.cleanup() - } catch (e: UncompletedCoroutinesError) { - // we expect these and will instead throw a more informative exception just below. - } - throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") - } - } - } - testScope.getCompletionExceptionOrNull()?.let { - try { - testScope.cleanup() - } catch (e: UncompletedCoroutinesError) { - // it's normal that some jobs are not completed if the test body has failed, won't clutter the output - } catch (e: Throwable) { - it.addSuppressed(e) - } - throw it - } - testScope.cleanup() - } + return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs, testBody) } /** - * Runs [testProcedure], creating a [TestResult]. - */ -@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` -internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult - -/** - * Runs a test in a [TestCoroutineScope] based on this one. - * - * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the - * [block] will be different from this one, but will use its [Job] as a parent. - * - * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned - * immediately from the test body. See the docs for [TestResult] for details. + * Performs [runTest] on an existing [TestScope]. */ @ExperimentalCoroutinesApi -public fun TestCoroutineScope.runTest( +public fun TestScope.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, - block: suspend TestCoroutineScope.() -> Unit -): TestResult = - runTest(coroutineContext, dispatchTimeoutMs, block) + testBody: suspend TestScope.() -> Unit +): TestResult = asSpecificImplementation().let { + it.enter() + createTestResult { + runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.finish() } + } +} /** - * Run a test using this [TestDispatcher]. - * - * A convenience function that calls [runTest] with the given arguments. - * - * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned - * immediately from the test body. See the docs for [TestResult] for details. + * Runs [testProcedure], creating a [TestResult]. */ -@ExperimentalCoroutinesApi -public fun TestDispatcher.runTest( - dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, - block: suspend TestCoroutineScope.() -> Unit -): TestResult = - runTest(this, dispatchTimeoutMs, block) +@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` +internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult /** A coroutine context element indicating that the coroutine is running inside `runTest`. */ -private object RunningInRunTest : CoroutineContext.Key, CoroutineContext.Element { +internal object RunningInRunTest : CoroutineContext.Key, CoroutineContext.Element { override val key: CoroutineContext.Key<*> get() = this @@ -309,24 +181,69 @@ private object RunningInRunTest : CoroutineContext.Key, Corout /** The default timeout to use when waiting for asynchronous completions of the coroutines managed by * a [TestCoroutineScheduler]. */ -private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L - -private class TestBodyCoroutine( - private val testScope: TestCoroutineScope, -) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope { - - override val testScheduler get() = testScope.testScheduler +internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L - @Deprecated( - "This deprecation is to prevent accidentally calling `cleanupTestCoroutines` in our own code.", - ReplaceWith("this.cleanup()"), - DeprecationLevel.ERROR - ) - override fun cleanupTestCoroutines() = - throw UnsupportedOperationException( - "Calling `cleanupTestCoroutines` inside `runTest` is prohibited: " + - "it will be called at the end of the test in any case." - ) +/** + * Run the [body][testBody] of the [test coroutine][coroutine], waiting for asynchronous completions for at most + * [dispatchTimeoutMs] milliseconds, and performing the [cleanup] procedure at the end. + * + * The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or + * return a list of uncaught exceptions that should be reported at the end of the test. + */ +internal suspend fun > runTestCoroutine( + coroutine: T, + dispatchTimeoutMs: Long, + testBody: suspend T.() -> Unit, + cleanup: () -> List, +) { + val scheduler = coroutine.coroutineContext[TestCoroutineScheduler]!! + /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with + * [TestCoroutineDispatcher], because the event loop is not started. */ + coroutine.start(CoroutineStart.UNDISPATCHED, coroutine) { + testBody() + } + var completed = false + while (!completed) { + scheduler.advanceUntilIdle() + if (coroutine.isCompleted) { + /* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no + non-trivial dispatches. */ + completed = true + continue + } + select { + coroutine.onJoin { + completed = true + } + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } + onTimeout(dispatchTimeoutMs) { + try { + cleanup() + } catch (e: UncompletedCoroutinesError) { + // we expect these and will instead throw a more informative exception just below. + emptyList() + }.throwAll() + throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") + } + } + } + coroutine.getCompletionExceptionOrNull()?.let { exception -> + val exceptions = try { + cleanup() + } catch (e: UncompletedCoroutinesError) { + // it's normal that some jobs are not completed if the test body has failed, won't clutter the output + emptyList() + } + (listOf(exception) + exceptions).throwAll() + } + cleanup().throwAll() +} - fun cleanup() = testScope.cleanupTestCoroutines() +internal fun List.throwAll() { + firstOrNull()?.apply { + drop(1).forEach { addSuppressed(it) } + throw this + } } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 2acd8e527f..d256f27fb0 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -169,9 +169,11 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout /** * Checks that the only tasks remaining in the scheduler are cancelled. */ - // TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap] - internal fun isIdle(): Boolean { + internal fun isIdle(strict: Boolean = true): Boolean { synchronized(lock) { + if (strict) + return events.isEmpty + // TODO: also completely empties the queue, as there's no nondestructive way to iterate over [ThreadSafeHeap] val presentEvents = mutableListOf>() while (true) { presentEvents += events.removeFirstOrNull() ?: break diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt new file mode 100644 index 0000000000..1f8204321d --- /dev/null +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.internal.* +import kotlin.coroutines.* + +/** + * A coroutine scope that for launching test coroutines. + * + * The scope provides the following functionality: + * * The [coroutineContext] includes a [coroutine dispatcher][TestDispatcher] that supports delay-skipping, using + * a [TestCoroutineScheduler] for orchestrating the virtual time. + * This scheduler is also available via the [testScheduler] property, and some helper extension + * methods are defined to more conveniently interact with it: see [TestScope.currentTime], [TestScope.runCurrent], + * [TestScope.advanceTimeBy], and [TestScope.advanceUntilIdle]. + * * When inside [runTest], uncaught exceptions from the child coroutines of this scope will be reported at the end of + * the test. + * It is invalid for child coroutines to throw uncaught exceptions when outside the call to [TestScope.runTest]: + * the only guarantee in this case is the best effort to deliver the exception. + * + * The usual way to access a [TestScope] is to call [runTest], but it can also be constructed manually, in order to + * use it to initialize the components that participate in the test. + * + * #### Differences from [TestCoroutineScope] + * + * * This doesn't provide an equivalent of [TestCoroutineScope.cleanupTestCoroutines], and so can't be used as a + * standalone mechanism for writing tests: it does require that [runTest] is eventually called. + * The reason for this is that a proper cleanup procedure that supports using non-test dispatchers and arbitrary + * coroutine suspensions would be equivalent to [runTest], but would also be more error-prone, due to the potential + * for forgetting to perform the cleanup. + * * [TestCoroutineScope.advanceTimeBy] also calls [TestCoroutineScheduler.runCurrent] after advancing the virtual time. + * * No support for dispatcher pausing, like [DelayController] allows. [TestCoroutineDispatcher], which supported + * pausing, is deprecated; now, instead of pausing a dispatcher, one can use [withContext] to run a dispatcher that's + * paused by default, like [StandardTestDispatcher]. + * * No access to the list of unhandled exceptions. + */ +@ExperimentalCoroutinesApi +public sealed interface TestScope : CoroutineScope { + /** + * The delay-skipping scheduler used by the test dispatchers running the code in this scope. + */ + @ExperimentalCoroutinesApi + public val testScheduler: TestCoroutineScheduler +} + +/** + * The current virtual time on [testScheduler][TestScope.testScheduler]. + * @see TestCoroutineScheduler.currentTime + */ +@ExperimentalCoroutinesApi +public val TestScope.currentTime: Long + get() = testScheduler.currentTime + +/** + * Advances the [testScheduler][TestScope.testScheduler] to the point where there are no tasks remaining. + * @see TestCoroutineScheduler.advanceUntilIdle + */ +@ExperimentalCoroutinesApi +public fun TestScope.advanceUntilIdle(): Unit = testScheduler.advanceUntilIdle() + +/** + * Run any tasks that are pending at the current virtual time, according to + * the [testScheduler][TestScope.testScheduler]. + * + * @see TestCoroutineScheduler.runCurrent + */ +@ExperimentalCoroutinesApi +public fun TestScope.runCurrent(): Unit = testScheduler.runCurrent() + +/** + * Moves the virtual clock of this dispatcher forward by [the specified amount][delayTimeMillis], running the + * scheduled tasks in the meantime. + * + * In contrast with [TestScope.advanceTimeBy], this function does not run the tasks scheduled at the moment + * [currentTime] + [delayTimeMillis]. + * + * @throws IllegalStateException if passed a negative [delay][delayTimeMillis]. + * @see TestCoroutineScheduler.advanceTimeBy + */ +@ExperimentalCoroutinesApi +public fun TestScope.advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler.advanceTimeBy(delayTimeMillis) + +/** + * Creates a [TestScope]. + * + * It ensures that all the test module machinery is properly initialized. + * * If [context] doesn't provide a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, + * a new one is created, unless either + * - a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used; + * - at the moment of the creation of the scope, [Dispatchers.Main] is delegated to a [TestDispatcher], in which case + * its [TestCoroutineScheduler] is used. + * * If [context] doesn't have a [TestDispatcher], a [StandardTestDispatcher] is created. + * * A [CoroutineExceptionHandler] is created that makes [TestCoroutineScope.cleanupTestCoroutines] throw if there were + * any uncaught exceptions, or forwards the exceptions further in a platform-specific manner if the cleanup was + * already performed when an exception happened. Passing a [CoroutineExceptionHandler] is illegal, unless it's an + * [UncaughtExceptionCaptor], in which case the behavior is preserved for the time being for backward compatibility. + * If you need to have a specific [CoroutineExceptionHandler], please pass it to [launch] on an already-created + * [TestCoroutineScope] and share your use case at + * [our issue tracker](https://github.com/Kotlin/kotlinx.coroutines/issues). + * * If [context] provides a [Job], that job is used as a parent for the new scope; + * otherwise, a [CompletableJob] is created. + * + * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a + * different scheduler. + * @throws IllegalArgumentException if [context] has a [ContinuationInterceptor] that is not a [TestDispatcher]. + * @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an + * [UncaughtExceptionCaptor]. + */ +@ExperimentalCoroutinesApi +@Suppress("FunctionName") +public fun TestScope(context: CoroutineContext = EmptyCoroutineContext): TestScope { + val ctxWithDispatcher = context.withDelaySkipping() + var scope: TestScopeImpl? = null + val exceptionHandler = when (ctxWithDispatcher[CoroutineExceptionHandler]) { + null -> CoroutineExceptionHandler { _, exception -> + scope!!.reportException(exception) + } + else -> throw IllegalArgumentException( + "A CoroutineExceptionHandler was passed to TestScope. " + + "Please pass it as an argument to a `launch` or `async` block on an already-created scope " + + "if uncaught exceptions require special treatment." + ) + } + return TestScopeImpl(ctxWithDispatcher + exceptionHandler).also { scope = it } +} + +/** + * Adds a [TestDispatcher] and a [TestCoroutineScheduler] to the context if there aren't any already. + * + * @throws IllegalArgumentException if both a [TestCoroutineScheduler] and a [TestDispatcher] are passed. + * @throws IllegalArgumentException if a [ContinuationInterceptor] is passed that is not a [TestDispatcher]. + */ +internal fun CoroutineContext.withDelaySkipping(): CoroutineContext { + val dispatcher: TestDispatcher = when (val dispatcher = get(ContinuationInterceptor)) { + is TestDispatcher -> { + val ctxScheduler = get(TestCoroutineScheduler) + if (ctxScheduler != null) { + require(dispatcher.scheduler === ctxScheduler) { + "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + + "another scheduler were passed." + } + } + dispatcher + } + null -> StandardTestDispatcher(get(TestCoroutineScheduler)) + else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") + } + return this + dispatcher + dispatcher.scheduler +} + +internal class TestScopeImpl(context: CoroutineContext) : + AbstractCoroutine(context, initParentJob = true, active = true), TestScope { + + override val testScheduler get() = context[TestCoroutineScheduler]!! + + var entered = false + var finished = false + val uncaughtExceptions = mutableListOf() + val lock = SynchronizedObject() + + /** Called upon entry to [runTest]. Will throw if called more than once. */ + fun enter() { + val exceptions = synchronized(lock) { + if (entered) + throw IllegalStateException("Only a single call to `runTest` can be performed during one test.") + entered = true + check(!finished) + uncaughtExceptions + } + if (exceptions.isNotEmpty()) { + throw UncaughtExceptionsBeforeTest().apply { + for (e in exceptions) + addSuppressed(e) + } + } + } + + /** Called at the end of the test. May only be called once. */ + fun finish(): List { + val exceptions = synchronized(lock) { + check(entered && !finished) + finished = true + uncaughtExceptions + } + if (exceptions.isEmpty() && (children.any { it.isActive } || !testScheduler.isIdle())) + throw UncompletedCoroutinesError( + "Unfinished coroutines during teardown. Ensure all coroutines are" + + " completed or cancelled by your test." + ) + return exceptions + } + + /** Stores an exception to report after [runTest], or rethrows it if not inside [runTest]. */ + fun reportException(throwable: Throwable) { + synchronized(lock) { + if (finished) { + throw throwable + } else { + uncaughtExceptions.add(throwable) + if (!entered) + throw UncaughtExceptionsBeforeTest().apply { addSuppressed(throwable) } + } + } + } +} + +/** Use the knowledge that any [TestScope] that we receive is necessarily a [TestScopeImpl]. */ +internal fun TestScope.asSpecificImplementation(): TestScopeImpl = when (this) { + is TestScopeImpl -> this +} + +internal class UncaughtExceptionsBeforeTest : IllegalStateException( + "There were uncaught exceptions in coroutines launched from TestScope before the test started. Please avoid this," + + " as such exceptions are also reported in a platform-dependent manner so that they are not lost." +) \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/src/DelayController.kt b/kotlinx-coroutines-test/common/src/migration/DelayController.kt similarity index 98% rename from kotlinx-coroutines-test/common/src/DelayController.kt rename to kotlinx-coroutines-test/common/src/migration/DelayController.kt index 8b34b8a267..62c2167177 100644 --- a/kotlinx-coroutines-test/common/src/DelayController.kt +++ b/kotlinx-coroutines-test/common/src/migration/DelayController.kt @@ -5,8 +5,7 @@ package kotlinx.coroutines.test -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.* /** * Control the virtual clock time of a [CoroutineDispatcher]. @@ -186,7 +185,7 @@ internal interface SchedulerAsDelayController : DelayController { override fun cleanupTestCoroutines() { // process any pending cancellations or completions, but don't advance time scheduler.runCurrent() - if (!scheduler.isIdle()) { + if (!scheduler.isIdle(strict = false)) { throw UncompletedCoroutinesError( "Unfinished coroutines during tear-down. Ensure all coroutines are" + " completed or cancelled by your test." diff --git a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt new file mode 100644 index 0000000000..1bc61ad32e --- /dev/null +++ b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +@file:Suppress("DEPRECATION") +@file:JvmName("TestBuildersKt") +@file:JvmMultifileClass + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.selects.* +import kotlin.coroutines.* +import kotlin.jvm.* + +/** + * Executes a [testBody] inside an immediate execution dispatcher. + * + * This is similar to [runBlocking] but it will immediately progress past delays and into [launch] and [async] blocks. + * You can use this to write tests that execute in the presence of calls to [delay] without causing your test to take + * extra time. + * + * ``` + * @Test + * fun exampleTest() = runBlockingTest { + * val deferred = async { + * delay(1_000) + * async { + * delay(1_000) + * }.await() + * } + * + * deferred.await() // result available immediately + * } + * + * ``` + * + * This method requires that all coroutines launched inside [testBody] complete, or are cancelled, as part of the test + * conditions. + * + * Unhandled exceptions thrown by coroutines in the test will be re-thrown at the end of the test. + * + * @throws AssertionError If the [testBody] does not complete (or cancel) all coroutines that it launches + * (including coroutines suspended on join/await). + * + * @param context additional context elements. If [context] contains [CoroutineDispatcher] or [CoroutineExceptionHandler], + * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively. + * @param testBody The code of the unit-test. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun runBlockingTest( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestCoroutineScope.() -> Unit +) { + val scope = createTestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) + val scheduler = scope.testScheduler + val deferred = scope.async { + scope.testBody() + } + scheduler.advanceUntilIdle() + deferred.getCompletionExceptionOrNull()?.let { + throw it + } + scope.cleanupTestCoroutines() +} + +/** + * A version of [runBlockingTest] that works with [TestScope]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun runBlockingTestOnTestScope( + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestScope.() -> Unit +) { + val startJobs = context.activeJobs() + val scope = TestScope(TestCoroutineDispatcher() + SupervisorJob() + context).asSpecificImplementation() + scope.enter() + val scheduler = scope.testScheduler + val deferred = scope.async { + scope.testBody() + } + scheduler.advanceUntilIdle() + deferred.getCompletionExceptionOrNull()?.let { + throw it + } + scope.finish().throwAll() + val jobs = context.activeJobs() - startJobs + if (jobs.isNotEmpty()) + throw UncompletedCoroutinesError("Some jobs were not completed at the end of the test: $jobs") +} + +/** + * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = + runBlockingTest(coroutineContext, block) + +/** + * Convenience method for calling [runBlockingTestOnTestScope] on an existing [TestScope]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun TestScope.runBlockingTest(block: suspend TestScope.() -> Unit): Unit = + runBlockingTestOnTestScope(coroutineContext, block) + +/** + * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. + */ +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) +public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = + runBlockingTest(this, block) + +/** + * This is an overload of [runTest] that works with [TestCoroutineScope]. + */ +@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead.", level = DeprecationLevel.WARNING) +public fun runTestWithLegacyScope( + context: CoroutineContext = EmptyCoroutineContext, + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + testBody: suspend TestCoroutineScope.() -> Unit +): TestResult { + if (context[RunningInRunTest] != null) + throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") + val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest)) + return createTestResult { + runTestCoroutine(testScope, dispatchTimeoutMs, testBody) { + try { + testScope.cleanup() + emptyList() + } catch (e: UncompletedCoroutinesError) { + throw e + } catch (e: Throwable) { + listOf(e) + } + } + } +} + +/** + * Runs a test in a [TestCoroutineScope] based on this one. + * + * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run the + * [block] will be different from this one, but will use its [Job] as a parent. + * + * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned + * immediately from the test body. See the docs for [TestResult] for details. + */ +@ExperimentalCoroutinesApi +@Deprecated("Use `TestScope.runTest` instead.", level = DeprecationLevel.WARNING) +public fun TestCoroutineScope.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult = runTestWithLegacyScope(coroutineContext, dispatchTimeoutMs, block) + +private class TestBodyCoroutine( + private val testScope: TestCoroutineScope, +) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope { + + override val testScheduler get() = testScope.testScheduler + + @Deprecated( + "This deprecation is to prevent accidentally calling `cleanupTestCoroutines` in our own code.", + ReplaceWith("this.cleanup()"), + DeprecationLevel.ERROR + ) + override fun cleanupTestCoroutines() = + throw UnsupportedOperationException( + "Calling `cleanupTestCoroutines` inside `runTest` is prohibited: " + + "it will be called at the end of the test in any case." + ) + + fun cleanup() = testScope.cleanupTestCoroutines() +} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt similarity index 100% rename from kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt rename to kotlinx-coroutines-test/common/src/migration/TestCoroutineDispatcher.kt diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt b/kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt similarity index 99% rename from kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt rename to kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt index b85f21ee69..f9991496a7 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineExceptionHandler.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestCoroutineExceptionHandler.kt @@ -57,7 +57,6 @@ public class TestCoroutineExceptionHandler : synchronized(_lock) { if (_coroutinesCleanedUp) { handleCoroutineExceptionImpl(context, exception) - return } _exceptions += exception } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt similarity index 90% rename from kotlinx-coroutines-test/common/src/TestCoroutineScope.kt rename to kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt index 01a6aa4b88..4a8b54ba69 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestCoroutineScope.kt @@ -1,6 +1,7 @@ /* * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:Suppress("DEPRECATION") package kotlinx.coroutines.test @@ -12,6 +13,7 @@ import kotlin.coroutines.* * A scope which provides detailed control over the execution of coroutines for tests. */ @ExperimentalCoroutinesApi +@Deprecated("Use `TestScope` in combination with `runTest` instead") public sealed interface TestCoroutineScope : CoroutineScope { /** * Called after the test completes. @@ -84,7 +86,7 @@ private class TestCoroutineScopeImpl( } } else { testScheduler.runCurrent() - !testScheduler.isIdle() + !testScheduler.isIdle(strict = false) } (coroutineContext[CoroutineExceptionHandler] as? UncaughtExceptionCaptor)?.cleanupTestCoroutines() synchronized(lock) { @@ -107,7 +109,7 @@ private class TestCoroutineScopeImpl( } } -private fun CoroutineContext.activeJobs(): Set { +internal fun CoroutineContext.activeJobs(): Set { return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() } @@ -156,37 +158,22 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) * [UncaughtExceptionCaptor]. */ @ExperimentalCoroutinesApi +@Deprecated( + "This function was introduced in order to help migrate from TestCoroutineScope to TestScope. " + + "Please use TestScope() construction instead, or just runTest(), without creating a scope.", + level = DeprecationLevel.WARNING +) public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { - val scheduler: TestCoroutineScheduler - val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) { - is TestDispatcher -> { - scheduler = dispatcher.scheduler - val ctxScheduler = context[TestCoroutineScheduler] - if (ctxScheduler != null) { - require(dispatcher.scheduler === ctxScheduler) { - "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + - "another scheduler were passed." - } - } - dispatcher - } - null -> StandardTestDispatcher(context[TestCoroutineScheduler]).also { scheduler = it.scheduler } - else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") - } + val ctxWithDispatcher = context.withDelaySkipping() var scope: TestCoroutineScopeImpl? = null - val ownExceptionHandler = run { - val lock = SynchronizedObject() + val ownExceptionHandler = object : AbstractCoroutineContextElement(CoroutineExceptionHandler), TestCoroutineScopeExceptionHandler { override fun handleException(context: CoroutineContext, exception: Throwable) { - val reported = synchronized(lock) { - scope!!.reportException(exception) - } - if (!reported) + if (!scope!!.reportException(exception)) throw exception // let this exception crash everything } } - } - val exceptionHandler = when (val exceptionHandler = context[CoroutineExceptionHandler]) { + val exceptionHandler = when (val exceptionHandler = ctxWithDispatcher[CoroutineExceptionHandler]) { is UncaughtExceptionCaptor -> exceptionHandler null -> ownExceptionHandler is TestCoroutineScopeExceptionHandler -> ownExceptionHandler @@ -196,8 +183,8 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo "if uncaught exceptions require special treatment." ) } - val job: Job = context[Job] ?: Job() - return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job).also { + val job: Job = ctxWithDispatcher[Job] ?: Job() + return TestCoroutineScopeImpl(ctxWithDispatcher + exceptionHandler + job).also { scope = it } } @@ -205,7 +192,7 @@ public fun createTestCoroutineScope(context: CoroutineContext = EmptyCoroutineCo /** A marker that shows that this [CoroutineExceptionHandler] was created for [TestCoroutineScope]. With this, * constructing a new [TestCoroutineScope] with the [CoroutineScope.coroutineContext] of an existing one will override * the exception handler, instead of failing. */ -private interface TestCoroutineScopeExceptionHandler: CoroutineExceptionHandler +private interface TestCoroutineScopeExceptionHandler : CoroutineExceptionHandler private inline val CoroutineContext.delayController: DelayController? get() { diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 20e24d448a..e063cdacf1 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -124,7 +124,7 @@ class RunTestTest { /** Tests that passing invalid contexts to [runTest] causes it to fail (on JS, without forking). */ @Test fun testRunTestWithIllegalContext() { - for (ctx in TestCoroutineScopeTest.invalidContexts) { + for (ctx in TestScopeTest.invalidContexts) { assertFailsWith { runTest(ctx) { } } @@ -279,13 +279,13 @@ class RunTestTest { /** Tests that [TestCoroutineScope.runTest] does not inherit the exception handler and works. */ @Test fun testScopeRunTestExceptionHandler(): TestResult { - val scope = createTestCoroutineScope() + val scope = TestScope() return testResultMap({ try { it() fail("should not be reached") } catch (e: TestException) { - scope.cleanupTestCoroutines() // should not fail + // expected } }) { scope.runTest { diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt index d00b50d90c..225e52a824 100644 --- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -10,10 +10,13 @@ import kotlin.test.* class StandardTestDispatcherTest: OrderedExecutionTestBase() { - private val scope = createTestCoroutineScope(StandardTestDispatcher()) + private val scope = TestScope(StandardTestDispatcher()) @AfterTest - fun cleanup() = scope.cleanupTestCoroutines() + fun cleanup() { + scope.runCurrent() + assertEquals(listOf(), scope.asSpecificImplementation().finish()) + } /** Tests that the [StandardTestDispatcher] follows an execution order similar to `runBlocking`. */ @Test diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index 5e5a91f6f7..4f7a262a4e 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -5,7 +5,6 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* -import kotlin.coroutines.* import kotlin.test.* class TestCoroutineSchedulerTest { @@ -47,7 +46,7 @@ class TestCoroutineSchedulerTest { @Test fun testAdvanceTimeByEnormousDelays() = forTestDispatchers { assertRunsFast { - with (createTestCoroutineScope(it)) { + with (TestScope(it)) { launch { val initialDelay = 10L delay(initialDelay) @@ -78,30 +77,29 @@ class TestCoroutineSchedulerTest { /** Tests the basic functionality of [TestCoroutineScheduler.advanceTimeBy]. */ @Test - fun testAdvanceTimeBy() = assertRunsFast { - val scheduler = TestCoroutineScheduler() - val scope = createTestCoroutineScope(scheduler) - var stage = 1 - scope.launch { - delay(1_000) - assertEquals(1_000, scheduler.currentTime) - stage = 2 - delay(500) - assertEquals(1_500, scheduler.currentTime) - stage = 3 - delay(501) - assertEquals(2_001, scheduler.currentTime) - stage = 4 + fun testAdvanceTimeBy() = runTest { + assertRunsFast { + var stage = 1 + launch { + delay(1_000) + assertEquals(1_000, currentTime) + stage = 2 + delay(500) + assertEquals(1_500, currentTime) + stage = 3 + delay(501) + assertEquals(2_001, currentTime) + stage = 4 + } + assertEquals(1, stage) + assertEquals(0, currentTime) + advanceTimeBy(2_000) + assertEquals(3, stage) + assertEquals(2_000, currentTime) + advanceTimeBy(2) + assertEquals(4, stage) + assertEquals(2_002, currentTime) } - assertEquals(1, stage) - assertEquals(0, scheduler.currentTime) - scheduler.advanceTimeBy(2_000) - assertEquals(3, stage) - assertEquals(2_000, scheduler.currentTime) - scheduler.advanceTimeBy(2) - assertEquals(4, stage) - assertEquals(2_002, scheduler.currentTime) - scope.cleanupTestCoroutines() } /** Tests the basic functionality of [TestCoroutineScheduler.runCurrent]. */ @@ -135,7 +133,7 @@ class TestCoroutineSchedulerTest { fun testRunCurrentNotDrainingQueue() = forTestDispatchers { assertRunsFast { val scheduler = it.scheduler - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var stage = 1 scope.launch { delay(SLOW) @@ -160,7 +158,7 @@ class TestCoroutineSchedulerTest { fun testNestedAdvanceUntilIdle() = forTestDispatchers { assertRunsFast { val scheduler = it.scheduler - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var executed = false scope.launch { launch { @@ -177,7 +175,7 @@ class TestCoroutineSchedulerTest { /** Tests [yield] scheduling tasks for future execution and not executing immediately. */ @Test fun testYield() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var stage = 0 scope.launch { yield() @@ -197,7 +195,7 @@ class TestCoroutineSchedulerTest { /** Tests that dispatching the delayed tasks is ordered by their waking times. */ @Test fun testDelaysPriority() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) var lastMeasurement = 0L fun checkTime(time: Long) { assertTrue(lastMeasurement < time) @@ -234,10 +232,11 @@ class TestCoroutineSchedulerTest { checkTime(201) } - private fun TestCoroutineScope.checkTimeout( + private fun TestScope.checkTimeout( timesOut: Boolean, timeoutMillis: Long = SLOW, block: suspend () -> Unit ) = assertRunsFast { var caughtException = false + asSpecificImplementation().enter() launch { try { withTimeout(timeoutMillis) { @@ -248,7 +247,7 @@ class TestCoroutineSchedulerTest { } } advanceUntilIdle() - cleanupTestCoroutines() + asSpecificImplementation().finish().throwAll() if (timesOut) assertTrue(caughtException) else @@ -258,7 +257,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts get triggered. */ @Test fun testSmallTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) scope.checkTimeout(true) { val half = SLOW / 2 delay(half) @@ -269,7 +268,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts don't get triggered if the code finishes in time. */ @Test fun testLargeTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) scope.checkTimeout(false) { val half = SLOW / 2 delay(half) @@ -280,7 +279,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts get triggered if the code fails to finish in time asynchronously. */ @Test fun testSmallAsynchronousTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) val deferred = CompletableDeferred() scope.launch { val half = SLOW / 2 @@ -296,7 +295,7 @@ class TestCoroutineSchedulerTest { /** Tests that timeouts don't get triggered if the code finishes in time, even if it does so asynchronously. */ @Test fun testLargeAsynchronousTimeouts() = forTestDispatchers { - val scope = createTestCoroutineScope(it) + val scope = TestScope(it) val deferred = CompletableDeferred() scope.launch { val half = SLOW / 2 diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt new file mode 100644 index 0000000000..da76adb9f7 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlin.coroutines.* +import kotlin.test.* + +class TestScopeTest { + /** Tests failing to create a [TestCoroutineScope] with incorrect contexts. */ + @Test + fun testCreateThrowsOnInvalidArguments() { + for (ctx in invalidContexts) { + assertFailsWith { + TestScope(ctx) + } + } + } + + /** Tests that a newly-created [TestCoroutineScope] provides the correct scheduler. */ + @Test + fun testCreateProvidesScheduler() { + // Creates a new scheduler. + run { + val scope = TestScope() + assertNotNull(scope.coroutineContext[TestCoroutineScheduler]) + } + // Reuses the scheduler that the dispatcher is linked to. + run { + val dispatcher = StandardTestDispatcher() + val scope = TestScope(dispatcher) + assertSame(dispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) + } + // Uses the scheduler passed to it. + run { + val scheduler = TestCoroutineScheduler() + val scope = TestScope(scheduler) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertSame(scheduler, (scope.coroutineContext[ContinuationInterceptor] as TestDispatcher).scheduler) + } + // Doesn't touch the passed dispatcher and the scheduler if they match. + run { + val scheduler = TestCoroutineScheduler() + val dispatcher = StandardTestDispatcher(scheduler) + val scope = TestScope(scheduler + dispatcher) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertSame(dispatcher, scope.coroutineContext[ContinuationInterceptor]) + } + // Reuses the scheduler of `Dispatchers.Main` + run { + val scheduler = TestCoroutineScheduler() + val mainDispatcher = StandardTestDispatcher(scheduler) + Dispatchers.setMain(mainDispatcher) + try { + val scope = TestScope() + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher, scope.coroutineContext[ContinuationInterceptor]) + } finally { + Dispatchers.resetMain() + } + } + // Does not reuse the scheduler of `Dispatchers.Main` if one is explicitly passed + run { + val mainDispatcher = StandardTestDispatcher() + Dispatchers.setMain(mainDispatcher) + try { + val scheduler = TestCoroutineScheduler() + val scope = TestScope(scheduler) + assertSame(scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher.scheduler, scope.coroutineContext[TestCoroutineScheduler]) + assertNotSame(mainDispatcher, scope.coroutineContext[ContinuationInterceptor]) + } finally { + Dispatchers.resetMain() + } + } + } + + /** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */ + @Test + fun testPresentDelaysThrowing() { + val scope = TestScope() + var result = false + scope.launch { + delay(5) + result = true + } + assertFalse(result) + scope.asSpecificImplementation().enter() + assertFailsWith { scope.asSpecificImplementation().finish() } + assertFalse(result) + } + + /** Tests that the cleanup procedure throws if there were active jobs by the end. */ + @Test + fun testActiveJobsThrowing() { + val scope = TestScope() + var result = false + val deferred = CompletableDeferred() + scope.launch { + deferred.await() + result = true + } + assertFalse(result) + scope.asSpecificImplementation().enter() + assertFailsWith { scope.asSpecificImplementation().finish() } + assertFalse(result) + } + + /** Tests that the cleanup procedure throws even if it detects that the job is already cancelled. */ + @Test + fun testCancelledDelaysThrowing() { + val scope = TestScope() + var result = false + val deferred = CompletableDeferred() + val job = scope.launch { + deferred.await() + result = true + } + job.cancel() + assertFalse(result) + scope.asSpecificImplementation().enter() + assertFailsWith { scope.asSpecificImplementation().finish() } + assertFalse(result) + } + + /** Tests that uncaught exceptions are thrown at the cleanup. */ + @Test + fun testGetsCancelledOnChildFailure(): TestResult { + val scope = TestScope() + val exception = TestException("test") + scope.launch { + throw exception + } + return testResultMap({ + try { + it() + fail("should not reach") + } catch (e: TestException) { + // expected + } + }) { + scope.runTest { + } + } + } + + /** Tests that, when reporting several exceptions, the first one is thrown, with the rest suppressed. */ + @Test + fun testSuppressedExceptions() { + TestScope().apply { + asSpecificImplementation().enter() + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + runCurrent() + val e = asSpecificImplementation().finish() + assertEquals(3, e.size) + assertEquals("x", e[0].message) + assertEquals("y", e[1].message) + assertEquals("z", e[2].message) + } + } + + companion object { + internal val invalidContexts = listOf( + Dispatchers.Default, // not a [TestDispatcher] + CoroutineExceptionHandler { _, _ -> }, // exception handlers can't be overridden + StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler + ) + } +} diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt similarity index 98% rename from kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt rename to kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt index 35d92904c8..1a62613790 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/migration/TestCoroutineScopeTest.kt @@ -1,6 +1,7 @@ /* - * Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:Suppress("DEPRECATION") package kotlinx.coroutines.test @@ -213,4 +214,4 @@ class TestCoroutineScopeTest { StandardTestDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler ) } -} +} \ No newline at end of file From 14338a7837df2ce2e41785e2ba4f6b24497ca6cb Mon Sep 17 00:00:00 2001 From: Vsevolod Tolstopyatov Date: Fri, 12 Nov 2021 17:18:50 +0300 Subject: [PATCH 2/8] ~fix compilation --- .../api/kotlinx-coroutines-test.api | 13 +++++-------- kotlinx-coroutines-test/common/src/TestBuilders.kt | 3 +++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 9e652411df..d90a319825 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -9,7 +9,7 @@ public abstract interface class kotlinx/coroutines/test/DelayController { public abstract fun runCurrent ()V } -public final class kotlinx/coroutines/test/TestBuilders { +public final class kotlinx/coroutines/test/TestBuildersKt { public static final fun runBlockingTest (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineDispatcher;Lkotlin/jvm/functions/Function2;)V public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V @@ -17,17 +17,14 @@ public final class kotlinx/coroutines/test/TestBuilders { public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static final fun runBlockingTestOnTestScope (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;)V public static synthetic fun runBlockingTestOnTestScope$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V - public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V - public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V - public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V - public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V -} - -public final class kotlinx/coroutines/test/TestBuildersKt { public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V public static final fun runTest (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;)V public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun runTestWithLegacyScope (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun runTestWithLegacyScope$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController { diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 012caf2767..e9d220845f 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -1,12 +1,15 @@ /* * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. */ +@file:JvmName("TestBuildersKt") +@file:JvmMultifileClass package kotlinx.coroutines.test import kotlinx.coroutines.* import kotlinx.coroutines.selects.* import kotlin.coroutines.* +import kotlin.jvm.* /** * A test result. From 78d1698be8de045d666751cc0615d108fa67998b Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 15 Nov 2021 12:12:58 +0300 Subject: [PATCH 3/8] Fixes --- kotlinx-coroutines-test/common/src/TestBuilders.kt | 2 +- kotlinx-coroutines-test/common/src/TestScope.kt | 13 ++++++++----- .../common/src/migration/TestBuildersDeprecated.kt | 2 +- .../common/test/StandardTestDispatcherTest.kt | 7 ++++++- .../common/test/TestCoroutineSchedulerTest.kt | 2 +- .../common/test/TestScopeTest.kt | 8 ++++---- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index e9d220845f..bc591a35a3 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -164,7 +164,7 @@ public fun TestScope.runTest( ): TestResult = asSpecificImplementation().let { it.enter() createTestResult { - runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.finish() } + runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() } } } diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index 1f8204321d..faf704baf5 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -157,10 +157,10 @@ internal class TestScopeImpl(context: CoroutineContext) : override val testScheduler get() = context[TestCoroutineScheduler]!! - var entered = false - var finished = false - val uncaughtExceptions = mutableListOf() - val lock = SynchronizedObject() + private var entered = false + private var finished = false + private val uncaughtExceptions = mutableListOf() + private val lock = SynchronizedObject() /** Called upon entry to [runTest]. Will throw if called more than once. */ fun enter() { @@ -180,7 +180,7 @@ internal class TestScopeImpl(context: CoroutineContext) : } /** Called at the end of the test. May only be called once. */ - fun finish(): List { + fun leave(): List { val exceptions = synchronized(lock) { check(entered && !finished) finished = true @@ -206,6 +206,9 @@ internal class TestScopeImpl(context: CoroutineContext) : } } } + + override fun toString(): String = + "TestScope[" + (if (finished) "test ended" else if (entered) "test started" else "test not started") + "]" } /** Use the knowledge that any [TestScope] that we receive is necessarily a [TestScopeImpl]. */ diff --git a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt index 1bc61ad32e..c1d499920a 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt @@ -82,7 +82,7 @@ public fun runBlockingTestOnTestScope( deferred.getCompletionExceptionOrNull()?.let { throw it } - scope.finish().throwAll() + scope.leave().throwAll() val jobs = context.activeJobs() - startJobs if (jobs.isNotEmpty()) throw UncompletedCoroutinesError("Some jobs were not completed at the end of the test: $jobs") diff --git a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt index 225e52a824..e9b2e179da 100644 --- a/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt +++ b/kotlinx-coroutines-test/common/test/StandardTestDispatcherTest.kt @@ -12,10 +12,15 @@ class StandardTestDispatcherTest: OrderedExecutionTestBase() { private val scope = TestScope(StandardTestDispatcher()) + @BeforeTest + fun init() { + scope.asSpecificImplementation().enter() + } + @AfterTest fun cleanup() { scope.runCurrent() - assertEquals(listOf(), scope.asSpecificImplementation().finish()) + assertEquals(listOf(), scope.asSpecificImplementation().leave()) } /** Tests that the [StandardTestDispatcher] follows an execution order similar to `runBlocking`. */ diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index 4f7a262a4e..d3e4294a1a 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -247,7 +247,7 @@ class TestCoroutineSchedulerTest { } } advanceUntilIdle() - asSpecificImplementation().finish().throwAll() + asSpecificImplementation().leave().throwAll() if (timesOut) assertTrue(caughtException) else diff --git a/kotlinx-coroutines-test/common/test/TestScopeTest.kt b/kotlinx-coroutines-test/common/test/TestScopeTest.kt index da76adb9f7..743dde3ca7 100644 --- a/kotlinx-coroutines-test/common/test/TestScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestScopeTest.kt @@ -88,7 +88,7 @@ class TestScopeTest { } assertFalse(result) scope.asSpecificImplementation().enter() - assertFailsWith { scope.asSpecificImplementation().finish() } + assertFailsWith { scope.asSpecificImplementation().leave() } assertFalse(result) } @@ -104,7 +104,7 @@ class TestScopeTest { } assertFalse(result) scope.asSpecificImplementation().enter() - assertFailsWith { scope.asSpecificImplementation().finish() } + assertFailsWith { scope.asSpecificImplementation().leave() } assertFalse(result) } @@ -121,7 +121,7 @@ class TestScopeTest { job.cancel() assertFalse(result) scope.asSpecificImplementation().enter() - assertFailsWith { scope.asSpecificImplementation().finish() } + assertFailsWith { scope.asSpecificImplementation().leave() } assertFalse(result) } @@ -155,7 +155,7 @@ class TestScopeTest { launch(SupervisorJob()) { throw TestException("y") } launch(SupervisorJob()) { throw TestException("z") } runCurrent() - val e = asSpecificImplementation().finish() + val e = asSpecificImplementation().leave() assertEquals(3, e.size) assertEquals("x", e[0].message) assertEquals("y", e[1].message) From d5e8aa8eaa586a6433330c1f0852b65a4366e300 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Nov 2021 14:04:50 +0300 Subject: [PATCH 4/8] Add tests for new migration helpers, fix some bugs --- .../common/src/TestScope.kt | 21 +- .../src/migration/TestBuildersDeprecated.kt | 22 +- .../RunBlockingTestOnTestScopeTest.kt | 165 +++++++++++ .../test/migration/RunTestLegacyScopeTest.kt | 279 ++++++++++++++++++ 4 files changed, 473 insertions(+), 14 deletions(-) create mode 100644 kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt create mode 100644 kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index faf704baf5..3a7c83bf87 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -25,7 +25,7 @@ import kotlin.coroutines.* * The usual way to access a [TestScope] is to call [runTest], but it can also be constructed manually, in order to * use it to initialize the components that participate in the test. * - * #### Differences from [TestCoroutineScope] + * #### Differences from the deprecated [TestCoroutineScope] * * * This doesn't provide an equivalent of [TestCoroutineScope.cleanupTestCoroutines], and so can't be used as a * standalone mechanism for writing tests: it does require that [runTest] is eventually called. @@ -186,11 +186,20 @@ internal class TestScopeImpl(context: CoroutineContext) : finished = true uncaughtExceptions } - if (exceptions.isEmpty() && (children.any { it.isActive } || !testScheduler.isIdle())) - throw UncompletedCoroutinesError( - "Unfinished coroutines during teardown. Ensure all coroutines are" + - " completed or cancelled by your test." - ) + val activeJobs = children.filter { it.isActive }.toList() + if (exceptions.isEmpty()) { + if (activeJobs.isNotEmpty()) + throw UncompletedCoroutinesError( + "Active jobs found during the tear-down. " + + "Ensure that all coroutines are completed or cancelled by your test. " + + "The active jobs: $activeJobs" + ) + if (!testScheduler.isIdle()) + throw UncompletedCoroutinesError( + "Unfinished coroutines found during the tear-down. " + + "Ensure that all coroutines are completed or cancelled by your test." + ) + } return exceptions } diff --git a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt index c1d499920a..c5979ad96d 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt @@ -71,19 +71,25 @@ public fun runBlockingTestOnTestScope( context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestScope.() -> Unit ) { - val startJobs = context.activeJobs() - val scope = TestScope(TestCoroutineDispatcher() + SupervisorJob() + context).asSpecificImplementation() + val completeContext = TestCoroutineDispatcher() + SupervisorJob() + context + val startJobs = completeContext.activeJobs() + val scope = TestScope(completeContext).asSpecificImplementation() scope.enter() - val scheduler = scope.testScheduler - val deferred = scope.async { + scope.start(CoroutineStart.UNDISPATCHED, scope) { scope.testBody() } - scheduler.advanceUntilIdle() - deferred.getCompletionExceptionOrNull()?.let { - throw it + scope.testScheduler.advanceUntilIdle() + scope.getCompletionExceptionOrNull()?.let { + val exceptions = try { + scope.leave() + } catch (e: UncompletedCoroutinesError) { + listOf() + } + (listOf(it) + exceptions).throwAll() + return } scope.leave().throwAll() - val jobs = context.activeJobs() - startJobs + val jobs = completeContext.activeJobs() - startJobs if (jobs.isNotEmpty()) throw UncompletedCoroutinesError("Some jobs were not completed at the end of the test: $jobs") } diff --git a/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt b/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt new file mode 100644 index 0000000000..174baa0819 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/migration/RunBlockingTestOnTestScopeTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.test.* + +/** Copy of [RunTestTest], but for [runBlockingTestOnTestScope], where applicable. */ +@Suppress("DEPRECATION") +class RunBlockingTestOnTestScopeTest { + + @Test + fun testRunTestWithIllegalContext() { + for (ctx in TestScopeTest.invalidContexts) { + assertFailsWith { + runBlockingTestOnTestScope(ctx) { } + } + } + } + + @Test + fun testThrowingInRunTestBody() { + assertFailsWith { + runBlockingTestOnTestScope { + throw RuntimeException() + } + } + } + + @Test + fun testThrowingInRunTestPendingTask() { + assertFailsWith { + runBlockingTestOnTestScope { + launch { + delay(SLOW) + throw RuntimeException() + } + } + } + } + + @Test + fun reproducer2405() = runBlockingTestOnTestScope { + val dispatcher = StandardTestDispatcher(testScheduler) + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + @Test + fun testChildrenCancellationOnTestBodyFailure() { + var job: Job? = null + assertFailsWith { + runBlockingTestOnTestScope { + job = launch { + while (true) { + delay(1000) + } + } + throw AssertionError() + } + } + assertTrue(job!!.isCancelled) + } + + @Test + fun testTimeout() { + assertFailsWith { + runBlockingTestOnTestScope { + withTimeout(50) { + launch { + delay(1000) + } + } + } + } + } + + @Test + fun testRunTestThrowsRootCause() { + assertFailsWith { + runBlockingTestOnTestScope { + launch { + throw TestException() + } + } + } + } + + @Test + fun testCompletesOwnJob() { + var handlerCalled = false + runBlockingTestOnTestScope { + coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + } + assertTrue(handlerCalled) + } + + @Test + fun testDoesNotCompleteGivenJob() { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + runBlockingTestOnTestScope(job) { + assertTrue(coroutineContext.job in job.children) + } + assertFalse(handlerCalled) + assertEquals(0, job.children.filter { it.isActive }.count()) + } + + @Test + fun testSuppressedExceptions() { + try { + runBlockingTestOnTestScope { + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + throw TestException("w") + } + fail("should not be reached") + } catch (e: TestException) { + assertEquals("w", e.message) + val suppressed = e.suppressedExceptions + + (e.suppressedExceptions.firstOrNull()?.suppressedExceptions ?: emptyList()) + assertEquals(3, suppressed.size) + assertEquals("x", suppressed[0].message) + assertEquals("y", suppressed[1].message) + assertEquals("z", suppressed[2].message) + } + } + + @Test + fun testScopeRunTestExceptionHandler(): TestResult { + val scope = TestCoroutineScope() + return testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + // expected + } + }) { + scope.runTest { + launch(SupervisorJob()) { throw TestException("x") } + } + } + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt b/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt new file mode 100644 index 0000000000..3ea11139d1 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/migration/RunTestLegacyScopeTest.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.coroutines.* +import kotlin.test.* + +/** Copy of [RunTestTest], but for [TestCoroutineScope] */ +@Suppress("DEPRECATION") +class RunTestLegacyScopeTest { + + @Test + fun testWithContextDispatching() = runTestWithLegacyScope { + var counter = 0 + withContext(Dispatchers.Default) { + counter += 1 + } + assertEquals(counter, 1) + } + + @Test + fun testJoiningForkedJob() = runTestWithLegacyScope { + var counter = 0 + val job = GlobalScope.launch { + counter += 1 + } + job.join() + assertEquals(counter, 1) + } + + @Test + fun testSuspendCoroutine() = runTestWithLegacyScope { + val answer = suspendCoroutine { + it.resume(42) + } + assertEquals(42, answer) + } + + @Test + fun testNestedRunTestForbidden() = runTestWithLegacyScope { + assertFailsWith { + runTest { } + } + } + + @Test + fun testRunTestWithZeroTimeoutWithControlledDispatches() = runTestWithLegacyScope(dispatchTimeoutMs = 0) { + // below is some arbitrary concurrent code where all dispatches go through the same scheduler. + launch { + delay(2000) + } + val deferred = async { + val job = launch(StandardTestDispatcher(testScheduler)) { + launch { + delay(500) + } + delay(1000) + } + job.join() + } + deferred.await() + } + + @Test + fun testRunTestWithZeroTimeoutWithUncontrolledDispatches() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTestWithLegacyScope(dispatchTimeoutMs = 0) { + withContext(Dispatchers.Default) { + delay(10) + 3 + } + fail("shouldn't be reached") + } + } + + @Test + @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestWithSmallTimeout() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTestWithLegacyScope(dispatchTimeoutMs = 100) { + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + @Test + fun testRunTestWithLargeTimeout() = runTestWithLegacyScope(dispatchTimeoutMs = 5000) { + withContext(Dispatchers.Default) { + delay(50) + } + } + + @Test + @NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTestWithLegacyScope(dispatchTimeoutMs = 1) { + coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException()) + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + @Test + fun testRunTestWithIllegalContext() { + for (ctx in TestScopeTest.invalidContexts) { + assertFailsWith { + runTestWithLegacyScope(ctx) { } + } + } + } + + @Test + fun testThrowingInRunTestBody() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + throw RuntimeException() + } + } + + @Test + fun testThrowingInRunTestPendingTask() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + launch { + delay(SLOW) + throw RuntimeException() + } + } + } + + @Test + fun reproducer2405() = runTestWithLegacyScope { + val dispatcher = StandardTestDispatcher(testScheduler) + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + @Test + fun testChildrenCancellationOnTestBodyFailure(): TestResult { + var job: Job? = null + return testResultMap({ + assertFailsWith { it() } + assertTrue(job!!.isCancelled) + }) { + runTestWithLegacyScope { + job = launch { + while (true) { + delay(1000) + } + } + throw AssertionError() + } + } + } + + @Test + fun testTimeout() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + withTimeout(50) { + launch { + delay(1000) + } + } + } + } + + @Test + fun testRunTestThrowsRootCause() = testResultMap({ + assertFailsWith { it() } + }) { + runTestWithLegacyScope { + launch { + throw TestException() + } + } + } + + @Test + fun testCompletesOwnJob(): TestResult { + var handlerCalled = false + return testResultMap({ + it() + assertTrue(handlerCalled) + }) { + runTestWithLegacyScope { + coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + } + } + } + + @Test + fun testDoesNotCompleteGivenJob(): TestResult { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + return testResultMap({ + it() + assertFalse(handlerCalled) + assertEquals(0, job.children.filter { it.isActive }.count()) + }) { + runTestWithLegacyScope(job) { + assertTrue(coroutineContext.job in job.children) + } + } + } + + @Test + fun testSuppressedExceptions() = testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + assertEquals("w", e.message) + val suppressed = e.suppressedExceptions + + (e.suppressedExceptions.firstOrNull()?.suppressedExceptions ?: emptyList()) + assertEquals(3, suppressed.size) + assertEquals("x", suppressed[0].message) + assertEquals("y", suppressed[1].message) + assertEquals("z", suppressed[2].message) + } + }) { + runTestWithLegacyScope { + launch(SupervisorJob()) { throw TestException("x") } + launch(SupervisorJob()) { throw TestException("y") } + launch(SupervisorJob()) { throw TestException("z") } + throw TestException("w") + } + } + + @Test + fun testScopeRunTestExceptionHandler(): TestResult { + val scope = TestCoroutineScope() + return testResultMap({ + try { + it() + fail("should not be reached") + } catch (e: TestException) { + // expected + } + }) { + scope.runTest { + launch(SupervisorJob()) { throw TestException("x") } + } + } + } +} \ No newline at end of file From 14b004460e0f1e1d5c0c8e6c5d6f400eea3946e7 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Nov 2021 14:13:59 +0300 Subject: [PATCH 5/8] Fix --- kotlinx-coroutines-test/common/src/TestScope.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index 3a7c83bf87..3f7a81f601 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -182,7 +182,8 @@ internal class TestScopeImpl(context: CoroutineContext) : /** Called at the end of the test. May only be called once. */ fun leave(): List { val exceptions = synchronized(lock) { - check(entered && !finished) + if(!entered || finished) + throw IllegalStateException("An internal error. Please report to the Kotlinx Coroutines issue tracker") finished = true uncaughtExceptions } From f9e486d71791d87b0ac7f404626306d97e88a0d6 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Nov 2021 14:25:10 +0300 Subject: [PATCH 6/8] Fix doc --- kotlinx-coroutines-core/common/README.md | 1 - kotlinx-coroutines-test/common/src/TestScope.kt | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/kotlinx-coroutines-core/common/README.md b/kotlinx-coroutines-core/common/README.md index b00921bbcd..23ca0a55ca 100644 --- a/kotlinx-coroutines-core/common/README.md +++ b/kotlinx-coroutines-core/common/README.md @@ -130,7 +130,6 @@ Low-level primitives for finer-grained control of coroutines. [kotlinx.coroutines.sync.Mutex]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.sync/-mutex/index.html [kotlinx.coroutines.sync.Mutex.lock]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.sync/-mutex/lock.html -[kotlinx.coroutines.sync.Mutex.tryLock]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.sync/-mutex/try-lock.html diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index 3f7a81f601..fd74bf5adf 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -101,8 +101,7 @@ public fun TestScope.advanceTimeBy(delayTimeMillis: Long): Unit = testScheduler. * If you need to have a specific [CoroutineExceptionHandler], please pass it to [launch] on an already-created * [TestCoroutineScope] and share your use case at * [our issue tracker](https://github.com/Kotlin/kotlinx.coroutines/issues). - * * If [context] provides a [Job], that job is used as a parent for the new scope; - * otherwise, a [CompletableJob] is created. + * * If [context] provides a [Job], that job is used as a parent for the new scope. * * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a * different scheduler. From af8a47f6eb19b873b34f06538b64d4285a9453fd Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Nov 2021 15:04:32 +0300 Subject: [PATCH 7/8] Add a comment --- kotlinx-coroutines-test/common/src/TestScope.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/src/TestScope.kt b/kotlinx-coroutines-test/common/src/TestScope.kt index fd74bf5adf..b48b273cd9 100644 --- a/kotlinx-coroutines-test/common/src/TestScope.kt +++ b/kotlinx-coroutines-test/common/src/TestScope.kt @@ -186,7 +186,7 @@ internal class TestScopeImpl(context: CoroutineContext) : finished = true uncaughtExceptions } - val activeJobs = children.filter { it.isActive }.toList() + val activeJobs = children.filter { it.isActive }.toList() // only non-empty if used with `runBlockingTest` if (exceptions.isEmpty()) { if (activeJobs.isNotEmpty()) throw UncompletedCoroutinesError( From b9e83b0ce6a4a63e92324dec1268a4c559d9155e Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 16 Nov 2021 15:07:56 +0300 Subject: [PATCH 8/8] Fix runBlockingTestOnTestScope --- .../common/src/migration/TestBuildersDeprecated.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt index c5979ad96d..68398fb424 100644 --- a/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt +++ b/kotlinx-coroutines-test/common/src/migration/TestBuildersDeprecated.kt @@ -79,7 +79,11 @@ public fun runBlockingTestOnTestScope( scope.testBody() } scope.testScheduler.advanceUntilIdle() - scope.getCompletionExceptionOrNull()?.let { + try { + scope.getCompletionExceptionOrNull() + } catch (e: IllegalStateException) { + null // the deferred was not completed yet; `scope.leave()` should complain then about unfinished jobs + }?.let { val exceptions = try { scope.leave() } catch (e: UncompletedCoroutinesError) {