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.
To follow this tutorial, I assume that you:
kotlinx-coroutines-test
coroutine testing libraryExample 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.
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 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 } } }
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.
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.
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.
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") } }
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 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.
TestCoroutineScope
in a testTo 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.
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.
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 }
CoroutineTestExtension
in testsOur 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.
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.
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]