Contents

A smarter way of testing Coroutines with runTest

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.