Kotlin coroutine unit testing the better way

5 min read 1473

Kotlin coroutine unit testing the better way

If you are programming in Kotlin, you most likely use coroutines for your asynchronous work.

However, the code using coroutines should be unit tested as well. Thanks to the kotlinx-coroutines-test library, at first glance, this seems like an easy task.

However, there is one thing that many developers overlook that can potentially make your tests unreliable: how coroutines handle exceptions.

In this post, we’ll cover a typical project situation where some production code calls coroutines. This code is unit tested, but the test configuration is incomplete in such a way that code called within the coroutine may throw an exception.

Then, when the exceptions thrown inside the coroutine are not propagated as test failures, we’ll then find a solution to mitigate this problem and automate it for all our tests.

Prerequisites

To follow this tutorial, I assume that you:

  • Have a project already that already uses Kotlin coroutines
  • Have unit tests set up using the JUnit 5 testing framework and the kotlinx-coroutines-test coroutine testing library
  • Use either IntelliJ IDEA or Android Studio as your IDE (to see the detailed results of running unit tests)

Example tests in this tutorial use MockK for mocking test dependencies, but this is not critical — you can also use other mocking frameworks.

You may also have a CI server set up for running unit tests or run them from the command line — as long as you can inspect exceptions thrown while the tests run.

Example production code using coroutines

As an example of the basic mechanism of how coroutines are usually run in production, I will use a simple presenter class that connects to ApiService to get user data and then display it in a UI.

As getUser() is a suspending function and we want to take advantage of structured concurrency, we must launch it in a CoroutineScope wrapping a CoroutineContext. The particular logic in your project will probably be different, but if you’re using structured concurrency, the mechanism of calling coroutines will be similar.

We made a custom demo for .
No really. Click here to check it out.

We must inject the coroutine context into the presenter’s constructor, so it can be easily replaced for testing:

class UserPresenter(
  val userId: String,
  val apiService: ApiService,
  val coroutineContext = Dispatchers.Default
) {

  init {
    CoroutineScope(coroutineContext).launch {
      val user = apiService.getUser(userId)
      // Do something with the user data, e.g. display in the UI
    }
  }
} 

Unit test for coroutines logic

For this presenter, we have an example unit test that tests whether or not we called the API method to get user data when the presenter was instantiated:

class UserPresenterTest {

  val apiService = mockk<ApiService>()

  @Test
  fun `when presenter is instantiated then get user from API`() {

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = TestCoroutineDispatcher()
    )

    coVerify { apiService.getUser("testUserId") }
  }
}

Here, we used the MockK mocking framework to mock the ApiService dependency; you might have used another mocking framework in your project.

We also injected a TestCoroutineDispatcher into our presenter as coroutine context. TestCoroutineDispatcher is a part of the kotlinx-coroutines-test library and allows us to more easily run coroutines in tests.

By using MockK’s coVerify {} block, we can verify if the getUser() method was called on the mocked ApiService as expected.

However, MockK is a strict mocking framework, meaning it requires us to define the behavior of the getUser() method of the mocked ApiService using the following syntax:

coEvery { 
  apiService.getUser("testUserId") 
} returns User(name = "Test User", email = "[email protected]")

As you see in the example test at the beginning of this section, this definition of the getUser() behavior is missing. This is because we forgot to define it.

This happens sometimes when writing tests. When it does, MockK will throw an exception when running the test to alert us about a missing configuration and the test should fail.

Test passes, but an exception is thrown

Yet, when we run the test in the IDE or on a continuous integration server, it passes!

UserPresenterTest - PASSED 1 of 1 test
  when presenter is instantiated then get user from API() - PASSED

Neither the IDE nor the CI server tells us that we forgot to configure the mocked ApiService.getUser() behavior.

However, when you click the seemingly green test result in IntelliJ IDEA, you see that an exception thrown by MockK has been logged:

Exception in thread "Test worker @coroutine#1" 
io.mockk.MockKException: 
no answer found for: ApiService(#1).getUser(testUserId, continuation {})

Unfortunately, this exception has not been propagated to the JUnit test framework as a test failure, rendering the test green and giving us a false sense of security. None of our reporting tools (IDE or CI) will show us at a quick glance that something went wrong.

Of course, if you have hundreds or thousands of tests it would be impracticable to click on every one of them just to make sure you didn’t forget to mock something.

Why does this happen?

Coroutines handle exceptions internally and don’t propagate them to JUnit as test failures by default. Moreover, coroutines also don’t report unfinished coroutines by default to JUnit. So, we might have leaking coroutines in our code and not even notice it.

There is an interesting discussion in the Kotlin coroutines GitHub about this topic, specifically about an investigation and different approaches to fix this problem.

Can runBlockingTest fix it?

The discussion on GitHub referred above mentions that even if we wrap our test code in runBlocking{} or runBlockingTest{} blocks, the coroutine exceptions will not be propagated as test failures. Here is the modified test, which still passes although an exception is thrown:

  @Test
  fun `when presenter is instantiated then get user from API`() = runBlockingTest {

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = TestCoroutineDispatcher()
    )

    coVerify { apiService.getUser("testUserId") }
  }

