If you program with Kotlin, you are likely to use Coroutines for asynchronous work.

However, code that uses Coroutines should also be unit tested. At first glance, this seems like a simple task, thanks to the Kotlinx-Coroutines-test library.

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

In this article, we’ll look at a typical project situation where some production code calls Coroutines. The code was unit tested, but the test configuration was incomplete, and the code called inside the Coroutine might throw an exception.

Then, when an exception thrown within coroutine is not propagated as a test failure, we will find a solution to mitigate the problem and automate all of our tests.

The premise condition

To follow this tutorial, I assume that you.

  • There is a project that already uses Kotlin Coroutines
  • Have been usingJUnit 5 testing frameworkandkotlinx-coroutines-testThe Coroutine test library sets up unit tests
  • Use IntelliJ IDEA or Android Studio as your IDE (to see the detailed results of running unit tests).

The test examples in this tutorial use MockK to simulate test dependencies, but that’s not critical — you can also use other simulation frameworks.

You can also set up a CI server for running unit tests, or run them from the command line — as long as you can check for exceptions thrown when the tests run.

Examples of production code using wheel lines

As an example of the basic mechanism that Coroutines typically runs in production, I’ll use a simple Presenter class that connects to ApiService to get user data and then display it in the UI.

Since getUser() is a suspended function, and we want to take advantage of structured concurrency, we must start it in a CoroutineScope wrapped around a CoroutineContext. The specific logic in your project may vary, but if you use structured concurrency, the mechanism for calling coroutine will be similar.

We have to inject the Coroutine context into the Presenter’s constructor, so it can easily be replaced for testing purposes.

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
    }
  }
} 

Copy the code

Unit tests of article logic

For presenter, we have a unit test example that tests whether we call API methods to get user data when presenter is 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") }
  }
}

Copy the code

Here, we use the MockK mockery framework to mock the dependency of ApiService; You may have used another mocking frame in your project.

We also inject a TestCoroutineDispatcher into our presenter as a coroutine context. TestCoroutineDispatcher is part of the Kotlinx-Coroutines-test library and allows us to run Coroutine more easily in tests.

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

However, MockK is a strict mocking framework, which means that it requires us to use the following syntax to define the behavior of the getUser() method of the mocked ApiService.

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

Copy the code

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

This happens sometimes when you write tests. When this happens, MockK throws an exception when running the test, alerting us that the configuration is missing and the test should fail.

The test passed, but an exception was thrown

However, when we run the test on an IDE or continuous integration server, it passes! Why is that?

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

Copy the code

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

However, when you click on the green-looking test results in IntelliJ IDEA, you’ll 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 {})

Copy the code

Unfortunately, this exception was not propagated into the JUnit testing framework as a test failure, turning the test green and giving us a false sense of security. None of our reporting tools (IDE or CI) allow us to immediately see that something is wrong.

Of course, if you have hundreds of tests, clicking on each one to make sure you haven’t forgotten what to simulate is unrealistic.

Why is this happening?

Coroutines handles exceptions internally and does not propagate them to JUnit as test failures by default. Also, by default, Coroutines do not report unfinished Coroutines to JUnit. As a result, we could have leaky Coroutines in our code and not even notice it.

There’s an interesting discussion on this topic at Kotlin Coroutines GitHub, specifically about a different approach to investigating and solving the problem.

runBlockingTestCan this problem be solved?

The GitHub discussion mentioned above mentioned that even if we wrapped our test code with a runBlocking{} or runBlockingTest{} block, coroutine exceptions would not be propagated as test failures. Here is a modified test that throws an exception but still passes.

  @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") }
  }

Copy the code

A way to propagate an exception as a test failureTestCoroutineScope

If you look at [Kotlinx-Coroutines-test](github.com/Kotlin/kotl… Target =) library, you’ll find TestCoroutineScope, which seems to be just the way we need to handle exceptions properly.

The cleanupTestCoroutines() method rethrows any uncaught exceptions that may have occurred during our tests. It also throws an exception if there are any unfinished loops.

Use it in testsTestCoroutineScope

In order to use TestCoroutineScope, we can in the test using TestCoroutineScope. CoroutineContext replace TestCoroutineDispatcher (). 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()
  }
}

Copy the code

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

The test has now accurately failed

Let’s run the test again. Now we see that the missing mock exception is 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 {})

Copy the code

Also, if we have an unfinished loop program in the test, it will be reported as a test failure.

Automated test configuration

By creating a JUnit5 test extension, we can automate the appropriate test configuration to save time.

To do this, we must create a CoroutineTestExtension class. This class implements the TestInstancePostProcessor, it will be after our test instance creation TestCoroutineScope () into our test case, so we can easily use it in the test.

This 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() } }Copy the code

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

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

   var testScope: TestCoroutineScope
}

Copy the code

Use it in testsCoroutineTextExtension

Our test class now implements only CoroutineTest.

Note that the overloaded testScope is set by the CoroutineTestExtension we just wrote. That’s why it’s perfectly ok to mark it as Lateinit VAR (it’s allowed to override var with Lateinit var 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 :)
  }
}

Copy the code

Your test will now correctly report all loop exceptions and unfinished loops as test failures.

conclusion

By using the CoroutineTestExtension in your tests, you will be able to rely on your tests to fail exactly as soon as an exception is thrown in your Coroutine or your Coroutine is incomplete. There will be no false negation, no false sense of security.

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

The postKotlin coroutine unit testing the better wayappeared first onLogRocket Blog.