Skip to content

Using TestCoroutineDispatcher

Devrath edited this page Jul 3, 2021 · 16 revisions

What is TestCoroutineDispatcher

  • This is a type of co-routine dispatcher that is used to test the coroutines.
class TestCoroutineDispatcher : CoroutineDispatcher, Delay, DelayController
  • As seen above the testCoroutineDispatcher extends the classes coroutineDispatcher, delay, delayController.
  • It performs immediate and lazy execution of co-routines.
  • By default testCoroutineDispatcher is immediate meaning any tasks that are scheduled to run immediately is executed immediately
  • If there is a delay the virtual clock is advanced by the amount of delay involved.

How to Unit Test a suspend function

  • Coroutines provides an easy way and elegant way of executing the asynchronous code. But sometimes coroutines are hard to unit test.
  • Most important thing to remember here is, to understand how to build a coroutine in unit test and when executing that coroutine in the unit test, we need to understand how to wait for all the jobs in the unit test to complete before completing the test function.
  • Next in the task is we want to run our tests as fast as possible without waiting for the delay in coroutines to finish.
Steps in performing the unit testing
Set up the gradle
Defining the coroutineTestRule
Applying the coroutineTestRule
Define a sample-heavy operation
Possible options to take for unit testing
Best approach of testing suspending functions explained

Gradle

ext {
    coroutines = "1.3.1"
}

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"

    // testImplementation for pure JVM unit tests
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines"

    // androidTestImplementation for Android instrumentation tests
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines"
}

Defining the coroutineTestRule

  • This class extends TextWatcher
  • Define it in the util package in the test folder
@ExperimentalCoroutinesApi
class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : TestWatcher() {
    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}
  • Above rule takes care of watching the tests starting and the finishing.
  • There is a reference to testDispatcher.
  • As the tests start and finish, this replaces Dispatchers.Main with our testDispatcher.

Applying the coroutineTestRule

@ExperimentalCoroutinesApi
class HeavyWorkerTest {
    
    @get:Rule
    var coroutinesTestRule = CoroutineTestRule()

    // Some tests written
}

Define a sample-heavy operation

  • Heavy Worker class has a suspend function heavyOperation() that does the heavy lifting.
  • We should try to write a test function for this that is testable and executes quickly.
class HeavyWorker {

    suspend fun heavyOperation(): Long {
        return withContext(Dispatchers.Default) {
            return@withContext doHardMaths()
        }
    }

    // waste some CPU cycles
    private fun doHardMaths(): Long {
        var count = 0.0
        for (i in 1..100_000_000) {
            count += sqrt(i.toDouble())
        }
        return count.toLong()
    }
}

Possible options to take for unit testing

  • We have three options to choose for testing the suspend function
    • kotlinx.coroutines.runBlocking ❌
    • kotlinx.coroutines.test.runBlockingTest ❌
    • kotlinx.coroutines.test.TestCoroutineDispatcher.runBlockingTest

when using kotlinx.coroutines.runBlocking

@Test
fun `testing using the run blocking`() {
   val heavyWorker = HeavyWorker()
   val expected = 666666671666
   val result = heavyWorker.heavyOperation()
   assertEquals(expected, result)
}

---> This will pass for sure but it will take time to pass .... But most importantly it passes. Say the function we are testing has an additional delay of say 50 seconds

suspend fun heavyOperation(): Long {
    return withContext(Dispatchers.Default) {
        delay(50_000) // ----------------------------- > Here is the delay 
        return@withContext doHardMaths()
    }
}

---> Being said it passes but the test will wait for 50_000 delay and then finishes, it's like adding overhead on top of an existing thing

when using kotlinx.coroutines.test.runBlockingTest

