Coroutines are great. They allow us to write asynchronous code as if they were synchronous. It lets us avoid callback hell, transform our data easily, thread switching, etc. It’s a pretty sweet deal.

Well at least we got Rainbow Brackets

Testing it however, may not be so straightforward from the get go. You may find your data is returning nulls, or it might seem your coroutines aren’t even executing at all.

But once you figure out why, testing coroutines becomes as simple as using them. All you need to do is apply a few basic rules (no pun intended).

Now before we get into the rest of the post, I will be referring to the ViewModel a lot under the assumption that most following this tutorial will be using coroutines in an Android app with an MVVM structure. Although that is the case, the content in this article is applicable even outside of the MVVM architectural pattern. If you are not familiar with the concept, first, all you need to know is the ViewModel is where we like starting our coroutines in this architecture and second, come on man, it’s 2020. MVVM is the stuff. Get learning.

Now without further ado, let’s start testing.

 

Dependencies you’ll need

To maximise our efficiency with writing unit tests for coroutines, there’s 3 dependencies we’ll need.

testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_version'

This provides us with Test Coroutine Builders, particularly runBlockingTest which is a builder similar to runBlocking except it will immediately progress past delays and into launch or async blocks to prevent our tests from taking unnecessary time. Also provides the TestCoroutineDispatcher which is important for making some tests possible.

testImplementation 'androidx.arch.core:core-testing:2.1.0'

This gives us the InstantTaskExecutorRule, a rule that swaps the background executor used by Architecture Components with a different one which executes each task synchronously.

Testing a Suspend Function in isolation

@Test
fun `ViewModel loads data correctly`() = runBlockingTest {
    val viewModel = MyViewModel()
    val pages = viewModel.getPages()

    // Assert pages equals, is, etc.
    assertEquals("pages", pages)
}

Testing a basic suspend function is, well, pretty basic. We want to test our suspend function within a coroutine that runs on the same thread as the test so it all executes in a synchronous fashion.

We wrap our test in the runBlockingTest coroutine builder which is similar to runBlocking but skips past delays so our tests don’t run longer than they have to. Other than this, we can build our test normally like we were testing any regular synchronous function.

suspend fun getPages(): String {
    delay(10000)
    return "pages"
}

The function being tested here isn’t anything special. It’s got a mighty delay of 10 seconds. Useless here, perhaps yes, but in real-world test scenarios, this much delay for actual reasons might not be out of the question.

Now go back to our runBlockingTest builder. Thanks to this, the long delay here doesn’t affect the length of our test. Our test completes and passes almost instantly. If we used runBlocking, we would have to wait the full 10 seconds before our test completes.

Testing a function that starts a Coroutine

class MyViewModel: ViewModel() {

    val pagesLivedata = MutableLiveData<String>()

    fun launchGetPages() {
        viewModelScope.launch(Dispatchers.IO) {
            val pages = getPages()
            pagesLivedata.postValue(pages)
        }
    }

    suspend fun getPages(): String {
        delay(10000)
        return "pages"
    }

}

Now say, we want to test the launchGetPages function above. It in itself is not a suspend function, but it starts a coroutine that upon completion, posts to liveData.

@Test
fun `ViewModel posts on pagesLiveData`() {
    val viewModel = MyViewModel()
    viewModel.launchGetPages()

    assertEquals("pages", viewModel.pagesLivedata.value)
}

You might think to write a test like this. You will however find that this doesn’t work. The LiveData doesn’t get posted and the assert finds a simple null value in its place.

junit.framework.ComparisonFailure: 
Expected :pages
Actual   :null

Why is this? Doesn’t runBlockingTest execute our code skipping any delays? Isn’t it supposed to make the test run synchronously?

Well, yes, but here’s the thing.

runBlockingTest starts a coroutine that blocks the current thread and skips past delays. launch starts another coroutine with its own rules, and if it has any suspend functions or delays, it will allow code outside of itself to execute while its suspended. This includes the assert function that’s within runBlockingTest but outside of launch.

Our production code includes launch though. We don’t want to change this. How do we test this then?

TestCoroutineDispatcher

val testCoroutineDispatcher = TestCoroutineDispatcher()
testCoroutineDispatcher.advanceUntilIdle()
testCoroutineDispatcher.pauseDispatcher()
testCoroutineDispatcher.runCurrent()

TestCoroutineDispatcher is the ideal dispatcher for tests, if the name doesn’t already give it away. It performs coroutine through lazy and immediate executions and implements delayController to control its virtual clock. That means we have more control over delays and such.

