diff --git a/kotlinx-coroutines-test/common/src/TestBuilders.kt b/kotlinx-coroutines-test/common/src/TestBuilders.kt index 2063e465e5..5f523191f7 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 // Since 1.2.1, tentatively till 1.3.0 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 // Since 1.2.1, tentatively till 1.3.0 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 = 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 58746cac61..90b4facef2 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 // Since 1.2.1, tentatively till 1.3.0 public 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 UncompletedCoroutinesError if any pending tasks are active, however it will not throw for suspended - * coroutines. + * @throws UncompletedCoroutinesError if any pending tasks are active. */ public fun cleanupTestCoroutines() @@ -33,29 +34,92 @@ public interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor { 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 // Since 1.2.1, tentatively till 1.3.0 -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 = 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 0862c1ff93..ce32d0c675 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 4480cd99a3..e86b408e30 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt @@ -5,9 +5,11 @@ package kotlinx.coroutines.test import kotlinx.coroutines.* +import kotlin.coroutines.* import kotlin.test.* class TestCoroutineScopeTest { + @Test fun whenGivenInvalidExceptionHandler_throwsException() { val handler = CoroutineExceptionHandler { _, _ -> } @@ -22,4 +24,74 @@ class TestCoroutineScopeTest { TestCoroutineScope(Dispatchers.Default) } } + + /** 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) + } }