@Test
fun useRunBlockingTest() = runBlockingTest {
    val heavyWorker = HeavyWorker()
    val expected = 666666671666
    val result = heavyWorker.heavyOperation()
    assertEquals(expected, result)
}
  • runBlockingTest was introduced as a newer coroutine builder than runBlocking
  • But test still fails with exception java.lang.IllegalStateException: This job has not completed yet
  • This happened because the test finished before the SUT has finished execution.

when using kotlinx.coroutines.test.TestCoroutineDispatcher.runBlockingTest --- > This is the best approach available

  • This is the same as runBlockingTest but an additional add-on here is we provide the dispatcher during the unit test in place of what is being used in the production code.
  • We shall explain in the section below

Best approach of testing suspending functions explained

  • Consider the code snippet
suspend fun heavyOperation(): Long {
    return withContext(Dispatchers.Default) {
        return@withContext doHardMaths()
    }
}
  • Above snippet Dispatchers.Default is hardcoded here.
  • We cannot provide the alternative dispatcher during the unit testing.
  • Providing the alternate dispatcher during the unit testing is exactly what we need.
  • Due to this we need to inject the dispatcher into the production code.
  • In the mentioned sample, we have just provided the Dispatcher.Default, But we might encounter other dispatchers in other scenarios
  • So let's define a common interface that has a reference to all the dispatchers.
interface DispatcherProvider {
    fun main(): CoroutineDispatcher = Dispatchers.Main
    fun default(): CoroutineDispatcher = Dispatchers.Default
    fun io(): CoroutineDispatcher = Dispatchers.IO
    fun unconfined(): CoroutineDispatcher = Dispatchers.Unconfined
}
  • Let's have an implementation of the interface which we use in our code
class DefaultDispatcherProvider : DispatcherProvider
  • Let's see how the DefaultDispatcherProvider is injected in the existing production code
class HeavyWorker(private val dispatchers: DispatcherProvider = DefaultDispatcherProvider()) {
    suspend fun heavyOperation(): Long {
        return withContext(dispatchers.default()) {
            delay(30_000)
            return@withContext doHardMaths()
        }
    }
}
  • Now let's define the unit test case
 val testDispatcherProvider = object : DispatcherProvider {
    override fun default(): CoroutineDispatcher = testDispatcher
    override fun io(): CoroutineDispatcher = testDispatcher
    override fun main(): CoroutineDispatcher = testDispatcher
    override fun unconfined(): CoroutineDispatcher = testDispatcher
}
  • We can modify our earlier rule as follows
@ExperimentalCoroutinesApi
class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : TestWatcher() {

    val testDispatcherProvider = object : DispatcherProvider {
        override fun default(): CoroutineDispatcher = testDispatcher
        override fun io(): CoroutineDispatcher = testDispatcher
        override fun main(): CoroutineDispatcher = testDispatcher
        override fun unconfined(): CoroutineDispatcher = testDispatcher
    }

    override fun starting(description: Description?) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}
  • Final test case of ours is modified as follows
@ExperimentalCoroutinesApi
class HeavyWorkerTest {

    @get:Rule
    var coroutinesTestRule = CoroutineTestRule()

    @Test
    fun useTestCoroutineDispatcherRunBlockingTest() = coroutinesTestRule.testDispatcher.runBlockingTest {
        val heavyWorker = HeavyWorker(coroutinesTestRule.testDispatcherProvider)
        val expected = 666666671666
        val result = heavyWorker.heavyOperation()
        assertEquals(expected, result)
    }

}
  • We use runBlockingTest that we obtain from the TestCoroutineDispatcher, which is inside the CoroutineTestRule.
  • We pass the testDispatcherProvider from inside the CoroutineTestRule to the HeavyWorker’s constructor.
  • The test passes and It doesn’t wait for the 50s for the delay to finish!
  • Using TestCoroutineDispatcher.runBlockingTest as our coroutine builder, and injecting the test dispatcher, allows us full control over the coroutine jobs created when unit testing.
  • It allows us to achieve the reliability we need, combined with the speed in not having to wait for delays to end.
Clone this wiki locally