Propagating exceptions as test failures using TestCoroutineScope

If you look through the kotlinx-coroutines-test library, you’ll find TestCoroutineScope, which seems to be exactly what we need to handle exceptions properly.

The test-coroutine-scope entry in the kotlinx-coroutines-test documentation

The cleanupTestCoroutines() method re-throws any uncaught exceptions that might have occurred during our test. It also throws an exception if there are any unfinished coroutines.

Using TestCoroutineScope in a test

To use TestCoroutineScope, we can replace the TestCoroutineDispatcher() in our test with TestCoroutineScope.coroutineContext. We must also call cleanupTestCoroutines() after each test:

class UserPresenterTest {

  val apiService = mockk<ApiService>()
  val testScope = TestCoroutineScope()

  @Test
  fun `when presenter is instantiated then get user from API`() {

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = testScope.coroutineContext
    )

    coVerify { apiService.getUser("testUserId") }
  }

  @AfterEach
  fun tearDown() {
    testScope.cleanupTestCoroutines()
  }
}

As you can see, the best thing about using TestCoroutineScrope is that we don’t have to change our test logic itself.

Testing now fails accurately

Let’s run the test again. Now, we see that the missing mock exception gets propagated to JUnit as a test failure:

UserPresenterTest - FAILED 1 of 1 test
  when presenter is instantiated then get user from API() - FAILED

  io.mockk.MockKException: 
  no answer found for: ApiService(#1).getUser(testUserId, continuation {})

Additionally, if we have an unfinished coroutine in the test, it reports as a test failure.

Automating test configuration

By creating a JUnit5 test extension, we can automate proper test configurations to save time.

For this, we must create a CoroutineTestExtension class. This class implements TestInstancePostProcessor, which injects the TestCoroutineScope() into our test instance after it’s created so we can easily use it in our tests.

The class also implements AfterEachCallback so we don’t need to copy and paste the cleanupTestCoroutines() method into every test class:

@ExperimentalCoroutinesApi
class CoroutineTestExtension : TestInstancePostProcessor, AfterEachCallback {

   private val testScope = TestCoroutineScope()

   override fun postProcessTestInstance(
           testInstance: Any?, 
           context: ExtensionContext?
   ) {
       (testInstance as? CoroutineTest)?.let { it.testScope = testScope }
   }

   override fun afterEach(context: ExtensionContext?) {
       testScope.cleanupTestCoroutines()
   }
}

We can also create a CoroutineTest interface for all our unit test classes to implement. This interface automatically extends with the CoroutineTestExtension class we just created:

@ExperimentalCoroutinesApi
@ExtendWith(CoroutineTestExtension::class)
interface CoroutineTest {

   var testScope: TestCoroutineScope
}

Using CoroutineTestExtension in tests

Our test class now only implements CoroutineTest.

Note that the overridden testScope is set by the CoroutineTestExtension we just wrote. That’s why it’s perfectly fine to mark it as a lateinit var (overriding var with lateinit var is allowed in Kotlin):

class UserPresenterTest : CoroutineTest {

  val apiService = mockk<ApiService>()
  override lateinit var testScope: TestCoroutineScope

  @Test
  fun `when presenter is instantiated then get user from API`() {

    val presenter = UserPresenter(
      userId = "testUserId",
      apiService = apiService,
      coroutineContext = testScope.coroutineContext
    )
    // No changes to test logic :)
  }
}

Now, your tests will report all coroutine exceptions and unfinished coroutines as test failures correctly.

Conclusion

By using CoroutineTestExtension in your tests, you will be able to rely on your tests failing accurately whenever there is an exception thrown inside your coroutines or your coroutines are unfinished. There will be no more false negatives conveying a false sense of security.

Also, thanks to the CoroutineTest interface, configuring your tests properly will be as easy as writing two additional lines of code in your tests. This makes it more probable that people will actually do it.

Leave a Reply