Testing Kotlin Flows on Coroutines 1.6

Cloaked Community
5 min readJun 1, 2022

--

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, toList():

However, the second and more challenging case is testing a Flow that does not complete during the lifecycle of the test such as a StateFlowor SharedFlow. Take this example:

Please assume that we’re making myRepository.sendRequestemit a 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 cancelAndJoin():

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 runBlockingTest to runTest.

StandardTestDispatcher and UnconfinedTestDispatcher

The reason for this behavior shift is an underlying difference in the mechanisms of runTest and runBlockingTest.

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 runTestcall:

This is because runTest by default uses the so called StandardTestDispatcher, which leads to a different execution order of child coroutines than using UnconfinedTestDispatcher.

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.

With 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 runBlockingTest with 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 UnconfinedTestDispatcher()to runTestbecause the TestFlowCollector already uses it internally.

Conclusion

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.

--

--