diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api index 0aebee5275..f60326b0eb 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.api @@ -14,6 +14,12 @@ 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 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 bf864333b4..941f864a3e 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.* /** @@ -41,10 +42,10 @@ 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) - val scheduler = scope.coroutineContext[TestCoroutineScheduler]!! + val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) + val scheduler = scope.testScheduler val deferred = scope.async { scope.testBody() } @@ -59,13 +60,260 @@ 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) + +/** + * 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") +@ExperimentalCoroutinesApi +public expect class TestResult + +/** + * Executes [testBody] as a test in a new coroutine, returning [TestResult]. + * + * On JVM and Native, this function behaves similarly to [runBlocking], with the difference that the code that it runs + * 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. + * + * 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 + * 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 doesn't know about TestCoroutineScheduler + * } + * } + * deferred.await() + * } + * println(elapsed) // about five seconds + * } + * ``` + * + * ### Failures + * + * #### Test body failures + * + * If the created coroutine completes with an exception, then this exception will be thrown at the end of the test. + * + * #### Reported exceptions + * + * 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, 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 + * 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. + * + * ### 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. + */ +@ExperimentalCoroutinesApi +public fun runTest( + context: CoroutineContext = EmptyCoroutineContext, + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + testBody: suspend TestCoroutineScope.() -> Unit +): TestResult { + if (context[RunningInRunTest] != null) + throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.") + val testScope = TestBodyCoroutine(TestCoroutineScope(context + RunningInRunTest)) + val scheduler = testScope.testScheduler + return createTestResult { + /** TODO: moving this [AbstractCoroutine.start] call outside [createTestResult] fails on Native with + * [TestCoroutineDispatcher], because the event loop is not started. */ + testScope.start(CoroutineStart.DEFAULT, testScope) { + testBody() + } + var completed = false + while (!completed) { + scheduler.advanceUntilIdle() + if (testScope.isCompleted) { + /* don't even enter `withTimeout`; this allows to use a timeout of zero to check that there are no + non-trivial dispatches. */ + completed = true + continue + } + select { + testScope.onJoin { + completed = true + } + scheduler.onDispatchEvent { + // we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout + } + onTimeout(dispatchTimeoutMs) { + try { + testScope.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") + } + } + } + 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() + } +} + +/** + * Runs [testProcedure], creating a [TestResult]. + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult` +internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult + +/** + * Runs a test in a [TestCoroutineScope] based on this one. + * + * Calls [runTest] using a coroutine context from this [TestCoroutineScope]. The [TestCoroutineScope] used to run + * [block] will be different from this one, but will use its [Job] as a parent. + * + * Since this function returns [TestResult], in order to work correctly on the JS, its result must be returned + * immediately from the test body. See the docs for [TestResult] for details. + */ +@ExperimentalCoroutinesApi +public fun TestCoroutineScope.runTest( + dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS, + block: suspend TestCoroutineScope.() -> Unit +): TestResult = + runTest(coroutineContext, dispatchTimeoutMs, block) + +/** + * 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. + */ +@ExperimentalCoroutinesApi +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 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 = 60_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/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..2acd8e527f 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. @@ -64,6 +70,9 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout 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) @@ -72,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. @@ -82,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() } } @@ -162,6 +179,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/src/TestCoroutineScope.kt b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt index c37f834356..17978df229 100644 --- a/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt +++ b/kotlinx-coroutines-test/common/src/TestCoroutineScope.kt @@ -16,6 +16,7 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap * 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. @@ -102,11 +103,11 @@ 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) } -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 f0a462e4b6..5c1796b148 100644 --- a/kotlinx-coroutines-test/common/test/Helpers.kt +++ b/kotlinx-coroutines-test/common/test/Helpers.kt @@ -4,5 +4,37 @@ package kotlinx.coroutines.test +import kotlin.test.* +import kotlin.time.* + +/** + * The number of milliseconds that is sure not to pass [assertRunsFast]. + */ +const val SLOW = 100_000L + +/** + * Asserts that a block completed within [timeout]. + */ +@OptIn(ExperimentalTime::class) +inline fun assertRunsFast(timeout: Duration, block: () -> T): T { + val result: T + val elapsed = TimeSource.Monotonic.measureTime { result = block() } + assertTrue("Should complete in $timeout, but took $elapsed") { elapsed < timeout } + return result +} + +/** + * Asserts that a block completed within two seconds. + */ +@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 + +class TestException(message: String? = null): Exception(message) + @OptionalExpectation expect annotation class NoNative() diff --git a/kotlinx-coroutines-test/common/test/RunTestTest.kt b/kotlinx-coroutines-test/common/test/RunTestTest.kt new file mode 100644 index 0000000000..fbca2b05ac --- /dev/null +++ b/kotlinx-coroutines-test/common/test/RunTestTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.coroutines.* +import kotlin.test.* + +class RunTestTest { + + /** Tests that [withContext] that sends work to other threads works in [runTest]. */ + @Test + fun testWithContextDispatching() = runTest { + var counter = 0 + withContext(Dispatchers.Default) { + counter += 1 + } + assertEquals(counter, 1) + } + + /** Tests that joining [GlobalScope.launch] works in [runTest]. */ + @Test + fun testJoiningForkedJob() = runTest { + var counter = 0 + val job = GlobalScope.launch { + counter += 1 + } + job.join() + assertEquals(counter, 1) + } + + /** Tests [suspendCoroutine] not failing [runTest]. */ + @Test + 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 { + runTest { } + } + } + + /** 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 + } + fail("shouldn't be reached") + } + } + + /** 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() } + }) { + runTest(dispatchTimeoutMs = 100) { + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("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 + @Ignore // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native + fun testRunTestTimingOutAndThrowing() = testResultMap({ fn -> + assertFailsWith { fn() } + }) { + runTest(dispatchTimeoutMs = 1) { + coroutineContext[CoroutineExceptionHandler]!!.handleException(coroutineContext, IllegalArgumentException()) + withContext(Dispatchers.Default) { + delay(10000) + 3 + } + fail("shouldn't be reached") + } + } + + /** 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() + } + } + } + + @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() + } + } + }) + + /** 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) + } + }) + } +} diff --git a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt index f7d2ed13fd..45c02c09ad 100644 --- a/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt +++ b/kotlinx-coroutines-test/common/test/TestCoroutineSchedulerTest.kt @@ -11,7 +11,7 @@ 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 +21,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 +45,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 +99,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 +182,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/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/TestDispatchersTest.kt b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt index 4ffbfc2c37..441ea0418e 100644 --- a/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt +++ b/kotlinx-coroutines-test/common/test/TestDispatchersTest.kt @@ -34,8 +34,7 @@ class TestDispatchersTest { } @Test - @NoNative - fun testImmediateDispatcher() = runBlockingTest { + fun testImmediateDispatcher() = runTest { Dispatchers.setMain(ImmediateDispatcher()) expect(1) withContext(Dispatchers.Main) { diff --git a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt b/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt deleted file mode 100644 index f3b8e79407..0000000000 --- a/kotlinx-coroutines-test/common/test/TestModuleHelpers.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2016-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. - */ - -package kotlinx.coroutines.test - -import kotlin.test.* -import kotlin.time.* - -/** - * The number of milliseconds that is sure not to pass [assertRunsFast]. - */ -const val SLOW = 100_000L - -/** - * Asserts that a block completed within [timeout]. - */ -@OptIn(ExperimentalTime::class) -inline fun assertRunsFast(timeout: Duration, block: () -> T): T { - val result: T - val elapsed = TimeSource.Monotonic.measureTime { result = block() } - assertTrue("Should complete in $timeout, but took $elapsed") { elapsed < timeout } - return result -} - -/** - * Asserts that a block completed within two seconds. - */ -@OptIn(ExperimentalTime::class) -inline fun assertRunsFast(block: () -> T): T = assertRunsFast(Duration.seconds(2), block) 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 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/Helpers.kt b/kotlinx-coroutines-test/js/test/Helpers.kt new file mode 100644 index 0000000000..b0a767c5df --- /dev/null +++ b/kotlinx-coroutines-test/js/test/Helpers.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.test + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult): TestResult = + test().then( + { + block { + } + }, { + block { + throw it + } + }) 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/jvm/test/HelpersJvm.kt b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt new file mode 100644 index 0000000000..e9aa3ff747 --- /dev/null +++ b/kotlinx-coroutines-test/jvm/test/HelpersJvm.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ +package kotlinx.coroutines.test + +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { + block { + test() + } +} diff --git a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt index 7dc99406a1..6b0c071a56 100644 --- a/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt +++ b/kotlinx-coroutines-test/jvm/test/MultithreadingTest.kt @@ -4,10 +4,11 @@ import kotlinx.coroutines.* import kotlinx.coroutines.test.* +import kotlin.concurrent.* import kotlin.coroutines.* import kotlin.test.* -class MultithreadingTest : TestBase() { +class MultithreadingTest { @Test fun incorrectlyCalledRunBlocking_doesNotHaveSameInterceptor() = runBlockingTest { @@ -22,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 -> @@ -86,4 +87,15 @@ class MultithreadingTest : TestBase() { assertEquals(3, deferred.await()) } } -} \ No newline at end of file + + /** Tests that resuming the coroutine of [runTest] asynchronously in reasonable time succeeds. */ + @Test + fun testResumingFromAnotherThread() = runTest { + suspendCancellableCoroutine { cont -> + thread { + Thread.sleep(10) + cont.resume(Unit) + } + } + } +} 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 diff --git a/kotlinx-coroutines-test/native/test/Helpers.kt b/kotlinx-coroutines-test/native/test/Helpers.kt index 28cc28ca00..ef478b7eb1 100644 --- a/kotlinx-coroutines-test/native/test/Helpers.kt +++ b/kotlinx-coroutines-test/native/test/Helpers.kt @@ -5,4 +5,10 @@ package kotlinx.coroutines.test import kotlin.test.* +actual fun testResultMap(block: (() -> Unit) -> Unit, test: () -> TestResult) { + block { + test() + } +} + actual typealias NoNative = Ignore