Testing coroutine-based code in Kotlin can be challenging due to its asynchronous nature.
Fortunately, Kotlin’s kotlinx.coroutines library provides helpful utilities for testing, including the runTest function.
Let’s dive into how to effectively use runTest and streamline coroutine testing in your Kotlin projects.
runTest
You’re probably used runTest before. Let me quote kotlinlang.org:
Executes testBody as a test in a new coroutine, returning TestResult.
On JVM and Native, this function behaves similarly to runBlocking, with the difference that the code that it runs will skip delays.
1
2
3
4
5
6
7
8
9
10
11
|
@Test
fun exampleTest() = runTest {
val deferred = async {
delay(1.seconds)
async {
delay(1.seconds)
}.await()
}
deferred.await() // result available immediately
}
|
In order to use runTest effectively, one must inject the CoroutineDispatcher to their class.
Injecting CoroutineDispatcher
Consider the following BooksViewModel, which fetches a list of books and updates the local UI state:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.invoke
import kotlinx.coroutines.launch
class BooksViewModel(
private val repository: BooksRepository,
private val backgroundDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _booksUiState = MutableStateFlow<BooksUiState>([default state])
val booksUiState: StateFlow<BooksUiState> = _booksUiState
init {
viewModelScope.launch {
_booksUiState.value = loadBooks()
}
}
private suspend fun loadBooks(): BooksUiState = backgroundDispatcher {
val books = repository.getBooks()
books.toBooksUiState()
}
private fun List<Book>.toBooksUiState(): BooksUiState { ... }
}
|
It employs two different CoroutineDispatchers. This allows flexibility in testing by substituting a TestDispatcher during testing.
Writing a Test
Let’s write a test for BooksViewModel using runTest.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
class BooksViewModelTest {
private var dispatcher: TestDispatcher = UnconfinedTestDispatcher()
private val repository = mockk<BooksRepository>()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun verifyBooksAreLoaded() = runTest(dispatcher) {
coEvery { repository.getBooks() } returns listOf(Book("title"))
val viewModel = BooksViewModel(
repository = repository,
backgroundDispatcher = dispatcher,
)
assert(viewModel.booksUiState.value.books == listOf(Book("title")))
}
}
|
A TestDispatcher is created and passed to both runTest on line 17 and BooksViewModel on line 21.
It’s also set as the Main dispatcher on line 8. We need it because viewModelScope is defined as CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).
Improve the setup
The test works, but it is too verbose. Let’s improve it step by step. There’s a nice hint in the runTest’s documentations:
If Dispatchers.Main is set to a TestDispatcher via Dispatchers.setMain before the test, then its TestCoroutineScheduler is used; otherwise, a new one is automatically created.
We’re using Dispatchers.setMain(dispatcher) which means runTest automatically adopts our beloved dispatcher.
Improvement 1
Simplify runTest(dispatcher) to runTest
We’ll need the following boilerplate every time. Let’s extract it to an Extension (JUnit 5) or a Rule (JUnit4).
1
2
3
4
5
6
7
8
9
10
11
|
private var dispatcher: TestDispatcher = UnconfinedTestDispatcher()
@BeforeEach
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
|
Improvement 2
Extract the boilerplate around test dispatcher handling into a reusable component.
JUnit5
1
2
3
4
5
6
7
8
9
10
11
12
|
class CoroutineTestExtension(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : AfterEachCallback, BeforeEachCallback {
override fun beforeEach(context: ExtensionContext?) {
Dispatchers.setMain(dispatcher)
}
override fun afterEach(context: ExtensionContext?) {
Dispatchers.resetMain()
}
}
|
JUnit4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class CoroutineTestExtension(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : InstantTaskExecutorRule() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}
|
The final version
Now, let’s rewrite our initial test.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class BooksViewModelTest {
@JvmField
@RegisterExtension
val testRule = CoroutineTestExtension()
private val repository = mockk<BooksRepository>()
@Test
fun verifyBooksAreLoaded() = runTest {
coEvery { repository.getBooks() } returns listOf(Book("title"))
val viewModel = BooksViewModel(
repository = repository,
backgroundDispatcher = testRule.dispatcher,
)
assert(viewModel.booksUiState.value.books == listOf(Book("title")))
}
}
|
Quite neat, right? The test dispatcher can be accessed via testRule.dispatcher.