[Part 1] Testing Coroutines and Kotlin Flows

[Part 1] Testing Coroutines and Kotlin Flows

Shounak Mulay's photo
Shounak Mulay
·Nov 20, 2021·

6 min read

Subscribe to my newsletter and never miss my upcoming articles

Table of contents

  • Starter Project
  • Running the first test
  • Testing functions that launch a new coroutine
  • Always Inject Dispatchers
  • Coroutine Scope Rule
  • Where to go from here

Testing coroutines in Kotlin can be tricky. Tests need to be setup correctly to avoid flakyness.

In this tutorial you will learn how to write tests for coroutines. We will use an Android project however, the concepts can be applied to any Kotlin based project.

If you are not familiar with unit testing with JUnit or Kotlin Coroutines you can read about it here & here.

Starter Project

Clone the start branch of this git repository https://github.com/shounakmulay/KotlinFlowTest.

Open the project in Android Studio. The starter project is based on the MVVM architecture but without the service layer. It already contains a repository, a view model, and a test class for the view model.

We will be testing the view model. The repository is just a place holder dependency of the view model. We will mock this repository in our tests.

Running the first test

The starter project already contains one test. Open the MainViewModelTest.kt file in the app/test directory.

@Test
fun `Given suspendingFunction is called, When no error occurs, Then repositorySuspendingFunction should be invoked successfully`() = runBlocking {
    mainViewModel.suspendingFunction()

    verify(mainRepository, times(1)).repositorySuspendingFunction()
}
  • Let's run this test. You can do that by pressing the icon in the gutter right next to the test function name.

Screenshot_2021-10-13_at_7.29.26_PM.png

  • Here we are testing a suspending function in the view model that calls another suspend function in the repository.
suspend fun suspendingFunction() {
    mainRepository.repositorySuspendingFunction()
}
  • On running this test it should pass as expected.

Screenshot_2021-10-13_at_7.57.15_PM.png

The test uses runBlocking. It allows us to call suspend functions within its body by creating a new coroutine and blocks the current thread until it completes. This is the behaviour that we want in a test, we do not want any other code running on the thread while the test is in progress.

If the function under test has any calls to delay , we can use runBlockingTest instead. It works the same as runBlocking with a few differences. While runBlocking will wait for the amount of the delay , runBlockingTest will skip past any delay blocks present and will instantly enter the coroutine blocks. This makes the tests run fast and in a more predictable manner.

Testing functions that launch a new coroutine

Now let's write a test for another function in the view model that calls the same function in the repository, but does that by launching a new coroutine, instead of being a suspend function itself.

fun launchASuspendFunction() = viewModelScope.launch {
    mainRepository.repositorySuspendingFunction()
}
  • Go to the MainViewModelTest file and add a new test that tests launchASuspendFunction.
@Test
fun `Given launchASuspendFunction is called, When no error occurs, Then repositorySuspendingFunction should be invoked successfully`() = runBlocking {
    mainViewModel.launchASuspendFunction()

    verify(mainRepository, times(1)).repositorySuspendingFunction()
}
  • On running this test, it fails with the error message Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used.

Screenshot_2021-10-13_at_8.02.00_PM.png

  • The error means that it was not possible to launch a coroutine on the main thread.

    Since launcASuspendFunction launches a new coroutine on the viewModelScope without any dispatcher provided as an argument, it uses Dispatchers.Main by default. In the test environment Looper.getMainLooper() is not available thus the test fails.

    To solve this issue we need to use TestCoroutineDispatcher which executes tasks immediately.

  • Set the TestCoroutineDispatcher as the main dispatcher in the setUp function that executes before every test.

@Before
fun setUp() {
    mainRepository = mock()
    Dispatchers.setMain(TestCoroutineDispatcher())
    mainViewModel = MainViewModel(mainRepository)
}
  • Create a function that will run after every test to clean up.
@After
fun cleanup() {
    Dispatchers.resetMain()
}
  • Now if you run the test, because we replaced the main dispatcher, it passes as expected!

Screenshot_2021-10-13_at_7.57.15_PM.png

Always Inject Dispatchers

Now that we have fixed the problem of Dispatchers.Main, let's try some other dispatchers.

Go to the MainViewModel and update the viewModelScope.launch method on launchASuspendFunction to accept a dispatcher. Let's use the Default dispatchers.

fun launchASuspendFunction() = viewModelScope.launch(Dispatchers.Default) {
    mainRepository.repositorySuspendingFunction()
}

Run the test again and you will see that it still passes.

So everything is fine right? Not quite.