So we know that we want the ViewModel to use this dispatcher during tests, but obviously not in production code. How do we make this possible?

The answer to that my dude, is to allow the dispatcher to be injected into the ViewModel as a parameter. Using a custom provider class, we can make it so that in production code, your ViewModel uses Main, IO, you know, all the regular dispatchers it should be using. And then in tests, it replaces all of these with the TestCoroutineDispatcher.

So let’s modify our production code first.

open class CoroutineContextProvider @Inject constructor() {
    open val Main: CoroutineContext by lazy { Dispatchers.Main }
    open val IO: CoroutineContext by lazy { Dispatchers.IO }
}

First we need to create our class which we’ll name CoroutineContextProvider.

class MyViewModel(contextProvider: CoroutineContextProvider): ViewModel() {

    val pagesLivedata = MutableLiveData<String>()

    val ioContext: CoroutineContext = (contextProvider.IO)

    fun launchGetPages() {
        viewModelScope.launch(ioContext) {
            val pages = getPages()
            pagesLivedata.postValue(pages)
        }
    }

    suspend fun getPages(): String {
        delay(10000)
        return "pages"
    }

}

Then our ViewModel becomes this. All that’s changed is now, the ViewModel takes in a CoroutineContextProvider as a parameter which we take our Dispatchers from. We also moved the context globally to make things easier for us to handle.

Now in our test, we’ll use a subtype of CoroutineContextProvider we’ll now create, and we’ll call it TestContextProvider.

class TestContextProvider: CoroutineContextProvider() {

    val testCoroutineDispatcher = TestCoroutineDispatcher()

    override val Main: CoroutineContext = testCoroutineDispatcher
    override val IO: CoroutineContext = testCoroutineDispatcher
}

Now as you can see, if we pass this instead of its normal supertype into the ViewModel, all the coroutines will launch using the TestCoroutineDispatcher instead of their normal dispatchers.

So now, our test becomes such.

@Test
fun `ViewModel posts on pagesLiveData`() {
    val testContextProvider = TestContextProvider()
    val viewModel = MyViewModel(testContextProvider)

    viewModel.launchGetPages()
    testContextProvider.testCoroutineDispatcher.advanceUntilIdle()

    assertEquals("pages", viewModel.pagesLivedata.value)
}

Now you can run this now.. but the test will still fail. What can it be now? If you run the debugger, you’ll see that the delay gets skipped, and the whole suspend function executes perfectly up through till even the postValue which is supposed to give our assert statement its value. Why does it still assert null?

The problem lies in postValue itself. No matter how you set up your coroutine, postValue always switches to the main thread to set the given value. How do we fix this now? You’ll be happy to hear that the fix to that is quick and easy.

@get:Rule
val instantTestExecutorRule = InstantTaskExecutorRule()

That’s it. That’s all you need. This rule swaps the background executor used by architecture components with a different one which executes each task synchronously, as it says inside the class itself. In a nutshell, this lets postValue and functions similar to it execute immediately.

Now run the test and lo and behold.

Our test finally passes

Conclusion

As a quick summary/TLDR of what we covered in this post:

  • Use runBlockingTest to test suspend functions in isolation and skip past delays
  • To test functions that start their own coroutines like with launch, you want them to run on TestCoroutineDispatcher during tests
  • To make the above easily possible, let your ViewModel take in a coroutine context provider of your making as a parameter, and create your ViewModel which a special test coroutine context provider during tests which changes all dispatchers to TestCoroutineDispatcher.
  • Use the InstantTaskExecutorRule to make postValue and similar other-thread-posting-functions execute immediately.

Now that you have this set up, all your coroutine-testing needs should be catered for. As long as you set up your ViewModels to have their dispatchers injected via the CoroutineContextProvider we created here or your own version of it, and you remember to use the InstantTaskExecutorRule, you should be able to test most of the coroutines you’ll be making.

Of course, you might encounter test cases where this won’t be enough. In which case, check out the Github page of the Coroutine Testing Library as there is plenty to learn from there, much more than I can fit in a measly post such as this one.

In any case, if you like this post, please give this a thumbs up, comment on it, share it on Twitter or LinkedIn, I don’t know, just give me something. I’m a small name and I have some big names to compete against in the world of Android blogging.

In any case, happy coding ༼ つ ◕_◕ ༽つ

 

Newsletter

Subscribe to the Newsletter