New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement 'runTest' that waits for asynchronous completion #2978
Changes from 17 commits
6c52412
b43b87d
2cddb28
1e9be83
a32533c
4080916
b6d55c9
b13fb61
862f834
2e7a039
3e0d885
c626470
123b550
063262b
5bbf226
a8e43d6
52ec7a3
ff409f4
617f1f3
0213090
ed328a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ | |
package kotlinx.coroutines.test | ||
|
||
import kotlinx.coroutines.* | ||
import kotlinx.coroutines.selects.* | ||
import kotlin.coroutines.* | ||
|
||
/** | ||
|
@@ -41,70 +42,250 @@ 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 (safeContext, dispatcher) = context.checkTestScopeArguments() | ||
val startingJobs = safeContext.activeJobs() | ||
val scope = TestCoroutineScope(safeContext) | ||
val scope = TestCoroutineScope(TestCoroutineDispatcher() + SupervisorJob() + context) | ||
val scheduler = scope.testScheduler | ||
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<Job> { | ||
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet() | ||
} | ||
|
||
/** | ||
* 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) | ||
|
||
internal fun CoroutineContext.checkTestScopeArguments(): Pair<CoroutineContext, TestDispatcher> { | ||
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." | ||
/** | ||
* 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, 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 | ||
* | ||
* #### Test body failures | ||
* | ||
* If the test body finishes with an exception, then this exception will be thrown at the end of the test. | ||
qwwdfsad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* | ||
* #### Reported exceptions | ||
* | ||
* Exceptions reported to the test coroutine scope via [TestCoroutineScope.reportException] will be thrown at the end. | ||
qwwdfsad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* 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 | ||
qwwdfsad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* 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. | ||
dkhalanskyjb marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
@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<Unit>(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<Unit> { | ||
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") | ||
} | ||
scheduler = ctxScheduler | ||
} | ||
dispatcher | ||
} | ||
null -> { | ||
scheduler = get(TestCoroutineScheduler) ?: TestCoroutineScheduler() | ||
TestCoroutineDispatcher(scheduler) | ||
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 | ||
} | ||
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher") | ||
testScope.cleanupTestCoroutines() | ||
qwwdfsad marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
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) | ||
} | ||
|
||
/** | ||
* 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; 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. | ||
*/ | ||
@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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we still need this one if #3006 gets merged? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It has its uses. People also create dispatchers in advance to perform DI. In any case, this method looks fairly benign and doesn't hurt anything, so a more important question is whether we need There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's make this decision in the final branch along with the migration guide. I still believe
Maybe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I should note that the scope inside Also, this code is missing a @AfterTest
fun after() {
myScope.cleanupTestCoroutines()
} I think this is a major point. According to the comments at the bottom of https://youtrack.jetbrains.com/issue/KT-19813,
|
||
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<RunningInRunTest>, 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<T>( | ||
private val testScope: TestCoroutineScope, | ||
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope, | ||
UncaughtExceptionCaptor by testScope.coroutineContext.uncaughtExceptionCaptor | ||
{ | ||
override val testScheduler get() = testScope.testScheduler | ||
|
||
override fun cleanupTestCoroutines() = testScope.cleanupTestCoroutines() | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mentioning the scope/dispatcher and the ability to wait until all tasks are executed is valuable for this API and worth mentioning:
I do not insist though, but it seems pretty important, WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, added a mention of this.