Skip to content
AgileLogics
6 min read

Testing Coroutines and Code with Dependencies

Prerequisites: Testing with kotlin.test and JUnit, StateFlow, SharedFlow, and Channels

Testing code that uses coroutines, timers, and outside services needs a few extra tools.

runTest and virtual time

The kotlinx-coroutines-test library provides runTest, which runs a coroutine test and skips delays using virtual time — a test for code that waits ten seconds finishes instantly:

// build.gradle.kts
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class RetryTest {
    private suspend fun fetchWithRetry(attempts: Int, call: suspend () -> String?): String? {
        repeat(attempts) {
            call()?.let { return it }
            delay(5_000)          // wait before retrying
        }
        return null
    }

    @Test
    fun `retries until a call succeeds`() = runTest {
        var calls = 0
        val result = fetchWithRetry(3) { calls++; if (calls < 3) null else "ok" }

        assertEquals("ok", result)
        assertEquals(3, calls)
        assertEquals(10_000, currentTime)   // two virtual 5-second waits
    }
}

currentTime shows how much virtual time passed — the test itself ran in milliseconds.

Fakes for dependencies

Classes that depend on interfaces can be tested with simple fake implementations:

class FakeArticleRepository(
    private val articles: List<Article> = emptyList(),
    private val fail: Boolean = false,
) : ArticleRepository {
    override suspend fun latest(): List<Article> {
        if (fail) throw IOException("offline")
        return articles
    }
}

Fakes like this are often clearer than mocking libraries. When you do need mocks — to verify a call was made, for example — MockK is the standard Kotlin choice and supports suspend functions.

Testing a ViewModel

ViewModels launch coroutines on the main dispatcher, which doesn't exist in plain unit tests. Replace it with a test dispatcher:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals

class ArticlesViewModelTest {
    private val dispatcher = StandardTestDispatcher()

    @BeforeTest fun setUp() = Dispatchers.setMain(dispatcher)
    @AfterTest fun tearDown() = Dispatchers.resetMain()

    @Test
    fun `shows articles after loading`() = runTest(dispatcher) {
        val articles = listOf(Article(1, "Hello"))
        val viewModel = ArticlesViewModel(FakeArticleRepository(articles))

        advanceUntilIdle()

        assertEquals(ArticlesUiState.Loaded(articles), viewModel.state.value)
    }
}

For testing flows that emit several values over time, the Turbine library makes assertions easy to read.

What to test

Focus on logic: ViewModels, repositories, validation, and mapping. Keep UI tests (Compose's testing APIs, Espresso) for the most important user journeys — they're slower and more fragile.