Testing Kotlin Flows on Coroutines 1.6
Get expert tech advice straight from our team of developers and engineers.
Recently, I’ve had to refresh some of my knowledge as well after updating to Coroutines 1.6 and switching to the new test APIs, i.e. using
runTest instead of
runBlockingTestand all that comes along with that. There is one change in behavior that I’m sure will have others already using Flows wondering why some of their tests are not working anymore.
This article should serve as a general summary for how to get started testing Flows and as quick help for those who made this migration. I will be referencing some of the new Coroutines test APIs. If you want to learn more about them, please check out the official migration guide which has detailed descriptions.
The problem with testing Flows
Let’s look at two cases.
The first is the simpler one: Testing a cold Flow that has a definite point of completion within the lifecycle of the test. See this case of a Flow that emulates the state of a network request:
This is easy enough to test using, for example,
However, the second and more challenging case is testing a Flow that does not complete during the lifecycle of the test such as a
SharedFlow. Take this example:
Please assume that we’re making
RequestState.Failurein all the following test scenarios. Now, In order to test
errorMessageEvents, a simple
collect does not suffice:
This test times out after a long while because the SharedFlow never completes and thus, our call to
collectsuspends indefinitely. A naive approach to fix this could look like this:
This makes the test pass but collecting like this in order to test a hot Flow is a fallacy. If the Flow never emits anything, the test still passes because the equality check is never executed. We could try to alleviate this by using
first()on the Flow and
join() instead of
But now we’re actually back to square one — the test times out because our job never completes.
errorMessageEventsdoes not receive an emission in this test. This is actually due to a breaking change if you’re migrating from
StandardTestDispatcher and UnconfinedTestDispatcher
The reason for this behavior shift is an underlying difference in the mechanisms of
After initially making the switch without reading the migration guide, I was stumped as to why my Flow tests were suddenly failing. But an attentive reader of the guide will know that in order to restore the behavior of
runBlockingTest, they need to pass an
UnconfinedTestDispatcher to the
This is because
runTest by default uses the so called
StandardTestDispatcher, which leads to a different execution order of child coroutines than using
In the example above, with
StandardTestDispatcher the coroutine where the Flow is collected is launched but there is no guarantee that
errorMessageEvents.first is executed before
viewModel.doThing - this means that the error message emission is sent before the Flow is starting to be collected so the emission is lost.
UnconfinedTestDispatcher however, the child coroutine’s content is executed immediately and runs until the first suspension point, which guarantees the execution order we expect in this test.
If you’re interested, you can read more about this in the section about replacing
runTest(UnconfinedTestDispatcher()) in the migration guide. It is explained in detail there.
TestFlowCollector — a shorthand for testing Flows
To make testing Flows more intuitive, I’ve been using a small helper class for testing, inspired by the
TestObserver API from RxJava. Check out the full implementation in this gist:
After making the switch to
runTest, it now uses
UnconfinedTestDispatcher internally to collect the receiving Flow and records emitted values in order to run assertions on them. Using this in the example above makes the test look as follows:
awaitValueis used to safely assert emissions.
ensureNoValue can be used to assert that a Flow does not emit a value at a certain position:
The tests become a little shorter and easier to write — plus, it is no longer necessary to add
TestFlowCollector already uses it internally.
I hope this article has been helpful for people who have struggled testing their Flows before — or those who were facing the same issue I did after switching to runTest.
Lastly, I want to give a mention to Turbine, a dedicated library for the purpose of testing Flows. Though the
TestFlowCollector has served all the use cases I’ve needed so far, I’m certain that there are many more that it is not handling properly. While I haven’t used Turbine personally yet, it looks to be a lot more advanced than my solution so it might be a better fit for many people.