-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Testing all emissions in StateFlow without losing any #3939
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
Comments
Thanks for filing this @dkhalanskyjb. This is a common pain point, and it is technically already called out in the docs by specifying that
Perhaps it's not right to articulate the special testing care that's needed in the StateFlow documentation; I think it's better suited to the conflated documentation. This isn't exclusive to just StateFlow. However, since StateFlow is commonly exposed in API surfaces, perhaps it warrants calling out that unit testing requires special care.
val flow1 = flow {
emit(1)
emit(2)
emit(1)
}.conflate()
val flow2 = MutableStateFlow(1).apply {
value = 2
value = 1
} Under the right circumstances, val flow2Alike = flow {
emit(1)
emit(2)
emit(1)
}.conflate().distinctUntilChanged() This behavior can be confusing and frustrating to test when combined and I think warrants documentation. What do you think? |
Thanks so much for the issue. Our preferred fix at Cash App is none of the above: we unit test on APIs that expose I agree with @kevincianfarini that readability in documentation would help. JetBrains documents are rarely lacking in technical accuracy; however, they frequently leave much to be desired in clarity. We can see that this is true because many readers report that they have read the documentation and believed they understood it, and yet were surprised when they encountered the actual behavior in practice that the documentation described. If you know the facts you are looking for in advance, you can always find the information in JetBrains docs; if you do not, however, the relevant information may not be apparent to you. |
It would be like a And conflation can easily occur in production as well: just take any single-threaded dispatcher. val flow = MutableStateFlow<Int>(0)
println(withContext(Dispatchers.Main) {
val result = mutableListOf<Int>()
launch {
flow.collect {
if (it > 10) { cancel() } else { result.add(it) }
}
}
repeat(100) {
flow.value = it
}
result
}) If someone's tests fail because they don't understand that conflation will happen, it's good: when it happens in tests, it's much less frustrating than in production.
Yes. From the docs of "all emission-related operators, such as value's setter, emit, and tryEmit, are conflated using Any.equals." From the docs of "Values in state flow are conflated using Any.equals comparison in a similar way to distinctUntilChanged operator. It is used to conflate incoming updates to value in MutableStateFlow and to suppress emission of the values to collectors when new value is equal to the previously emitted one. State flow behavior with classes that violate the contract for Any.equals is unspecified."
There's a delicate balance between too little and too much documentation. We shouldn't infantilize our users: I'm pretty sure they are capable of reading when they have to. If we add extra examples to every sentence someone somewhere misreads, the documentation will become a monstrosity that's a pain to browse through. The I'm certainly not saying we shouldn't update the documentation, but we must be careful to make sure the edit is actually useful. Maybe someone can help me enter the mindset of someone who doesn't understand the issue of So, where does one go off the happy path and need some railing?
You're touching on a deep concept here. First, I'd like to argue that whether or not it's sufficient to test using a fun startLoading() {
_stateFlow.value = State.LOADING
_stateFlow.value = State.HAS_CONTENTS().apply {
scope.launch {
contents = networkCaller.getPage("https://neocities.org/about")
}
}
} A test that collects all emissions will say that everything's completely fine here, even though If we remember about conflation, then there's another argument for having a test with Whether it is useful to go the extra mile to collect all suspensions to guard against spurious emissions is the second question. I don't understand why this would be useful. Let's consider two scenarios:
I thought about this feedback hard, and I don't think I can do anything with it other than ignore it. JetBrains produces many documents, from https://www.jetbrains.com/help/youtrack/server/Issues.html to https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-setup.html to https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/run-test.html. It's all written, edited and reviewed by different teams, and there's no internal guideline for how clear or unclear the texts should be. In the Kotlin libraries team, clarity is an explicit goal. We often don't fully understand in advance how our code will be used, so it's impossible for us to always hit the mark, but we aspire to. We often incorporate suggestions from our users about how the documentation can be written more clearly for them to find what they seek, or sometimes we do so proactively when we notice a common misunderstanding. The reason we didn't update the |
Problem statement
A very common pain point when using our testing framework is that
StateFlow
conflates values emitted in quick succession.Example (all examples are written without Turbine, DI frameworks, or other niceties to show the most universal form that doesn't rely on any technologies but the ones available in our library):
This test will fail with
Expected 0 but got 10
.The reason is that there is no buffer inside
StateFlow
that stores the values that were emitted. Instead, only the latest value gets stored.runTest
uses only a single thread, so whilerepeat(10)
is running, the collector is waiting for its turn before it can start collecting values. The first moment when it gets its turn iscollector.join()
. Only then does the collector finally have a chance to check which values were sent—but at that point,StateFlow
contains10
.This issue was reported to us many times: #3120, #3367, #3339, #3519, #3143, https://slack-chats.kotlinlang.org/t/16039814/hey-all-wave-i-have-been-trying-to-test-the-intermediate-sta#9c1503b6-6da5-4e21-9aef-8b03c0dc1dbc, https://slack-chats.kotlinlang.org/t/16056041/is-there-any-interest-in-calling-out-that-stateflow-instance#2878e145-9e65-4095-9112-1da730105076, https://www.billjings.com/posts/title/testing-stateflow-is-annoying/?up= ...
Affected code
The typical use case we get approached with is similar to the following.
System under test is:
The test:
The test tries to observe every step of the process of loading a web page: initially, it's empty; after calling
startLoading
, it's supposed to enter theLOADING
state, and finally, after the page has loaded, we getHAS_CONTENTS
. However, this test fails withExpected LOADING, got HAS_CONTENTS(contents=<html></html>)
: due to conflation,startLoading
went fromLOADING
toHAS_CONTENTS
beforecollect
had a chance to process theLOADING
state.Fix
The problem with the above code is here:
This should be replaced with
The reasoning is as follows:
delay
in its implementation, it's happening instantly. If it really happened instantly in real life, the user wouldn't be able to see the spinner or any other visual indication of theLOADING
state: the state would get conflated, and we'd observe going fromEMPTY
toHAS_CONTENTS
immediately, which is exactly what the test correctly shows. By insertingdelay
, we clarify that we want to emulate the case when the network call took a noticeably long time.EMPTY
andHAS_CONTENTS
emissions in production).delay(25.milliseconds)
executes on a test dispatcher and not on actualDispatchers.IO
, it will be instantaneous. So, thisdelay
call has no impact on the performance of the tests.Alternative fixes (not recommended)
These are the alternative ways to work around this issue, with explanations of why we don't recommend any of them. Some of these fixes we explicitly recommended before, but now that our understanding of the use case has deepened, we can confidently advise against them.
UnconfinedTestDispatchers
We can work around the issue if we replace the line
with
Being a test-framework-specific equivalent to
Dispatchers.Unconfined
,UnconfinedTestDispatcher
this will ensure that each time an action to be executed on that dispatcher is enqueued (like a new value being available on the flow that's being collected), that action happens immediately.This approach works. However, it's unintuitive and requires a deep dive into coroutines to understand it, but also a bit brittle: if assignments to the
StateFlow
also happen from an unconfined dispatcher, this behavior will not be guaranteed. This is a realistic concern:runTest(UnconfinedTestDispatcher())
to ensure alllaunch
calls are entered immediately.ViewModelScope
usesDispatchers.Main.immediate
, which behaves likeDispatchers.Unconfined
when used from the main thread. So, any test for aViewModelScope
is susceptible to this.See also #3760
A custom
CoroutineDispatcher
One can tweak the
UnconfinedTestDispatcher
approach by defining their own coroutine dispatcher to deal with this case robustly:Then,
will ensure the collection is happening immediately, even if
runTest
as a whole runs inUnconfinedTestDispatcher
.This is clearly unreadable to anyone not well-versed in coroutines, and it's giving up the delay-skipping behavior: if
collector
used adelay
inside, it would be performed with the real-time clock, making the test take longer. In addition, and most importantly, none of the dispatchers in real life behave like this, so any test written withDirectCoroutineDispatcher
is at risk of testing the behavior that can't actually happen in production, and what even is the point of testing the behavior you can never observe?yield()
A common approach is to add a
yield()
after each assignment to make sure the test thread gets the chance to process it. In general,yield
should not be used to ensure a specific execution order, as it will only allow the code to run until the next suspension, and the number of suspensions a function will go through during its execution is an implementation detail that may change. If one goes down theyield()
path, they may end up with strange constructions likeyield(); yield(); yield() // ensure everyone processed the change
, which demonstrates the intent much worse thandelay(25.milliseconds) // emulate a long network call
does.Semantically,
yield
is supposed to mean, "let this thread do some other work," without defining exactly which work and how much of it is being done. The behavior ofyield
is predictable, but tracing through the interleavings of coroutines manually is too much work when reading a simple test.runCurrent()
(/advanceUntilIdle()
)Instead of
yield
, one can userunCurrent()
. It's slightly better, as it doesn't rely on an exact number of context switches to perform: depending on your needs, it can work asyield()
, oryield(); yield(); yield()
: all the work that's scheduled to execute at the current moment will finish by the end of the call.There are also downsides to
runCurrent()
. Notably, for this use case, it's a blocking call that can't be cancelled. See #3919 for more details and a discussion.What should we do?
We should update the documentation with a clear explanation of what to do in this typical scenario. For now, we'll keep this issue open for some time to collect feedback on the proposed approach.
The text was updated successfully, but these errors were encountered: