From 6c52412d49a53da742908dadd1ce28f38ec95831 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 11 Oct 2021 21:31:05 +0300 Subject: [PATCH 01/19] Improve `TestCoroutineScope` * Add more detailed documentation; * Move most verification logic from `runBlockingTest` to `cleanupTestCoroutines` Fixes #1749 * Complete the scope's job if a new job was created for it Fixes #1772 --- .../common/src/TestBuilders.kt | 45 +--------- .../common/src/TestCoroutineScope.kt | 84 ++++++++++++++++--- .../common/test/TestBuildersTest.kt | 2 +- .../common/test/TestCoroutineSchedulerTest.kt | 1 + .../common/test/TestCoroutineScopeTest.kt | 70 ++++++++++++++++ 5 files changed, 149 insertions(+), 53 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index f66f962be7..bf864333b4 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -43,25 +43,16 @@ import kotlin.coroutines.* */ @ExperimentalCoroutinesApi public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { - val (safeContext, dispatcher) = context.checkTestScopeArguments() - val startingJobs = safeContext.activeJobs() - val scope = TestCoroutineScope(safeContext) + val scope = TestCoroutineScope(context) + val scheduler = scope.coroutineContext[TestCoroutineScheduler]!! val deferred = scope.async { scope.testBody() } - dispatcher.scheduler.advanceUntilIdle() + scheduler.advanceUntilIdle() deferred.getCompletionExceptionOrNull()?.let { throw it } scope.cleanupTestCoroutines() - val endingJobs = safeContext.activeJobs() - if ((endingJobs - startingJobs).isNotEmpty()) { - throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") - } -} - -private fun CoroutineContext.activeJobs(): Set { - return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() } /** @@ -78,33 +69,3 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope. @ExperimentalCoroutinesApi public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) - -internal fun CoroutineContext.checkTestScopeArguments(): Pair { - val scheduler: TestCoroutineScheduler - val dispatcher = when (val dispatcher = get(ContinuationInterceptor)) { - is TestDispatcher -> { - val ctxScheduler = get(TestCoroutineScheduler) - if (ctxScheduler == null) { - scheduler = dispatcher.scheduler - } else { - require(dispatcher.scheduler === ctxScheduler) { - "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + - "another scheduler were passed." - } - scheduler = ctxScheduler - } - dispatcher - } - null -> { - scheduler = get(TestCoroutineScheduler) ?: TestCoroutineScheduler() - TestCoroutineDispatcher(scheduler) - } - else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") - } - val exceptionHandler = get(CoroutineExceptionHandler).run { - this?.let { require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $this" } } - this ?: TestCoroutineExceptionHandler() - } - val job = get(Job) ?: SupervisorJob() - return Pair(this + scheduler + dispatcher + exceptionHandler + job, dispatcher) -} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index e4e92bd486..65a3d4002b 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -13,12 +13,13 @@ import kotlin.coroutines.* @ExperimentalCoroutinesApi public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { /** - * Call after the test completes. + * Called after the test completes. + * * Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines]. + * If a new job was created for this scope, the job is completed. * * @throws Throwable the first uncaught exception, if there are any uncaught exceptions. - * @throws AssertionError if any pending tasks are active, however it will not throw for suspended - * coroutines. + * @throws AssertionError if any pending tasks are active. */ @ExperimentalCoroutinesApi public fun cleanupTestCoroutines() @@ -32,29 +33,92 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap private class TestCoroutineScopeImpl ( override val coroutineContext: CoroutineContext, - override val testScheduler: TestCoroutineScheduler + val ownJob: CompletableJob? ): TestCoroutineScope, UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor { + override val testScheduler: TestCoroutineScheduler + get() = coroutineContext[TestCoroutineScheduler]!! + + /** These jobs existed before the coroutine scope was used, so it's alright if they don't get cancelled. */ + val initialJobs = coroutineContext.activeJobs() + override fun cleanupTestCoroutines() { coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() coroutineContext.delayController?.cleanupTestCoroutines() + val jobs = coroutineContext.activeJobs() + if ((jobs - initialJobs).isNotEmpty()) { + throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") + } + ownJob?.complete() } } +private fun CoroutineContext.activeJobs(): Set { + return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() +} + /** - * A scope which provides detailed control over the execution of coroutines for tests. + * A coroutine scope for launching test coroutines. * - * If the provided context does not provide a [ContinuationInterceptor] (Dispatcher) or [CoroutineExceptionHandler], the - * scope adds [TestCoroutineDispatcher] and [TestCoroutineExceptionHandler] automatically. + * It ensures that all the test module machinery is properly initialized. + * * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping, + * a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used. + * * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created. + * * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created + * automatically. + * * If [context] provides a [Job], that job is used for the new scope, but is not completed once the scope completes. + * On the other hand, if there is no [Job] in the context, a [CompletableJob] is created and completed on + * [TestCoroutineScope.cleanupTestCoroutines]. * - * @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController] + * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a + * different scheduler. + * @throws IllegalArgumentException if [context] has a [ContinuationInterceptor] that is not a [TestDispatcher]. + * @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an + * [UncaughtExceptionCaptor]. */ @Suppress("FunctionName") @ExperimentalCoroutinesApi -public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope = - context.checkTestScopeArguments().let { TestCoroutineScopeImpl(it.first, it.second.scheduler) } +public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope { + val scheduler: TestCoroutineScheduler + val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) { + is TestDispatcher -> { + scheduler = dispatcher.scheduler + val ctxScheduler = context[TestCoroutineScheduler] + if (ctxScheduler != null) { + require(dispatcher.scheduler === ctxScheduler) { + "Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " + + "another scheduler were passed." + } + } + dispatcher + } + null -> { + scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler() + TestCoroutineDispatcher(scheduler) + } + else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") + } + val exceptionHandler = context[CoroutineExceptionHandler].run { + this?.let { + require(this is UncaughtExceptionCaptor) { + "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context" + } + } + this ?: TestCoroutineExceptionHandler() + } + val job: Job + val ownJob: CompletableJob? + if (context[Job] == null) { + ownJob = SupervisorJob() + job = ownJob + } else { + ownJob = null + job = context[Job]!! + } + return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job, ownJob) +} private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor get() { diff --git a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt index a3167e5876..7fefaf78b5 100644 --- a/kotlinx-coroutines-test/common/test/TestBuildersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestBuildersTest.kt @@ -104,7 +104,7 @@ class TestBuildersTest { } @Test - fun whenInrunBlocking_runBlockingTest_nestsProperly() { + fun whenInRunBlocking_runBlockingTest_nestsProperly() { // this is not a supported use case, but it is possible so ensure it works val scope = TestCoroutineScope() diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index ab6ced4741..f7d2ed13fd 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -5,6 +5,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlin.coroutines.* import kotlin.test.* class TestCoroutineSchedulerTest { diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index a7d4480d63..968b093c79 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -50,6 +50,76 @@ class TestCoroutineScopeTest { } } + /** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */ + @Test + fun testPresentDelaysThrowing() { + val scope = TestCoroutineScope() + var result = false + scope.launch { + delay(5) + result = true + } + assertFalse(result) + assertFailsWith { scope.cleanupTestCoroutines() } + assertFalse(result) + } + + /** Tests that the cleanup procedure throws if there were active jobs by the end. */ + @Test + fun testActiveJobsThrowing() { + val scope = TestCoroutineScope() + var result = false + val deferred = CompletableDeferred() + scope.launch { + deferred.await() + result = true + } + assertFalse(result) + assertFailsWith { scope.cleanupTestCoroutines() } + assertFalse(result) + } + + /** Tests that the cleanup procedure doesn't throw if it detects that the job is already cancelled. */ + @Test + fun testCancelledDelaysNotThrowing() { + val scope = TestCoroutineScope() + var result = false + val deferred = CompletableDeferred() + val job = scope.launch { + deferred.await() + result = true + } + job.cancel() + assertFalse(result) + scope.cleanupTestCoroutines() + assertFalse(result) + } + + /** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */ + @Test + fun testCompletesOwnJob() { + val scope = TestCoroutineScope() + var handlerCalled = false + scope.coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + scope.cleanupTestCoroutines() + assertTrue(handlerCalled) + } + + /** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */ + @Test + fun testDoesNotCompleteGivenJob() { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + val scope = TestCoroutineScope(job) + scope.cleanupTestCoroutines() + assertFalse(handlerCalled) + } + private val invalidContexts = listOf( Dispatchers.Default, // not a [TestDispatcher] TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler From b43b87ddd0c864baa77b0456cbd59d5a79cd1e03 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 26 Oct 2021 10:27:41 +0300 Subject: [PATCH 02/19] Fixes --- .../common/src/TestCoroutineScope.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 65a3d4002b..ec7c332410 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -31,9 +31,9 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap public val testScheduler: TestCoroutineScheduler } -private class TestCoroutineScopeImpl ( +private class TestCoroutineScopeImpl( override val coroutineContext: CoroutineContext, - val ownJob: CompletableJob? + private val ownJob: CompletableJob? ): TestCoroutineScope, UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor @@ -42,14 +42,16 @@ private class TestCoroutineScopeImpl ( get() = coroutineContext[TestCoroutineScheduler]!! /** These jobs existed before the coroutine scope was used, so it's alright if they don't get cancelled. */ - val initialJobs = coroutineContext.activeJobs() + private val initialJobs = coroutineContext.activeJobs() override fun cleanupTestCoroutines() { coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() coroutineContext.delayController?.cleanupTestCoroutines() val jobs = coroutineContext.activeJobs() if ((jobs - initialJobs).isNotEmpty()) { - throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") + val exception = UncompletedCoroutinesError("Test finished with active jobs: $jobs") + ownJob?.completeExceptionally(exception) + throw exception } ownJob?.complete() } From 2cddb284b5c015a6fc2ac1c77d6f5ca476b78049 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 27 Oct 2021 10:43:26 +0300 Subject: [PATCH 03/19] Fix ownJob not always completing --- .../common/src/TestCoroutineScope.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index ec7c332410..69af8cd764 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -45,11 +45,13 @@ private class TestCoroutineScopeImpl( private val initialJobs = coroutineContext.activeJobs() override fun cleanupTestCoroutines() { - coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() - coroutineContext.delayController?.cleanupTestCoroutines() - val jobs = coroutineContext.activeJobs() - if ((jobs - initialJobs).isNotEmpty()) { - val exception = UncompletedCoroutinesError("Test finished with active jobs: $jobs") + try { + coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() + coroutineContext.delayController?.cleanupTestCoroutines() + val jobs = coroutineContext.activeJobs() + if ((jobs - initialJobs).isNotEmpty()) + throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") + } catch (exception: Throwable) { ownJob?.completeExceptionally(exception) throw exception } From 1e9be832a68d402b527f21b219b90b2e916881f6 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 28 Oct 2021 14:26:03 +0300 Subject: [PATCH 04/19] Don't complete the created job on TestCoroutineScope.cleanup --- .../common/src/TestCoroutineScope.kt | 35 +++++-------------- .../common/test/TestCoroutineScopeTest.kt | 25 ------------- 2 files changed, 9 insertions(+), 51 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 69af8cd764..c2c67b1c56 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -32,8 +32,7 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap } private class TestCoroutineScopeImpl( - override val coroutineContext: CoroutineContext, - private val ownJob: CompletableJob? + override val coroutineContext: CoroutineContext ): TestCoroutineScope, UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor @@ -45,17 +44,11 @@ private class TestCoroutineScopeImpl( private val initialJobs = coroutineContext.activeJobs() override fun cleanupTestCoroutines() { - try { - coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() - coroutineContext.delayController?.cleanupTestCoroutines() - val jobs = coroutineContext.activeJobs() - if ((jobs - initialJobs).isNotEmpty()) - throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") - } catch (exception: Throwable) { - ownJob?.completeExceptionally(exception) - throw exception - } - ownJob?.complete() + coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor() + coroutineContext.delayController?.cleanupTestCoroutines() + val jobs = coroutineContext.activeJobs() + if ((jobs - initialJobs).isNotEmpty()) + throw UncompletedCoroutinesError("Test finished with active jobs: $jobs") } } @@ -72,9 +65,7 @@ private fun CoroutineContext.activeJobs(): Set { * * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created. * * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created * automatically. - * * If [context] provides a [Job], that job is used for the new scope, but is not completed once the scope completes. - * On the other hand, if there is no [Job] in the context, a [CompletableJob] is created and completed on - * [TestCoroutineScope.cleanupTestCoroutines]. + * * If [context] provides a [Job], that job is used for the new scope; otherwise, a [CompletableJob] is created. * * @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a * different scheduler. @@ -112,16 +103,8 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) } this ?: TestCoroutineExceptionHandler() } - val job: Job - val ownJob: CompletableJob? - if (context[Job] == null) { - ownJob = SupervisorJob() - job = ownJob - } else { - ownJob = null - job = context[Job]!! - } - return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job, ownJob) + val job: Job = context[Job] ?: SupervisorJob() + return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job) } private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index 968b093c79..85e44351ee 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -95,31 +95,6 @@ class TestCoroutineScopeTest { assertFalse(result) } - /** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */ - @Test - fun testCompletesOwnJob() { - val scope = TestCoroutineScope() - var handlerCalled = false - scope.coroutineContext.job.invokeOnCompletion { - handlerCalled = true - } - scope.cleanupTestCoroutines() - assertTrue(handlerCalled) - } - - /** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */ - @Test - fun testDoesNotCompleteGivenJob() { - var handlerCalled = false - val job = Job() - job.invokeOnCompletion { - handlerCalled = true - } - val scope = TestCoroutineScope(job) - scope.cleanupTestCoroutines() - assertFalse(handlerCalled) - } - private val invalidContexts = listOf( Dispatchers.Default, // not a [TestDispatcher] TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler From a32533c7b4bcbfea5871caf13ce15eb6425316a9 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 12 Oct 2021 14:01:33 +0300 Subject: [PATCH 05/19] Implement 'runTest' that waits for asynchronous completion --- .../api/kotlinx-coroutines-test.api | 2 + .../common/src/TestBuilders.kt | 140 +++++++++++++++++- .../common/src/TestCoroutineDispatcher.kt | 1 + .../common/src/TestCoroutineScheduler.kt | 19 +++ .../common/test/TestRunTest.kt | 41 +++++ .../js/src/TestBuilders.kt | 15 ++ .../js/test/PromiseTest.kt | 21 +++ .../jvm/src/TestBuildersJvm.kt | 15 ++ .../native/src/TestBuilders.kt | 15 ++ .../src/{ => internal}/TestMainDispatcher.kt | 0 10 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 kotlinx-coroutines-test/common/test/TestRunTest.kt create mode 100644 kotlinx-coroutines-test/js/src/TestBuilders.kt create mode 100644 kotlinx-coroutines-test/js/test/PromiseTest.kt create mode 100644 kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt create mode 100644 kotlinx-coroutines-test/native/src/TestBuilders.kt rename kotlinx-coroutines-test/native/src/{ => internal}/TestMainDispatcher.kt (100%) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 0aebee5275..831276f2e0 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -14,6 +14,8 @@ public final class kotlinx/coroutines/test/TestBuildersKt { public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineDispatcher;Lkotlin/jvm/functions/Function2;)V public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun runTest$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 bf864333b4..d19cfe9fd6 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -5,6 +5,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlinx.coroutines.selects.* import kotlin.coroutines.* /** @@ -42,9 +43,10 @@ import kotlin.coroutines.* * @param testBody The code of the unit-test. */ @ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { val scope = TestCoroutineScope(context) - val scheduler = scope.coroutineContext[TestCoroutineScheduler]!! + val scheduler = scope.testScheduler val deferred = scope.async { scope.testBody() } @@ -69,3 +71,139 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope. @ExperimentalCoroutinesApi public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) + +/** + * A test result. + * + * * On JVM and Native, this resolves to [Unit], representing the fact that tests are run in a blocking manner on these + * platforms: a call to a function returning a [TestResult] will simply execute the test inside it. + * * On JS, this is a `Promise`, which reflects the fact that the test-running function does not wait for a test to + * finish. The JS test frameworks typically support returning `Promise` from a test and will correctly handle it. + * + * Because of the behavior on JS, extra care must be taken when writing multiplatform tests to avoid losing test errors: + * * Don't do anything after running the functions returning a [TestResult]. On JS, this code will execute *before* the + * test finishes. + * * As a corollary, don't run functions returning a [TestResult] more than once per test. The only valid thing to do + * with a [TestResult] is to immediately `return` it from a test. + * * Don't nest functions returning a [TestResult]. + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +public expect class TestResult + +/** + * Executes [testBody] as a test, returning [TestResult]. + * + * On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs + * will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary. + * On JS, this function creates a `Promise` that executes the test body with the delay-skipping behavior. + * + * ``` + * @Test + * fun exampleTest() = runTest { + * val deferred = async { + * delay(1_000) + * async { + * delay(1_000) + * }.await() + * } + * + * deferred.await() // result available immediately + * } + * ``` + * + * The platform difference entails that, in order to use this function correctly in common code, one must always + * immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See + * [TestResult] for details on this. + * + * ### Delay-skipping + * + * Delay-skipping is achieved by using virtual time. [TestCoroutineScheduler] is automatically created (if it wasn't + * passed in some way in [context]) and can be used to control the virtual time, advancing it, running the tasks + * scheduled at a specific time etc. Some convenience methods are available on [TestCoroutineScope] to control the + * scheduler. + * + * Delays in code that runs inside dispatchers that don't use a [TestCoroutineScheduler] don't get skipped: + * ``` + * @Test + * fun exampleTest() = runTest { + * val elapsed = TimeSource.Monotonic.measureTime { + * val deferred = async { + * delay(1_000) // will be skipped + * withContext(Dispatchers.Default) { + * delay(5_000) // Dispatchers.Default don't know about TestCoroutineScheduler + * } + * } + * deferred.await() + * } + * println(elapsed) // about five seconds + * } + * ``` + * + * ### Failures + * + * This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test + * will be failed (which, on JVM and Native, means that [runTest] itself will throw [UncompletedCoroutinesError], + * whereas on JS, the `Promise` will fail with it). + * + * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due + * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait + * for [dispatchTimeoutMs] milliseconds (by default, 10 seconds) from the moment when [TestCoroutineScheduler] becomes + * idle before throwing [UncompletedCoroutinesError]. If some dispatcher linked to [TestCoroutineScheduler] receives a + * task during that time, the timer gets reset. + * + * Unhandled exceptions thrown by coroutines in the test will be rethrown at the end of the test. + * + * ### Configuration + * + * [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine + * scope created for the test, [context] also can be used to change how the test is executed. + * See the [TestCoroutineScope] constructor documentation for details. + * + * @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for + * details. + */ +public fun runTest( + context: CoroutineContext = EmptyCoroutineContext, + dispatchTimeoutMs: Long = 10_000, + testBody: suspend TestCoroutineScope.() -> Unit +): TestResult = createTestResult { + val testScope = TestCoroutineScope(context) + val scheduler = testScope.testScheduler + val deferred = testScope.async { + testScope.testBody() + } + var completed = false + while (!completed) { + scheduler.advanceUntilIdle() + if (deferred.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 + } + try { + withTimeout(dispatchTimeoutMs) { + select { + deferred.onAwait { + completed = true + } + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } + } + } + } catch (e: TimeoutCancellationException) { + throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") + } + } + deferred.getCompletionExceptionOrNull()?.let { + throw it + } + testScope.cleanupTestCoroutines() +} + +/** + * Runs [testProcedure], creating a [TestResult]. + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` +internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt index 7baf0a8e17..3537910ac5 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineDispatcher.kt @@ -44,6 +44,7 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin override fun dispatch(context: CoroutineContext, block: Runnable) { checkSchedulerInContext(scheduler, context) if (dispatchImmediately) { + scheduler.sendDispatchEvent() block.run() } else { post(block) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 2f5e1fd216..75c41f0a49 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -6,7 +6,10 @@ package kotlinx.coroutines.test import kotlinx.atomicfu.* import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.internal.* +import kotlinx.coroutines.selects.* import kotlin.coroutines.* import kotlin.jvm.* @@ -46,6 +49,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout get() = synchronized(lock) { field } private set + /** A channel for notifying about the fact that a dispatch recently happened. */ + private val dispatchEvents: Channel = Channel(CONFLATED) + /** * Registers a request for the scheduler to notify [dispatcher] at a virtual moment [timeDeltaMillis] milliseconds * later via [TestDispatcher.processEvent], which will be called with the provided [marker] object. @@ -59,6 +65,7 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout isCancelled: (T) -> Boolean ): DisposableHandle { require(timeDeltaMillis >= 0) { "Attempted scheduling an event earlier in time (with the time delta $timeDeltaMillis)" } + sendDispatchEvent() val count = count.getAndIncrement() return synchronized(lock) { val time = addClamping(currentTime, timeDeltaMillis) @@ -162,6 +169,18 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout return presentEvents.all { it.isCancelled() } } } + + /** + * Notifies this scheduler about a dispatch event. + */ + internal fun sendDispatchEvent() { + dispatchEvents.trySend(Unit) + } + + /** + * Consumes the knowledge that a dispatch event happened recently. + */ + internal val onDispatchEvent: SelectClause1 get() = dispatchEvents.onReceive } // Some error-throwing functions for pretty stack traces diff --git a/kotlinx-coroutines-test/common/test/TestRunTest.kt b/kotlinx-coroutines-test/common/test/TestRunTest.kt new file mode 100644 index 0000000000..5908f5c814 --- /dev/null +++ b/kotlinx-coroutines-test/common/test/TestRunTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlin.coroutines.* +import kotlin.test.* +import kotlin.time.* + +class TestRunTest { + + @Test + fun testWithContextDispatching() = runTest { + var counter = 0 + withContext(Dispatchers.Default) { + counter += 1 + } + assertEquals(counter, 1) + } + + @Test + fun testJoiningForkedJob() = runTest { + var counter = 0 + val job = GlobalScope.launch { + counter += 1 + } + job.join() + assertEquals(counter, 1) + } + + @Test + fun testSuspendCancellableCoroutine() = runTest { + val answer = suspendCoroutine { + it.resume(42) + } + assertEquals(42, answer) + } + +} diff --git a/kotlinx-coroutines-test/js/src/TestBuilders.kt b/kotlinx-coroutines-test/js/src/TestBuilders.kt new file mode 100644 index 0000000000..3976885991 --- /dev/null +++ b/kotlinx-coroutines-test/js/src/TestBuilders.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test +import kotlinx.coroutines.* +import kotlin.js.* + +@Suppress("ACTUAL_WITHOUT_EXPECT", "ACTUAL_TYPE_ALIAS_TO_CLASS_WITH_DECLARATION_SITE_VARIANCE") +public actual typealias TestResult = Promise + +internal actual fun createTestResult(testProcedure: suspend () -> Unit): TestResult = + GlobalScope.promise { + testProcedure() + } \ No newline at end of file diff --git a/kotlinx-coroutines-test/js/test/PromiseTest.kt b/kotlinx-coroutines-test/js/test/PromiseTest.kt new file mode 100644 index 0000000000..ff09d9ab86 --- /dev/null +++ b/kotlinx-coroutines-test/js/test/PromiseTest.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlin.test.* + +class PromiseTest { + @Test + fun testCompletionFromPromise() = runTest { + var promiseEntered = false + val p = promise { + delay(1) + promiseEntered = true + } + delay(2) + p.await() + assertTrue(promiseEntered) + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt new file mode 100644 index 0000000000..7cafb54753 --- /dev/null +++ b/kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +package kotlinx.coroutines.test + +import kotlinx.coroutines.* + +@Suppress("ACTUAL_WITHOUT_EXPECT") +public actual typealias TestResult = Unit + +internal actual fun createTestResult(testProcedure: suspend () -> Unit) { + runBlocking { + testProcedure() + } +} \ No newline at end of file diff --git a/kotlinx-coroutines-test/native/src/TestBuilders.kt b/kotlinx-coroutines-test/native/src/TestBuilders.kt new file mode 100644 index 0000000000..c3176a03de --- /dev/null +++ b/kotlinx-coroutines-test/native/src/TestBuilders.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test +import kotlinx.coroutines.* + +@Suppress("ACTUAL_WITHOUT_EXPECT") +public actual typealias TestResult = Unit + +internal actual fun createTestResult(testProcedure: suspend () -> Unit) { + runBlocking { + testProcedure() + } +} diff --git a/kotlinx-coroutines-test/native/src/TestMainDispatcher.kt b/kotlinx-coroutines-test/native/src/internal/TestMainDispatcher.kt similarity index 100% rename from kotlinx-coroutines-test/native/src/TestMainDispatcher.kt rename to kotlinx-coroutines-test/native/src/internal/TestMainDispatcher.kt From 4080916065f0e8cd97562627ce0853814148346d Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Tue, 12 Oct 2021 20:25:11 +0300 Subject: [PATCH 06/19] Prevent nested 'runTest' --- .../api/kotlinx-coroutines-test.api | 4 +++ .../common/src/TestBuilders.kt | 34 ++++++++++++++++--- .../common/test/TestRunTest.kt | 7 ++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 831276f2e0..f60326b0eb 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -15,7 +15,11 @@ public final class kotlinx/coroutines/test/TestBuildersKt { public static final fun runBlockingTest (Lkotlinx/coroutines/test/TestCoroutineScope;Lkotlin/jvm/functions/Function2;)V public static synthetic fun runBlockingTest$default (Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V public static final fun runTest (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;)V + public static final fun runTest (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;)V + public static final fun runTest (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;)V public static synthetic fun runTest$default (Lkotlin/coroutines/CoroutineContext;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestCoroutineScope;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun runTest$default (Lkotlinx/coroutines/test/TestDispatcher;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } public final class kotlinx/coroutines/test/TestCoroutineDispatcher : kotlinx/coroutines/test/TestDispatcher, kotlinx/coroutines/Delay, kotlinx/coroutines/test/SchedulerAsDelayController { diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index d19cfe9fd6..098f3fc213 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -42,7 +42,6 @@ import kotlin.coroutines.* * then they must implement [DelayController] and [TestCoroutineExceptionHandler] respectively. * @param testBody The code of the unit-test. */ -@ExperimentalCoroutinesApi @Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { val scope = TestCoroutineScope(context) @@ -61,14 +60,14 @@ public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, te * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineScope]. */ // todo: need documentation on how this extension is supposed to be used -@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(coroutineContext, block) /** * Convenience method for calling [runBlockingTest] on an existing [TestCoroutineDispatcher]. */ -@ExperimentalCoroutinesApi +@Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit = runBlockingTest(this, block) @@ -164,10 +163,10 @@ public expect class TestResult */ public fun runTest( context: CoroutineContext = EmptyCoroutineContext, - dispatchTimeoutMs: Long = 10_000, + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, testBody: suspend TestCoroutineScope.() -> Unit ): TestResult = createTestResult { - val testScope = TestCoroutineScope(context) + val testScope = TestCoroutineScope(context + RunningInRunTest()) val scheduler = testScope.testScheduler val deferred = testScope.async { testScope.testBody() @@ -207,3 +206,28 @@ public fun runTest( */ @Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult + +/** TODO: docs */ +public fun TestCoroutineScope.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult { + val ctx = this.coroutineContext + if (ctx[RunningInRunTest] != null) + throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") + return runTest(ctx, dispatchTimeoutMs, block) +} + +/** TODO: docs */ +public fun TestDispatcher.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult = + runTest(this, dispatchTimeoutMs, block) + +/** A coroutine context element indicating that the coroutine is running inside `runTest`. */ +private class RunningInRunTest: AbstractCoroutineContextElement(RunningInRunTest), CoroutineContext.Element { + companion object Key : CoroutineContext.Key +} + +private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L diff --git a/kotlinx-coroutines-test/common/test/TestRunTest.kt b/kotlinx-coroutines-test/common/test/TestRunTest.kt index 5908f5c814..f10071b6c9 100644 --- a/kotlinx-coroutines-test/common/test/TestRunTest.kt +++ b/kotlinx-coroutines-test/common/test/TestRunTest.kt @@ -38,4 +38,11 @@ class TestRunTest { assertEquals(42, answer) } + @Test + fun testNestedRunTestForbidden() = runTest { + assertFailsWith { + runTest { } + } + } + } From b6d55c9c63e43effac05e7e5f7b663214cbe1f68 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 13 Oct 2021 11:41:52 +0300 Subject: [PATCH 07/19] Document the convenience methods --- .../common/src/TestBuilders.kt | 95 ++++++++++++------- .../jvm/test/MultithreadingTest.kt | 12 +++ 2 files changed, 71 insertions(+), 36 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 098f3fc213..e8cca8be31 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -87,6 +87,7 @@ public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineS * * Don't nest functions returning a [TestResult]. */ @Suppress("NO_ACTUAL_FOR_EXPECT") +@DelicateCoroutinesApi public expect class TestResult /** @@ -161,44 +162,49 @@ public expect class TestResult * @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for * details. */ +@DelicateCoroutinesApi public fun runTest( context: CoroutineContext = EmptyCoroutineContext, dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, testBody: suspend TestCoroutineScope.() -> Unit -): TestResult = createTestResult { - val testScope = TestCoroutineScope(context + RunningInRunTest()) - val scheduler = testScope.testScheduler - val deferred = testScope.async { - testScope.testBody() - } - var completed = false - while (!completed) { - scheduler.advanceUntilIdle() - if (deferred.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 +): TestResult { + if (context[RunningInRunTest] != null) + throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") + return createTestResult { + val testScope = TestCoroutineScope(context + RunningInRunTest()) + val scheduler = testScope.testScheduler + val deferred = testScope.async { + testScope.testBody() } - try { - withTimeout(dispatchTimeoutMs) { - select { - deferred.onAwait { - completed = true - } - scheduler.onDispatchEvent { - // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + var completed = false + while (!completed) { + scheduler.advanceUntilIdle() + if (deferred.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 + } + try { + withTimeout(dispatchTimeoutMs) { + select { + deferred.onAwait { + completed = true + } + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } } } + } catch (e: TimeoutCancellationException) { + throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") } - } catch (e: TimeoutCancellationException) { - throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") } + deferred.getCompletionExceptionOrNull()?.let { + throw it + } + testScope.cleanupTestCoroutines() } - deferred.getCompletionExceptionOrNull()?.let { - throw it - } - testScope.cleanupTestCoroutines() } /** @@ -207,18 +213,33 @@ public fun runTest( @Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult -/** TODO: docs */ +/** + * Runs a test in a [TestCoroutineScope] based on this one. + * + * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run + * [block] will be different from this one, but will reuse its [Job]; therefore, even if calling + * [TestCoroutineScope.cleanupTestCoroutines] on this scope were to complete its job, [runTest] won't complete it at the + * end of the test. + * + * 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. + */ +@DelicateCoroutinesApi public fun TestCoroutineScope.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, block: suspend TestCoroutineScope.() -> Unit -): TestResult { - val ctx = this.coroutineContext - if (ctx[RunningInRunTest] != null) - throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") - return runTest(ctx, dispatchTimeoutMs, block) -} +): TestResult = + runTest(coroutineContext, dispatchTimeoutMs, block) -/** TODO: docs */ +/** + * 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. + */ +@DelicateCoroutinesApi public fun TestDispatcher.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, block: suspend TestCoroutineScope.() -> Unit @@ -230,4 +251,6 @@ private class RunningInRunTest: AbstractCoroutineContextElement(RunningInRunTest companion object Key : CoroutineContext.Key } +/** The default timeout to use when waiting for asynchronous completions of the coroutines managed by + * a [TestCoroutineScheduler]. */ private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index 7dc99406a1..46fcc8df80 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.* import kotlinx.coroutines.test.* +import java.util.concurrent.* +import kotlin.concurrent.* import kotlin.coroutines.* import kotlin.test.* @@ -86,4 +88,14 @@ class MultithreadingTest : TestBase() { assertEquals(3, deferred.await()) } } + + @Test + fun testResumingFromAnotherThread() = runTest { + suspendCancellableCoroutine { cont -> + thread { + Thread.sleep(10) + cont.resume(Unit) + } + } + } } \ No newline at end of file From b13fb613d5c27970150146c689e5beab63173c1a Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 14 Oct 2021 15:19:56 +0300 Subject: [PATCH 08/19] Fix a race condition --- .../common/src/TestCoroutineScheduler.kt | 4 +++- kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt | 7 +++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 75c41f0a49..00c6975cc8 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -65,12 +65,14 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout isCancelled: (T) -> Boolean ): DisposableHandle { require(timeDeltaMillis >= 0) { "Attempted scheduling an event earlier in time (with the time delta $timeDeltaMillis)" } - sendDispatchEvent() val count = count.getAndIncrement() return synchronized(lock) { val time = addClamping(currentTime, timeDeltaMillis) val event = TestDispatchEvent(dispatcher, count, time, marker as Any) { isCancelled(marker) } events.addLast(event) + /** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's + * actually anything in the event queue. */ + sendDispatchEvent() DisposableHandle { synchronized(lock) { events.remove(event) diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index 46fcc8df80..71c996067b 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -4,12 +4,11 @@ import kotlinx.coroutines.* import kotlinx.coroutines.test.* -import java.util.concurrent.* import kotlin.concurrent.* import kotlin.coroutines.* import kotlin.test.* -class MultithreadingTest : TestBase() { +class MultithreadingTest { @Test fun incorrectlyCalledRunBlocking_doesNotHaveSameInterceptor() = runBlockingTest { @@ -24,7 +23,7 @@ class MultithreadingTest : TestBase() { } @Test - fun testSingleThreadExecutor() = runTest { + fun testSingleThreadExecutor() = runBlocking { val mainThread = Thread.currentThread() Dispatchers.setMain(Dispatchers.Unconfined) newSingleThreadContext("testSingleThread").use { threadPool -> @@ -98,4 +97,4 @@ class MultithreadingTest : TestBase() { } } } -} \ No newline at end of file +} From 862f8345c3b2ef2806128da8605c30cf2a53c912 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 14 Oct 2021 16:40:39 +0300 Subject: [PATCH 09/19] Comments for tests --- .../common/test/{TestRunTest.kt => RunTestTest.kt} | 9 +++++++-- kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) rename kotlinx-coroutines-test/common/test/{TestRunTest.kt => RunTestTest.kt} (68%) diff --git a/kotlinx-coroutines-test/common/test/TestRunTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt similarity index 68% rename from kotlinx-coroutines-test/common/test/TestRunTest.kt rename to kotlinx-coroutines-test/common/test/RunTestTest.kt index f10071b6c9..1745c90531 100644 --- a/kotlinx-coroutines-test/common/test/TestRunTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -5,12 +5,14 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlin.coroutines.* import kotlin.test.* import kotlin.time.* -class TestRunTest { +class RunTestTest { + /** Tests that [withContext] that sends work to other threads works in [runTest]. */ @Test fun testWithContextDispatching() = runTest { var counter = 0 @@ -20,6 +22,7 @@ class TestRunTest { assertEquals(counter, 1) } + /** Tests that joining [GlobalScope.launch] works in [runTest]. */ @Test fun testJoiningForkedJob() = runTest { var counter = 0 @@ -30,14 +33,16 @@ class TestRunTest { assertEquals(counter, 1) } + /** Tests [suspendCoroutine] not failing [runTest]. */ @Test - fun testSuspendCancellableCoroutine() = runTest { + fun testSuspendCoroutine() = runTest { val answer = suspendCoroutine { it.resume(42) } assertEquals(42, answer) } + /** Tests that [runTest] attempts to detect it being run inside another [runTest] and failing in such scenarios. */ @Test fun testNestedRunTestForbidden() = runTest { assertFailsWith { diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index 71c996067b..6b0c071a56 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -88,6 +88,7 @@ class MultithreadingTest { } } + /** Tests that resuming the coroutine of [runTest] asynchronously in reasonable time succeeds. */ @Test fun testResumingFromAnotherThread() = runTest { suspendCancellableCoroutine { cont -> From 2e7a0399b862aad2399704b1bd54636f5ee88581 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 14 Oct 2021 21:35:28 +0300 Subject: [PATCH 10/19] Add more tests for 'runTest' --- .../common/src/TestBuilders.kt | 9 ++- .../test/{TestModuleHelpers.kt => Helpers.kt} | 5 ++ .../common/test/RunTestTest.kt | 72 ++++++++++++++++++- kotlinx-coroutines-test/js/test/Helpers.kt | 21 ++++++ kotlinx-coroutines-test/jvm/test/Helpers.kt | 12 ++++ .../native/test/Helpers.kt | 12 ++++ 6 files changed, 127 insertions(+), 4 deletions(-) rename kotlinx-coroutines-test/common/test/{TestModuleHelpers.kt => Helpers.kt} (80%) create mode 100644 kotlinx-coroutines-test/js/test/Helpers.kt create mode 100644 kotlinx-coroutines-test/jvm/test/Helpers.kt create mode 100644 kotlinx-coroutines-test/native/test/Helpers.kt diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index e8cca8be31..75e5244e2e 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -170,9 +170,9 @@ 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 = TestCoroutineScope(context + RunningInRunTest()) + val scheduler = testScope.testScheduler return createTestResult { - val testScope = TestCoroutineScope(context + RunningInRunTest()) - val scheduler = testScope.testScheduler val deferred = testScope.async { testScope.testBody() } @@ -197,6 +197,11 @@ public fun runTest( } } } catch (e: TimeoutCancellationException) { + try { + testScope.cleanupTestCoroutines() + } catch (e: UncompletedCoroutinesError) { + // we expect these and will instead throw a more informative exception just below. + } throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") } } diff --git a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt similarity index 80% rename from kotlinx-coroutines-test/common/test/TestModuleHelpers.kt rename to kotlinx-coroutines-test/common/test/Helpers.kt index f3b8e79407..453472ad81 100644 --- a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -28,3 +28,8 @@ inline fun assertRunsFast(timeout: Duration, block: () -> T): T { */ @OptIn(ExperimentalTime::class) inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.seconds(2), block) + +/** + * Passes [test] as an argument to [block], but as a function returning not a [TestResult] but [Unit]. +*/ +expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult \ No newline at end of file diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 1745c90531..91b82617b7 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -5,10 +5,8 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* import kotlin.coroutines.* import kotlin.test.* -import kotlin.time.* class RunTestTest { @@ -50,4 +48,74 @@ class RunTestTest { } } + /** Tests that even the dispatch timeout of `0` is fine if all the dispatches go through the same scheduler. */ + @Test + fun testRunTestWithZeroTimeoutWithControlledDispatches() = runTest(dispatchTimeoutMs = 0) { + // below is some arbitrary concurrent code where all dispatches go through the same scheduler. + launch { + delay(2000) + } + val deferred = async { + val job = launch(TestCoroutineDispatcher(testScheduler)) { + launch { + delay(500) + } + delay(1000) + } + job.join() + } + deferred.await() + } + + /** Tests that a dispatch timeout of `0` will fail the test if there are some dispatches outside the scheduler. */ + @Test + fun testRunTestWithZeroTimeoutWithUncontrolledDispatches() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 0) { + withContext(Dispatchers.Default) { + delay(10) + 3 + } + throw IllegalStateException("shouldn't be reached") + } + } + + /** Tests that too low of a dispatch timeout causes crashes. */ + @Test + fun testRunTestWithSmallTimeout() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 100) { + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + throw RuntimeException("shouldn't be reached") + } + } + + /** Tests that too low of a dispatch timeout causes crashes. */ + @Test + fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) { + withContext(Dispatchers.Default) { + delay(50) + } + } + + /** Tests uncaught exceptions taking priority over dispatch timeout in error reports. */ + @Test + fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 1) { + coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException()) + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + throw RuntimeException("shouldn't be reached") + } + } + } diff --git a/kotlinx-coroutines-test/js/test/Helpers.kt b/kotlinx-coroutines-test/js/test/Helpers.kt new file mode 100644 index 0000000000..42afb599be --- /dev/null +++ b/kotlinx-coroutines-test/js/test/Helpers.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult = + GlobalScope.promise { + val promise = test() + promise.then( + { + block { + } + }, { + block { + throw it + } + }) + } \ No newline at end of file diff --git a/kotlinx-coroutines-test/jvm/test/Helpers.kt b/kotlinx-coroutines-test/jvm/test/Helpers.kt new file mode 100644 index 0000000000..017f2369bb --- /dev/null +++ b/kotlinx-coroutines-test/jvm/test/Helpers.kt @@ -0,0 +1,12 @@ +/* + * 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.* + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { + block { + test() + } +} diff --git a/kotlinx-coroutines-test/native/test/Helpers.kt b/kotlinx-coroutines-test/native/test/Helpers.kt new file mode 100644 index 0000000000..017f2369bb --- /dev/null +++ b/kotlinx-coroutines-test/native/test/Helpers.kt @@ -0,0 +1,12 @@ +/* + * 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.* + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { + block { + test() + } +} From 3e0d885b12fb1240272abdfef98f010663822eed Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 15 Oct 2021 09:45:35 +0300 Subject: [PATCH 11/19] Disable tests failing on Native --- .../common/test/RunTestTest.kt | 2 ++ kotlinx-coroutines-test/js/test/Helpers.kt | 23 ++++++++----------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 91b82617b7..b0411b9e68 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -83,6 +83,7 @@ class RunTestTest { /** Tests that too low of a dispatch timeout causes crashes. */ @Test + @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native fun testRunTestWithSmallTimeout() = testResultMap({ fn -> assertFailsWith { fn() } }) { @@ -105,6 +106,7 @@ class RunTestTest { /** Tests uncaught exceptions taking priority over dispatch timeout in error reports. */ @Test + @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> assertFailsWith { fn() } }) { diff --git a/kotlinx-coroutines-test/js/test/Helpers.kt b/kotlinx-coroutines-test/js/test/Helpers.kt index 42afb599be..b0a767c5df 100644 --- a/kotlinx-coroutines-test/js/test/Helpers.kt +++ b/kotlinx-coroutines-test/js/test/Helpers.kt @@ -4,18 +4,13 @@ package kotlinx.coroutines.test -import kotlinx.coroutines.* - actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult = - GlobalScope.promise { - val promise = test() - promise.then( - { - block { - } - }, { - block { - throw it - } - }) - } \ No newline at end of file + test().then( + { + block { + } + }, { + block { + throw it + } + }) From c626470b92baa9cf1aa7741b40477cc4515e086f Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Fri, 15 Oct 2021 14:26:35 +0300 Subject: [PATCH 12/19] Fix build --- .../common/test/RunTestTest.kt | 33 ++++++++ .../common/test/TestCoroutineSchedulerTest.kt | 84 +++++++++++++++++-- .../common/test/TestDispatchersTest.kt | 2 +- .../jvm/test/{Helpers.kt => HelpersJvm.kt} | 2 - .../native/test/Helpers.kt | 2 - 5 files changed, 113 insertions(+), 10 deletions(-) rename kotlinx-coroutines-test/jvm/test/{Helpers.kt => HelpersJvm.kt} (89%) diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index b0411b9e68..68c9ab9eb5 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -120,4 +120,37 @@ 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) { + assertFailsWith { + runTest(ctx) { } + } + } + } + + /** Tests that throwing exceptions in [runTest] fails the test with them. */ + @Test + fun testThrowingInRunTestBody() = testResultMap({ + assertFailsWith { it() } + }) { + runTest { + throw RuntimeException() + } + } + + /** Tests that throwing exceptions in pending tasks [runTest] fails the test with them. */ + @Test + fun testThrowingInRunTestPendingTask() = testResultMap({ + assertFailsWith { it() } + }) { + runTest { + launch { + delay(SLOW) + throw RuntimeException() + } + } + } + } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index f7d2ed13fd..8751f7d067 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -5,13 +5,12 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* -import kotlin.coroutines.* import kotlin.test.* class TestCoroutineSchedulerTest { /** Tests that `TestCoroutineScheduler` attempts to detect if there are several instances of it. */ @Test - fun testContextElement() = runBlockingTest { + fun testContextElement() = runTest { assertFailsWith { withContext(TestCoroutineDispatcher()) { } @@ -21,7 +20,7 @@ class TestCoroutineSchedulerTest { /** Tests that, as opposed to [DelayController.advanceTimeBy] or [TestCoroutineScope.advanceTimeBy], * [TestCoroutineScheduler.advanceTimeBy] doesn't run the tasks scheduled at the target moment. */ @Test - fun testAdvanceTimeByDoesNotRunCurrent() = runBlockingTest { + fun testAdvanceTimeByDoesNotRunCurrent() = runTest { var entered = false launch { delay(15) @@ -45,7 +44,7 @@ class TestCoroutineSchedulerTest { /** Tests that if [TestCoroutineScheduler.advanceTimeBy] encounters an arithmetic overflow, all the tasks scheduled * until the moment [Long.MAX_VALUE] get run. */ @Test - fun testAdvanceTimeByEnormousDelays() = runBlockingTest { + fun testAdvanceTimeByEnormousDelays() = runTest { val initialDelay = 10L delay(initialDelay) assertEquals(initialDelay, currentTime) @@ -99,7 +98,7 @@ class TestCoroutineSchedulerTest { /** Tests the basic functionality of [TestCoroutineScheduler.runCurrent]. */ @Test - fun testRunCurrent() = runBlockingTest { + fun testRunCurrent() = runTest { var stage = 0 launch { delay(1) @@ -182,4 +181,79 @@ class TestCoroutineSchedulerTest { stage = 1 scope.runCurrent() } + + private fun TestCoroutineScope.checkTimeout( + timesOut: Boolean, timeoutMillis: Long = SLOW, block: suspend () -> Unit + ) = assertRunsFast { + var caughtException = false + launch { + try { + withTimeout(timeoutMillis) { + block() + } + } catch (e: TimeoutCancellationException) { + caughtException = true + } + } + advanceUntilIdle() + cleanupTestCoroutines() + if (timesOut) + assertTrue(caughtException) + else + assertFalse(caughtException) + } + + /** Tests that timeouts get triggered. */ + @Test + fun testSmallTimeouts() { + val scope = TestCoroutineScope() + scope.checkTimeout(true) { + val half = SLOW / 2 + delay(half) + delay(SLOW - half) + } + } + + /** Tests that timeouts don't get triggered if the code finishes in time. */ + @Test + fun testLargeTimeouts() { + val scope = TestCoroutineScope() + scope.checkTimeout(false) { + val half = SLOW / 2 + delay(half) + delay(SLOW - half - 1) + } + } + + /** Tests that timeouts get triggered if the code fails to finish in time asynchronously. */ + @Test + fun testSmallAsynchronousTimeouts() { + val scope = TestCoroutineScope() + val deferred = CompletableDeferred() + scope.launch { + val half = SLOW / 2 + delay(half) + delay(SLOW - half) + deferred.complete(Unit) + } + scope.checkTimeout(true) { + deferred.await() + } + } + + /** Tests that timeouts don't get triggered if the code finishes in time, even if it does so asynchronously. */ + @Test + fun testLargeAsynchronousTimeouts() { + val scope = TestCoroutineScope() + val deferred = CompletableDeferred() + scope.launch { + val half = SLOW / 2 + delay(half) + delay(SLOW - half - 1) + deferred.complete(Unit) + } + scope.checkTimeout(false) { + deferred.await() + } + } } diff --git a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 77ee4f3a9f..3ec110ebcf 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -33,7 +33,7 @@ class TestDispatchersTest { } @Test - fun testImmediateDispatcher() = runBlockingTest { + fun testImmediateDispatcher() = runTest { Dispatchers.setMain(ImmediateDispatcher()) expect(1) withContext(Dispatchers.Main) { diff --git a/kotlinx-coroutines-test/jvm/test/Helpers.kt b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt similarity index 89% rename from kotlinx-coroutines-test/jvm/test/Helpers.kt rename to kotlinx-coroutines-test/jvm/test/HelpersJvm.kt index 017f2369bb..e9aa3ff747 100644 --- a/kotlinx-coroutines-test/jvm/test/Helpers.kt +++ b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt @@ -3,8 +3,6 @@ */ package kotlinx.coroutines.test -import kotlinx.coroutines.* - actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { block { test() diff --git a/kotlinx-coroutines-test/native/test/Helpers.kt b/kotlinx-coroutines-test/native/test/Helpers.kt index 017f2369bb..e9aa3ff747 100644 --- a/kotlinx-coroutines-test/native/test/Helpers.kt +++ b/kotlinx-coroutines-test/native/test/Helpers.kt @@ -3,8 +3,6 @@ */ package kotlinx.coroutines.test -import kotlinx.coroutines.* - actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { block { test() From 123b5501688f170b7e183f5135c16cfaa137eb08 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 27 Oct 2021 10:49:28 +0300 Subject: [PATCH 13/19] Don't mention UncompletedCoroutinesError in the docs --- kotlinx-coroutines-test/common/src/TestBuilders.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 75e5244e2e..c60a5155f5 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -142,13 +142,13 @@ public expect class TestResult * ### Failures * * This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test - * will be failed (which, on JVM and Native, means that [runTest] itself will throw [UncompletedCoroutinesError], + * will be failed (which, on JVM and Native, means that [runTest] itself will throw [AssertionError], * whereas on JS, the `Promise` will fail with it). * * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait * for [dispatchTimeoutMs] milliseconds (by default, 10 seconds) from the moment when [TestCoroutineScheduler] becomes - * idle before throwing [UncompletedCoroutinesError]. If some dispatcher linked to [TestCoroutineScheduler] receives a + * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a * task during that time, the timer gets reset. * * Unhandled exceptions thrown by coroutines in the test will be rethrown at the end of the test. From 063262b86e31ab571638263ce98e208c009a4260 Mon Sep 17 00:00:00 2001 From: dkhalanskyjb <52952525+dkhalanskyjb@users.noreply.github.com> Date: Wed, 27 Oct 2021 11:46:47 +0300 Subject: [PATCH 14/19] Better handle the exceptions from child coroutines in `runTest` (#2995) Fixes #1910 --- .../common/src/TestBuilders.kt | 57 +++++++++++++---- .../common/src/TestCoroutineScheduler.kt | 26 +++++--- .../common/src/TestCoroutineScope.kt | 2 +- .../common/test/Helpers.kt | 4 +- .../common/test/RunTestTest.kt | 64 +++++++++++++++++++ .../common/test/TestCoroutineScopeTest.kt | 41 ++++++++++-- .../common/test/TestRunBlockingTest.kt | 4 +- 7 files changed, 167 insertions(+), 31 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index c60a5155f5..1867999830 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -44,7 +44,7 @@ import kotlin.coroutines.* */ @Deprecated("Use `runTest` instead to support completing from other dispatchers.", level = DeprecationLevel.WARNING) public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) { - val scope = TestCoroutineScope(context) + val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) val scheduler = scope.testScheduler val deferred = scope.async { scope.testBody() @@ -141,6 +141,18 @@ public expect class TestResult * * ### Failures * + * #### Test body failures + * + * If the test body finishes with an exception, then this exception will be thrown at the end of the test. + * + * #### Reported exceptions + * + * Exceptions reported to the test coroutine scope via [TestCoroutineScope.reportException] will be thrown at the end. + * By default, unless an explicit [TestExceptionHandler] is passed, this includes all unhandled exceptions. If the test + * body also fails, the reported exceptions are suppressed by it. + * + * #### Uncompleted coroutines + * * This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test * will be failed (which, on JVM and Native, means that [runTest] itself will throw [AssertionError], * whereas on JS, the `Promise` will fail with it). @@ -151,8 +163,6 @@ public expect class TestResult * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a * task during that time, the timer gets reset. * - * Unhandled exceptions thrown by coroutines in the test will be rethrown at the end of the test. - * * ### Configuration * * [context] can be used to affect the environment of the code under test. Beside just being passed to the coroutine @@ -170,16 +180,18 @@ 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 = TestCoroutineScope(context + RunningInRunTest()) + val testScope = TestBodyCoroutine(TestCoroutineScope(context + RunningInRunTest)) val scheduler = testScope.testScheduler return createTestResult { - val deferred = testScope.async { - testScope.testBody() + /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with + * [TestCoroutineDispatcher], because the event loop is not started. */ + testScope.start(CoroutineStart.DEFAULT, testScope) { + testBody() } var completed = false while (!completed) { scheduler.advanceUntilIdle() - if (deferred.isCompleted) { + 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 @@ -188,7 +200,7 @@ public fun runTest( try { withTimeout(dispatchTimeoutMs) { select { - deferred.onAwait { + testScope.onJoin { completed = true } scheduler.onDispatchEvent { @@ -205,7 +217,14 @@ public fun runTest( throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") } } - deferred.getCompletionExceptionOrNull()?.let { + testScope.getCompletionExceptionOrNull()?.let { + try { + testScope.cleanupTestCoroutines() + } catch (e: UncompletedCoroutinesError) { + // it's normal that some jobs are not completed if the test body has failed, won't clutter the output + } catch (e: Throwable) { + it.addSuppressed(e) + } throw it } testScope.cleanupTestCoroutines() @@ -222,7 +241,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes * Runs a test in a [TestCoroutineScope] based on this one. * * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run - * [block] will be different from this one, but will reuse its [Job]; therefore, even if calling + * [block] will be different from this one, but will use its [Job] as a parent; therefore, even if calling * [TestCoroutineScope.cleanupTestCoroutines] on this scope were to complete its job, [runTest] won't complete it at the * end of the test. * @@ -252,10 +271,24 @@ public fun TestDispatcher.runTest( runTest(this, dispatchTimeoutMs, block) /** A coroutine context element indicating that the coroutine is running inside `runTest`. */ -private class RunningInRunTest: AbstractCoroutineContextElement(RunningInRunTest), CoroutineContext.Element { - companion object Key : CoroutineContext.Key +private object RunningInRunTest: CoroutineContext.Key, CoroutineContext.Element { + override val key: CoroutineContext.Key<*> + get() = this + + override fun toString(): String = "RunningInRunTest" } /** The default timeout to use when waiting for asynchronous completions of the coroutines managed by * a [TestCoroutineScheduler]. */ private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L + +private class TestBodyCoroutine( + private val testScope: TestCoroutineScope, +) : AbstractCoroutine(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope, + UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor +{ + override val testScheduler get() = testScope.testScheduler + + override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines() + +} diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt index 00c6975cc8..2acd8e527f 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt @@ -81,6 +81,21 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout } } + /** + * Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening. + */ + private fun tryRunNextTask(): Boolean { + val event = synchronized(lock) { + val event = events.removeFirstOrNull() ?: return false + if (currentTime > event.time) + currentTimeAheadOfEvents() + currentTime = event.time + event + } + event.dispatcher.processEvent(event.time, event.marker) + return true + } + /** * Runs the enqueued tasks in the specified order, advancing the virtual time as needed until there are no more * tasks associated with the dispatchers linked to this scheduler. @@ -91,15 +106,8 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout */ @ExperimentalCoroutinesApi public fun advanceUntilIdle() { - while (!events.isEmpty) { - val event = synchronized(lock) { - val event = events.removeFirstOrNull() ?: return - if (currentTime > event.time) - currentTimeAheadOfEvents() - currentTime = event.time - event - } - event.dispatcher.processEvent(event.time, event.marker) + while (!synchronized(lock) { events.isEmpty }) { + tryRunNextTask() } } diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index c2c67b1c56..008959b390 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -107,7 +107,7 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job) } -private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor +internal inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor get() { val handler = this[CoroutineExceptionHandler] return handler as? UncaughtExceptionCaptor ?: throw IllegalArgumentException( diff --git a/kotlinx-coroutines-test/common/test/Helpers.kt b/kotlinx-coroutines-test/common/test/Helpers.kt index 453472ad81..8be3ea106a 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -32,4 +32,6 @@ inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.secon /** * Passes [test] as an argument to [block], but as a function returning not a [TestResult] but [Unit]. */ -expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult \ No newline at end of file +expect fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult + +class TestException(message: String? = null): Exception(message) diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index 68c9ab9eb5..e086fea9ea 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -5,6 +5,7 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* import kotlin.coroutines.* import kotlin.test.* @@ -153,4 +154,67 @@ class RunTestTest { } } + @Test + fun reproducer2405() = runTest { + val dispatcher = TestCoroutineDispatcher(testScheduler) + var collectedError = false + withContext(dispatcher) { + flow { emit(1) } + .combine( + flow { throw IllegalArgumentException() } + ) { int, string -> int.toString() + string } + .catch { emit("error") } + .collect { + assertEquals("error", it) + collectedError = true + } + } + assertTrue(collectedError) + } + + /** Tests that, once the test body has thrown, the child coroutines are cancelled. */ + @Test + fun testChildrenCancellationOnTestBodyFailure() { + var job: Job? = null + testResultMap({ + assertFailsWith { it() } + assertTrue(job!!.isCancelled) + }, { + runTest { + job = launch { + while (true) { + delay(1000) + } + } + throw AssertionError() + } + }) + } + + /** Tests that [runTest] reports [TimeoutCancellationException]. */ + @Test + fun testTimeout() = testResultMap({ + assertFailsWith { it() } + }, { + runTest { + withTimeout(50) { + launch { + delay(1000) + } + } + } + }) + + /** Checks that [runTest] throws the root cause and not [JobCancellationException] when a child coroutine throws. */ + @Test + fun testRunTestThrowsRootCause() = testResultMap({ + assertFailsWith { it() } + }, { + runTest { + launch { + throw TestException() + } + } + }) + } diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt index 85e44351ee..b81eddbb5b 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -95,9 +95,40 @@ class TestCoroutineScopeTest { assertFalse(result) } - private val invalidContexts = listOf( - Dispatchers.Default, // not a [TestDispatcher] - TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler - CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` - ) + /** Tests that uncaught exceptions are thrown at the cleanup. */ + @Test + fun testThrowsUncaughtExceptionsOnCleanup() { + val scope = TestCoroutineScope() + val exception = TestException("test") + scope.launch { + throw exception + } + assertFailsWith { + scope.cleanupTestCoroutines() + } + } + + /** Tests that uncaught exceptions take priority over uncompleted jobs when throwing on cleanup. */ + @Test + fun testUncaughtExceptionsPrioritizedOnCleanup() { + val scope = TestCoroutineScope() + val exception = TestException("test") + scope.launch { + throw exception + } + scope.launch { + delay(1000) + } + assertFailsWith { + scope.cleanupTestCoroutines() + } + } + + companion object { + internal val invalidContexts = listOf( + Dispatchers.Default, // not a [TestDispatcher] + TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler + CoroutineExceptionHandler { _, _ -> }, // not an `UncaughtExceptionCaptor` + ) + } } diff --git a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt index 041f58a3c5..139229e610 100644 --- a/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt +++ b/kotlinx-coroutines-test/common/test/TestRunBlockingTest.kt @@ -435,6 +435,4 @@ class TestRunBlockingTest { } } } -} - -private class TestException(message: String? = null): Exception(message) \ No newline at end of file +} \ No newline at end of file From 5bbf226bc012e3e2f9d35c3278a7a487f2554f79 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 28 Oct 2021 12:35:12 +0300 Subject: [PATCH 15/19] Fixes --- .../common/src/TestBuilders.kt | 41 +++++++++---------- .../common/test/RunTestTest.kt | 6 +-- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 1867999830..ecdd8fb80f 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -87,7 +87,7 @@ public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineS * * Don't nest functions returning a [TestResult]. */ @Suppress("NO_ACTUAL_FOR_EXPECT") -@DelicateCoroutinesApi +@ExperimentalCoroutinesApi public expect class TestResult /** @@ -159,7 +159,7 @@ public expect class TestResult * * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait - * for [dispatchTimeoutMs] milliseconds (by default, 10 seconds) from the moment when [TestCoroutineScheduler] becomes + * for [dispatchTimeoutMs] milliseconds (by default, 60 seconds) from the moment when [TestCoroutineScheduler] becomes * idle before throwing [AssertionError]. If some dispatcher linked to [TestCoroutineScheduler] receives a * task during that time, the timer gets reset. * @@ -172,7 +172,7 @@ public expect class TestResult * @throws IllegalArgumentException if the [context] is invalid. See the [TestCoroutineScope] constructor docs for * details. */ -@DelicateCoroutinesApi +@ExperimentalCoroutinesApi public fun runTest( context: CoroutineContext = EmptyCoroutineContext, dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, @@ -197,24 +197,21 @@ public fun runTest( completed = true continue } - try { - withTimeout(dispatchTimeoutMs) { - select { - testScope.onJoin { - completed = true - } - scheduler.onDispatchEvent { - // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout - } - } + select { + testScope.onJoin { + completed = true } - } catch (e: TimeoutCancellationException) { - try { - testScope.cleanupTestCoroutines() - } catch (e: UncompletedCoroutinesError) { - // we expect these and will instead throw a more informative exception just below. + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } + onTimeout(dispatchTimeoutMs) { + try { + testScope.cleanupTestCoroutines() + } catch (e: UncompletedCoroutinesError) { + // we expect these and will instead throw a more informative exception just below. + } + throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") } - throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms") } } testScope.getCompletionExceptionOrNull()?.let { @@ -248,7 +245,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes * 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. */ -@DelicateCoroutinesApi +@ExperimentalCoroutinesApi public fun TestCoroutineScope.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, block: suspend TestCoroutineScope.() -> Unit @@ -263,7 +260,7 @@ public fun TestCoroutineScope.runTest( * 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. */ -@DelicateCoroutinesApi +@ExperimentalCoroutinesApi public fun TestDispatcher.runTest( dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, block: suspend TestCoroutineScope.() -> Unit @@ -280,7 +277,7 @@ private object RunningInRunTest: CoroutineContext.Key, Corouti /** The default timeout to use when waiting for asynchronous completions of the coroutines managed by * a [TestCoroutineScheduler]. */ -private const val DEFAULT_DISPATCH_TIMEOUT_MS = 10_000L +private const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L private class TestBodyCoroutine( private val testScope: TestCoroutineScope, diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index e086fea9ea..d64e7c327e 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -78,7 +78,7 @@ class RunTestTest { delay(10) 3 } - throw IllegalStateException("shouldn't be reached") + fail("shouldn't be reached") } } @@ -93,7 +93,7 @@ class RunTestTest { delay(10000) 3 } - throw RuntimeException("shouldn't be reached") + fail("shouldn't be reached") } } @@ -117,7 +117,7 @@ class RunTestTest { delay(10000) 3 } - throw RuntimeException("shouldn't be reached") + fail("shouldn't be reached") } } From a8e43d6f5069f798d9236db96a347a369aa206ae Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 28 Oct 2021 14:41:18 +0300 Subject: [PATCH 16/19] Add lost tests --- .../common/test/RunTestTest.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt index d64e7c327e..fbca2b05ac 100644 --- a/kotlinx-coroutines-test/common/test/RunTestTest.kt +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -217,4 +217,38 @@ class RunTestTest { } }) + /** Tests that [runTest] completes its job. */ + @Test + fun testCompletesOwnJob(): TestResult { + var handlerCalled = false + return testResultMap({ + it() + assertTrue(handlerCalled) + }, { + runTest { + coroutineContext.job.invokeOnCompletion { + handlerCalled = true + } + } + }) + } + + /** Tests that [runTest] doesn't complete the job that was passed to it as an argument. */ + @Test + fun testDoesNotCompleteGivenJob(): TestResult { + var handlerCalled = false + val job = Job() + job.invokeOnCompletion { + handlerCalled = true + } + return testResultMap({ + it() + assertFalse(handlerCalled) + assertEquals(0, job.children.filter { it.isActive }.count()) + }, { + runTest(job) { + assertTrue(coroutineContext.job in job.children) + } + }) + } } From 52ec7a3d7f0382baf0d7ebf9cdc0562160cb3cff Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Thu, 28 Oct 2021 15:12:22 +0300 Subject: [PATCH 17/19] Fix an incorrect rebase artifact --- kotlinx-coroutines-test/common/src/TestCoroutineScope.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index 008959b390..17978df229 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -103,7 +103,7 @@ public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext) } this ?: TestCoroutineExceptionHandler() } - val job: Job = context[Job] ?: SupervisorJob() + val job: Job = context[Job] ?: Job() return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job) } From 617f1f3f951fabde4a78548cfdf59591b236ec76 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 1 Nov 2021 13:29:08 +0300 Subject: [PATCH 18/19] Update docs --- .../common/src/TestBuilders.kt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index ecdd8fb80f..9127f068dc 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -91,7 +91,7 @@ public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineS public expect class TestResult /** - * Executes [testBody] as a test, returning [TestResult]. + * Executes [testBody] as a test in a new coroutine, returning [TestResult]. * * On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs * will skip delays. This allows to use [delay] in without causing the tests to take more time than necessary. @@ -143,19 +143,20 @@ public expect class TestResult * * #### Test body failures * - * If the test body finishes with an exception, then this exception will be thrown at the end of the test. + * If the created coroutine completes with an exception, then this exception will be thrown at the end of the test. * * #### Reported exceptions * - * Exceptions reported to the test coroutine scope via [TestCoroutineScope.reportException] will be thrown at the end. - * By default, unless an explicit [TestExceptionHandler] is passed, this includes all unhandled exceptions. If the test - * body also fails, the reported exceptions are suppressed by it. + * Unhandled exceptions will be thrown at the end of the test. + * If the uncaught exceptions happen after the test finishes, the error is propagated in a platform-specific manner. + * If the test coroutine completes with an exception, the unhandled exceptions are suppressed by it. * * #### Uncompleted coroutines * - * This method requires that all coroutines launched inside [testBody] complete, or are cancelled. Otherwise, the test - * will be failed (which, on JVM and Native, means that [runTest] itself will throw [AssertionError], - * whereas on JS, the `Promise` will fail with it). + * This method requires that, after the test coroutine has completed, all the other coroutines launched inside + * [testBody] also complete, or are cancelled. + * Otherwise, the test will be failed (which, on JVM and Native, means that [runTest] itself will throw + * [AssertionError], whereas on JS, the `Promise` will fail with it). * * In the general case, if there are active jobs, it's impossible to detect if they are going to complete eventually due * to the asynchronous nature of coroutines. In order to prevent tests hanging in this scenario, [runTest] will wait @@ -238,9 +239,7 @@ internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestRes * Runs a test in a [TestCoroutineScope] based on this one. * * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run - * [block] will be different from this one, but will use its [Job] as a parent; therefore, even if calling - * [TestCoroutineScope.cleanupTestCoroutines] on this scope were to complete its job, [runTest] won't complete it at the - * end of the test. + * [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. From ed328a29677d2819e78face7bc075105dbff454b Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Mon, 1 Nov 2021 16:24:22 +0300 Subject: [PATCH 19/19] Mention waiting for child coroutines in docs --- .../common/src/TestBuilders.kt | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 9127f068dc..941f864a3e 100644 --- a/kotlinx-coroutines-test/common/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/common/src/TestBuilders.kt @@ -115,7 +115,36 @@ public expect class TestResult * immediately return the produced [TestResult] from the test method, without doing anything else afterwards. See * [TestResult] for details on this. * - * ### Delay-skipping + * The test is run in a single thread, unless other [ContinuationInterceptor] are used for child coroutines. + * Because of this, child coroutines are not executed in parallel to the test body. + * In order to for the spawned-off asynchronous code to actually be executed, one must either [yield] or suspend the + * test body some other way, or use commands that control scheduling (see [TestCoroutineScheduler]). + * + * ``` + * @Test + * fun exampleWaitingForAsyncTasks1() = runTest { + * // 1 + * val job = launch { + * // 3 + * } + * // 2 + * job.join() // the main test coroutine suspends here, so the child is executed + * // 4 + * } + * + * @Test + * fun exampleWaitingForAsyncTasks2() = runTest { + * // 1 + * launch { + * // 3 + * } + * // 2 + * advanceUntilIdle() // runs the tasks until their queue is empty + * // 4 + * } + * ``` + * + * ### Task scheduling * * Delay-skipping is achieved by using virtual time. [TestCoroutineScheduler] is automatically created (if it wasn't * passed in some way in [context]) and can be used to control the virtual time, advancing it, running the tasks @@ -130,7 +159,7 @@ public expect class TestResult * val deferred = async { * delay(1_000) // will be skipped * withContext(Dispatchers.Default) { - * delay(5_000) // Dispatchers.Default don't know about TestCoroutineScheduler + * delay(5_000) // Dispatchers.Default doesn't know about TestCoroutineScheduler * } * } * deferred.await()