We could have functions that do more than just one operation or function call.

  • Let's call the repository function one more time.
fun launchASuspendFunction() = viewModelScope.launch(Dispatchers.Default) {
    mainRepository.repositorySuspendingFunction()
    mainRepository.repositorySuspendingFunction()
}
  • And also update the test to expect 2 calls to repositorySuspendingFunction
@Test
fun `Given launchASuspendFunction is called, When no error occurs, Then repositorySuspendingFunction should be invoked successfully`() = runBlocking {
    mainViewModel.launchASuspendFunction()

    verify(mainRepository, times(2)).repositorySuspendingFunction()
}
  • Now if you run the test, it fails! We expected the function to be called twice, but it was only called once.

Screenshot_2021-10-13_at_8.11.36_PM.png

We replaced the Main dispatcher with the TestCoroutineDispatcer. But now the function under test is launching a new coroutine on the Default dispatcher. To make the test run correctly we need to replace the Default dispatcher with TestCoroutineDispatcher as well. But there is no method like Dispatchers.setMain to replace the Default or IO dispatcher.

The solution to this is to inject dispatchers as a dependency into **the classes that use them.

  • Create a class CoroutineDispatcherProvider
data class CoroutineDispatcherProvider(
  val main: CoroutineDispatcher = Dispatchers.Main,
  val default: CoroutineDispatcher = Dispatchers.Default,
  val io: CoroutineDispatcher = Dispatchers.IO
)
  • Add CoroutineDispatcherProvider as a dependency to MainViewModel
class MainViewModel(
  private val mainRepository: MainRepository,
  private val dispatcherProvider: CoroutineDispatcherProvider
): ViewModel() {
  • In app/di/viewModelModule update the view model constructor
val viewModelModule = module {
    viewModel { MainViewModel(get(), CoroutineDispatcherProvider()) }
}
  • Update the setUp function of the test class
@Before
fun setUp() {
    mainRepository = mock()
    val testDispatcher = TestCoroutineDispatcher()
    Dispatchers.setMain(TestCoroutineDispatcher())
    val coroutineDispatcherProvider = CoroutineDispatcherProvider(
        main = testDispatcher,
        default = testDispatcher,
        io = testDispatcher
    )
    mainViewModel = MainViewModel(mainRepository, coroutineDispatcherProvider)
}
  • Finally update the function we are testing to use the dispatcher provider
fun launchASuspendFunction() = viewModelScope.launch(dispatcherProvider.default) {
    mainRepository.repositorySuspendingFunction()
    mainRepository.repositorySuspendingFunction()
}

Now if we run the test, it will pass again no matter what dispatcher we use.

Coroutine Scope Rule

Manually creating a test dispatcher and CoroutineDispatcherProvider for every test class is a bit tedious. We can extract this work into a JUnit rule.

  • Create a class CoroutineScopeRule in the test directory.
@ExperimentalCoroutinesApi
class CoroutineScopeRule(
    private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
    var dispatcherProvider: CoroutineDispatcherProvider = CoroutineDispatcherProvider()
): TestWatcher(), TestCoroutineScope by TestCoroutineScope(dispatcher) {

    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(dispatcher)
        dispatcherProvider = CoroutineDispatcherProvider(
            main = dispatcher,
            default = dispatcher,
            io = dispatcher
        )
    }

    override fun finished(description: Description?) {
        super.finished(description)
        cleanupTestCoroutines()
        Dispatchers.resetMain()
    }

}
  • We can apply this rule as
@get:Rule
val coroutineScope = CoroutineScopeRule()
  • We can also create a BaseTestclass that will apply the rule. Create an new file BaseTest
open class BaseTest {

    @get:Rule
    val coroutineScope = CoroutineScopeRule()
}
  • And make the MainViewModelTest class extend from BaseTest
class MainViewModelTest: BaseTest()
  • We can now get rid of the dispatchers related code in the setup function of MainViewModelTest
@Before
fun setUp() {
    mainRepository = mock()
    mainViewModel = MainViewModel(mainRepository, coroutineScope.dispatcherProvider)
}
  • The cleanup function can be deleted as that part is already handled by the rule applied in the BaseTest class.

Where to go from here

You now have a setup that can help you write unit tests that deal with coroutines and any dispatchers!

You can look at the complete code after part 1 at https://github.com/shounakmulay/KotlinFlowTest/tree/part1-end

In Part 2 we will look at testing Kotlin Flows. Here's the link to Part 2 blog.shounakmulay.dev/part-2-testing-corout..

 
Share this