-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Update TestCoroutineContext to support structured concurrency. #890
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
Conversation
acf4830
to
23d228b
Compare
* ``` | ||
* | ||
* [runBlockingTest] will allow tests to finish successfully while started coroutines are unfinished. In addition unhandled | ||
* exceptions inside coroutines will not fail the test. |
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.
Why allow unhandled exceptions? Seems like this could mask actual failures or errors, especially since I think tests should automatically catch issues where the programmer used GlobalScope.launch
without remembering that exceptions thrown from such coroutines will be reported as uncaught.
This could also be configurable as well, although I would expect the default to not drop exceptions.
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.
Good idea - it would probably make sense to make this default to true if implemented.
In this implementation, GlobalScope.launch
exceptions won't be captured - only exceptions thrown in the scope passed.
I couldn't figure out how to make this error on exceptions since the CoroutineExceptionHandler
didn't get triggered in runBlocking
.
@elizarov do you have any thoughts about how to accomplish either?
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.
Derp, of course GlobalScope.launch
wouldn't get caught. Test scope launch
with a SupervisorJob
is what I meant, I think.
* | ||
* @return the amount of delay-time that this Dispatcher's clock has been forwarded. | ||
*/ | ||
fun runUntilIdle(): Long |
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.
It's not obvious to me if there's a semantic difference between the run*
and advance*
prefixes in these method names – is there? E.g. advanceTimeToNextDelayed
seems like it could also be called runToNextDelayed
.
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.
Good point. I was thinking about making all the clock moving functions advance (which would rename this advanceUntilIdle
to be consistent).
Alternatively, everything could become run, which would make the following
runUntilNextDelayed()
runUntilDelay(delayTime, unit)
I don't think runUntilDelay
is a great name. I'll change this to advanceUntilIdle
and leave this comment up for more input.
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.
If the prefixes don't have distinct meanings, I don't have strong opinions on the color of the bikeshed as long as it's consistent. For prior art, RxJava's TestScheduler
uses advance
but also only has 2 such methods.
core/kotlinx-coroutines-core/src/test_/TestCoroutineExceptionHandler.kt
Outdated
Show resolved
Hide resolved
core/kotlinx-coroutines-core/src/test_/TestCoroutineExceptionHandler.kt
Outdated
Show resolved
Hide resolved
* | ||
* @param context an optional context that must provide delegates [ExceptionCaptor] and [DelayController] | ||
*/ | ||
class TestCoroutineScope( |
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.
Since it's a recommended practice to have classes implement CoroutineScope
to provide convenient access to coroutine builders, it would be nice if the same pattern could be used for tests. E.g.
class MyTest : TestCoroutineScope {
override val context = TestCoroutineDispatcher() + SomeCustomElement()
@Test fun thingsWork() = asyncTest {
…
}
}
The way this API is designed, it requires custom scopes to always be expressed as explicit properties. Is this required verbosity intentional?
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.
I'm not a huge fan of that style in tests, since TestCoroutineDispatcher
is stateful and it wouldn't take much to leak state between tests if coded like that.
However, if you preferred to code it that way, it would just require a this to ensure you're dispatching to the builder you intend, as well as ensure that dispatcher.cleanupTestCoroutines gets called @after.
@Test fun things() = this.asyncTest { }
From a real-world usage perspective, using something like a JUnit4 rule would probably be the way to go here, which would already move out of using inheritance like this.
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.
Agreed a rule is probably better. Figured this was intended to prevent that pattern, but since that's a pretty common pattern in the official docs it might be helpful to explicitly call it out in the docs (e.g. "Implementing the scope interface is not recommended, for {reasons}, instead use this pattern.").
core/kotlinx-coroutines-core/src/test_/TestCoroutineDispatcher.kt
Outdated
Show resolved
Hide resolved
Thanks for review @zach-klippenstein! |
I've did a first pass through the codes, docs, and example usage. All in all, I very much like this approach to testing. The issues that need to be addressed (in particular order) are:
|
Thanks for the review - I'm going to get it migrated to the new module then take a look at the other points once it's back up to date with master. |
17d329f
to
86d3488
Compare
Ahoy! Got it rebased to master (ended up doing a bit of manual git history since the merge conflict commit wasn't particularly useful).
Moved this over. Taking a look at the current master, subclassing The
Done. As a result it's copy on read.
In both of those cases, I was imagining test code doing one of two things:
In this case everything works as expected, but multi-threading is removed. From a test design standpoint, the lack of multi-threading improves test reliability so I don't think it would be advantageous to support it from the TestCoroutineDispatcher.
In this case, the a delay in a library function will lead to a test delay. However, it's not clear that time can be auto-progressed in a discover-able way in this context. That said, some work will need to integrate coroutines with Espresso (e.g. #242). I started chatting about that but haven't figured out exactly what it will mean. The trivial implementation (let espresso know whenever the dispatcher is executing something) will capture CPU bound tasks, but won't be busy when all coroutines are suspended waiting on input. The next implementation I'm considering is "any active jobs" = busy, but that won't handle actors. I'll give it some more thought and think about what can fit here.
Good idea, will take a look at what that would mean. I think it fits into this PR.
Agreed. I've completely reworked the API of the dispatcher to expose auto-advance by default and allow it to be paused for advanced use cases. I've removed Thanks. I'll ping again for a review after I take a look into the remaining issues discussed above. |
Thanks. I totally agree that "single-threaded tests" is the great goal to have. That is how we write most of our library tests, too. It makes functional testing & debugging of various behaviors so much easier. Let me clarify my concerns about interaction between Suppose that I have a separate module in my project that implements some useful function. For example, it performs some long-running CPU computation in its own thread pool or using some built-in dispatcher (like We can require, as a coding style practice, that such dispatchers are always get injected (so they can be replaced by a test dispatcher in tests), but, IMHO, that places too much burden on all the libraries. I would rather find some global, test-only mechanism that automatically replaces all such dispatchers with a test-dispatcher (or makes test dispatcher's time-advance feature aware of alternative dispatchers), so that we don't have to manually write this dispatcher-injecting code in our libraries just for the sake of being able to use them in tests. |
Add deprecations to new API for changes.
- removed asyncTest - added pauseDispatcher(), pausDispatcher(block), and resumeDispatcher() API to delayController - removed dispatchImmediately (the name was fairly opaque in an actual test case) - added exception handling to runBlockingTest
- Added missing defaults to ctor of TestCoroutineScope - Docs cleanup & tests for coverage
8d0ec9f
to
e64ac09
Compare
- Removed some added-as-deprecated APIs that might have helped with automatic refactoring via ReplaceWith. Not sure if these should be shipped.
That all makes sense to me. I've updated the code based on the remaining comments. Current APII've added appropriate experimental and deprecation annotations throughout. One thing to get input on - there is an existing API for TestCoroutineContext that's being replaced here, does it make sense to add it on The current API exposes EspressoI did not put espresso in this PR, but looking at @yigit's code in #242 it fits cleanly in Dispatchers.IO/DefaultAgreed completely, a service locator should be added for both of those otherwise it will be nearly impossible to control this for libraries you depend upon. I'm seeing the pattern of thread confinement with Exception Handling outside of the testing scoepAfter playing around with it I decided /not/ to include a global exception handler hook in this PR. It would add a public API for coroutines (perhaps Adding a global default exception handler is probably a good idea (especially considering the different implementations for uncaught exceptions of the bytecode and js versions). There's two reasons for an exception handler like this:
Since this isn't really a test-first API it makes sense to consider it's addition for metrics / crash reporting, especially in JS where it looks like there's no way to intercept the exceptions another way (in bytecode the thread uncaught exception handler can be used). |
When you have a second @elizarov can you review it and I'll loop back and address any comments you have! Thanks, |
* [CoroutineDispatcher] that can be used in tests for both immediate and lazy execution of coroutines. | ||
* | ||
* By default, [TestCoroutineDispatcher] will be immediate. That means any tasks scheduled to be run without delay will | ||
* be immediately executed. If they were scheduled with a delay, the virtual clock-time must be advanced via one of the |
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.
this behavior is a bit worrying.
Specifically, an asyc being dispatched immediately significantly alters my coroutine's execution order. Of course, there are no guaratees but consider a buggy code like this:
val atomic = AtomicBoolean(false)
async {
if (fetchData() == true) {
atomic.set(true)
}
}
// do something else
if (atomic.set()) {
// do something.
}
normally, the code above would flake since async
may or may not be scheduled immediately. By altering that behavior and making it consistent; this might hide bugs in applications. If we need to pick a consistent behavior, i would prefer asyncs and launches to be deferred as much as possible (put them end of the execution queue).
That is not perfect either but i think it is better behavior since it will make it easier to catch any missing await
calls for side effects.
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.
This is one I've gone back and forth on - in this specific example it would be prudent for a developer to write the test like this
- test of delayed async call
runBlockingTest {
pauseDispatcher {
subject()
runCurrent()
}
}
- test of early async call
runBlockingTest {
subject()
}
I modeled the current behavior off the Robolectric scheduler which typically works for most code that doesn't introduce either race conditions or inappropriate side-effects at launch. For code that does, pauseDispatcher
provides the ability to engage in explicit ordering control.
Eager execution in tests provides a benefit over purely lazy or EventLoop
style execution for this typical code:
fun subject() { // don't return job
scope.launch { // stuff }
}
@Test
fun testSubject() = scope.runBlockingTest {
subject()
}
In execution in the runBlocking
style, launch
body won't be entered until the job.join()
, which isn't available. With this dispatcher, the test could pause the dispatcher then call runCurrent()
after subject()
which is similar to the Rx implementation.
As another detail: All of this only applies to launched coroutines when using runBlockingTest
. Regular suspend functions will progress through both dispatch and delay (calling another suspend function or a delay function) in runBlockingTest
which accomplishes making the test go faster than the wall clock without adding a lot of code (or introducing new call / execution semantics)
Here's a pathological test that motivates this behavior:
@Test
fun funnyCode() = scope.runBlockingTest {
delay(5000) // what should this do?
}
Update: for clarity, when using the dispatcher without runBlockingTest
current tasks will be dispatched immediately, but delays will always require explicit time advance.
Again, I have gone back and forth on this a few times and would welcome other ideas. One way to go would be to invert the behavior, and expose a way to enter auto-progressing dispatcher state (maybe resumeDispatcher
could take a block?)
I really love this PR as I've found it quite difficult to consistently use |
(rest of commit)
Thanks for taking a look @ashdavies ! Definitely looking for more input about the public API so please feel free to put comments on it! Cheers, |
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.
+1
} | ||
|
||
|
||
private fun doActionsUntil(targetTime: Long) { |
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.
can we make this return boolean (or int, # of executed coroutines blocks) OR provide a function to check if idle?
I'm using the old one in another test where i have multiple TestCoroutineContexts. I want to have a function that progresses all of them (and they enqueue messages into eachother) and i cannot write a custom function without reflecting into the queue.
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.
to be clear, the version I need is expected to ignore delayed tasks if time is not ready.
Maybe i'm missing a function that already exists :/.
Not proud of it but here is what i'm doing there:
private fun triggerAllActions() {
do {
mainContext.triggerActions()
testContext.triggerActions()
val allIdle = listOf(mainContext, testContext).all {
it.isIdle()
}
} while (!allIdle)
}
private fun TestCoroutineContext.isIdle() : Boolean {
val queueField = this::class.java
.getDeclaredField("queue")
queueField.isAccessible = true
val queue = queueField.get(this)
val peekMethod = queue::class.java
.getDeclaredMethod("peek")
val nextTask = peekMethod.invoke(queue) ?: return true
val timeField = nextTask::class.java.getDeclaredField("time")
timeField.isAccessible = true
val time = timeField.getLong(nextTask)
return time > now()
}
Write a test around MAX_INT |
I Love to follow this. I wrote the original version of |
Here's what I've done to expedite release of this PR:
There is still an unresolved issue around semantics of immediate All new API is marked with @objcode Please, take a look at my adjustments. We tentatively plan to merge |
By the way, pay attention to "expect" style sequence testing that |
Thanks @elizarov taking a look at the updates and notes. |
Took a look at all of the changes, LGTM. This will need a readme updates for the test package to cover the new API surface. I'll draft that in an isolated commit that can cherry-picked or merged after 890. Minor nit: Typo at d76456f#diff-e3c98820170c8b47a0aad1428e940c2cL111 And inquiry, is the |
No. Only for backing properties: https://kotlinlang.org/docs/reference/coding-conventions.html#property-names |
Put together a README.md for this PR in a cherry-pickable commit. All of the url's are pending because I wasn't sure I figured out the url format for docs. |
Great. I've cherry-picked it into
URLs links to docs are automatically fixed by running |
🎉 Merged into |
Updates
TestCoroutineContext
to support structured concurrency.Issue: #541
Changes:
TestCoroutineContext
intoTestCoroutineDispatcher
,TestCoroutineExceptionHandler
andTestCoroutineScope
DelayController
interface for testing libraries to expose Dispatcher controlExceptionCaptor
inerface for testing libraries to expose uncaught exceptionsrunBlockingTest
withTestContext
Usage (main entry points)
runBlockingTest
runBlockingTest
is an immediate executor, delays will always auto-progress from eitherdispatch
orscheduleResumeAfterDelay
. Note that it provides a suspend lambda.fun runBlockingTest(context: CoroutineContext? = null, testBody: suspend CoroutineScope.() -> Unit)
TestCoroutineDispatcher / TestCoroutineScope
When used in conjunction with #810 most tests will declare a dispatcher as part of the setup method. As a convenience extension functions are provided
needs review: Tests that only have a dispatcher are allowed access to
runBlockingTest
as well without constructing a scoperunBlockingTest with time control
Tests can use runBlockingTest with explicit time control if they use the pauseDispatcher function:
Tests can use runBlockingTest with time control if they pass the dispatcher directly:
Library friendly interfaces
To help testing libraries expose test control, the time control and exception handling are split into interfaces. This allows a testing library to expose a smaller interface to tests than
TestCoroutineDispatcher
as well as combine the two interfaces onto a control object. In the current impl.,TestCoroutineScope
delegates both interfaces.Ideally, testing libraries would prefer to expose these smaller interfaces. For example a JUnit4 rule might expose
DelayController
and delegate it to an instance ofTestCoroutineDispatcher
.(Notes) API changes on this PR
asyncTest
that would start the test with explicit time control. This API was a close duplicate ofrunBlockingTest
, and is more naturally handled by the block version ofpauseDispatcher
inside a test.runBlocking
support. The overlap betweenrunBlocking
andrunBlockingTest
is large - but there are subtle differences that makerunBlockingTest
better suited for testing. There is no API change forrunBlockingTest
for this. The main advantagerunBlockingTest
will eagerly executelaunch
bodies even if they are never joined.