Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 048 / 100

Testing coroutines and flows in Modern Android & Kotlin

Deterministic async tests.

Expona Admin8 min read

Testing coroutines and flows in Modern Android & Kotlin

When you build real applications with coroutines and flows, your async code is just as critical as your business logic—but it's also just as easy to get wrong if you're not testing it properly. Testing coroutines and flows in Modern Android & Kotlin requires more than just running your app and hoping it works; you need deterministic async tests that run consistently, complete predictably, and surface race conditions before your users do.

Why async testing matters

Coroutines and flows simplify async code, but they introduce timing dependencies that make testing harder. A naive approach—wrapping tests in runBlocking or using arbitrary delays—leads to flaky tests that pass sometimes and fail others, or tests that take far too long to run.

We test coroutines to catch bugs early: race conditions, resource leaks, cancellation issues, and incorrect state transitions. The tools available in the Kotlin ecosystem make this possible without sacrificing speed or maintainability.

runTest from kotlinx-coroutines-test

runTest is the foundation of coroutine testing. It provides a special test dispatcher that runs your suspending code synchronously, advancing time as needed, so tests complete instantly instead of waiting for real delays.

Here's a practical example. Imagine a UserRepository that fetches a user with a 2-second simulated network delay:

kotlin
class UserRepository(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun fetchUser(id: String): User = withContext(dispatcher) {
        delay(2000)
        User(id, "Alice")
    }
}

class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow = _uiState.asStateFlow()

    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = repo.fetchUser(id)
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

Without runTest, you'd either add Thread.sleep(2500) (making tests slow) or hope timing doesn't cause failures. With runTest, you control time completely:

kotlin
@Test
fun loadUser_success() = runTest {
    val repo = UserRepository(dispatcher = StandardTestDispatcher(testScheduler))
    val viewModel = UserViewModel(repo)

    viewModel.loadUser("user123")
    
    // Advance time past the 2-second delay
    advanceUntilIdle()
    
    val state = viewModel.uiState.value
    assertTrue(state is UiState.Success)
    assertEquals("Alice", (state as UiState.Success).user.name)
}

Notice we pass StandardTestDispatcher(testScheduler) to the repository. This is key: the repository uses it internally via withContext, so all delays and suspensions run under our test's time control. advanceUntilIdle() fast-forwards past all pending operations instantly.

StandardTestDispatcher controls time

StandardTestDispatcher is your lever for deterministic testing. Instead of wall-clock time, it uses a virtual clock that only advances when you tell it to. This gives you three superpowers: speed (tests run in milliseconds), determinism (no flakiness), and control (you can test exact timing scenarios).

Let's build a more complex example: a feature that debounces user search input and retries failed requests.

kotlin
class SearchViewModel(
    private val searchService: SearchService,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
    private val _results = MutableStateFlow>(emptyList())
    val results: StateFlow> = _results.asStateFlow()

    private val searchQuery = MutableStateFlow("")

    init {
        viewModelScope.launch {
            searchQuery
                .debounce(300)
                .distinctUntilChanged()
                .collect { query ->
                    if (query.isNotEmpty()) {
                        performSearch(query)
                    }
                }
        }
    }

    fun onQueryChanged(query: String) {
        searchQuery.value = query
    }

    private suspend fun performSearch(query: String) = withContext(dispatcher) {
        try {
            val results = searchService.search(query)
            _results.value = results
        } catch (e: Exception) {
            // retry logic here
        }
    }
}

Testing the debounce behavior with virtual time:

kotlin
@Test
fun search_debouncesInput() = runTest {
    val service = FakeSearchService()
    val viewModel = SearchViewModel(service, StandardTestDispatcher(testScheduler))

    // Simulate rapid typing
    viewModel.onQueryChanged("a")
    advanceTimeBy(100)
    viewModel.onQueryChanged("ab")
    advanceTimeBy(100)
    viewModel.onQueryChanged("abc")
    
    // Search shouldn't happen yet (debounce is 300ms, we've only waited 200ms)
    assertEquals(0, service.searchCallCount)

    // Advance the remaining 100ms
    advanceTimeBy(100)
    
    // Now the search fires
    assertEquals(1, service.searchCallCount)
    assertEquals("abc", service.lastQuery)
}

We control time with surgical precision: advanceTimeBy(100) moves the clock forward by exactly 100ms, advanceUntilIdle() runs everything pending, and currentTime lets you query where you are. This makes it trivial to test timing-sensitive behavior.

Turbine for testing flows

Flows emit sequences of values over time. Testing them means asserting on those sequences—first value, multiple values, completion, errors. That's where Turbine comes in: a small but powerful library that lets you consume flows in tests with clear, readable assertions.

Add it to your test dependencies:

kotlin
testImplementation("app.cash.turbine:turbine:1.0.0")

Here's a real scenario: a chat feature that loads messages incrementally, with a loading state at the start.

kotlin
class ChatViewModel(
    private val chatService: ChatService,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
    fun loadMessages(roomId: String): Flow = flow {
        emit(UiState.Loading)
        try {
            val messages = withContext(dispatcher) {
                chatService.fetchMessages(roomId)
            }
            emit(UiState.Success(messages))
        } catch (e: Exception) {
            emit(UiState.Error(e.message ?: "Unknown error"))
        }
    }
}

With Turbine, testing the flow is straightforward:

kotlin
@Test
fun loadMessages_emitsLoadingThenSuccess() = runTest {
    val service = FakeChatService()
    val viewModel = ChatViewModel(service, StandardTestDispatcher(testScheduler))

    viewModel.loadMessages("room1").test {
        // First emission should be Loading
        assertEquals(UiState.Loading, awaitItem())
        
        // After fetching completes, we get Success
        assertEquals(UiState.Success(listOf(Message("Hello"))), awaitItem())
        
        // Flow should complete (no more items)
        awaitComplete()
    }
}</code><p></p>

<p>Turbine's <code>test</code> block gives you an intuitive API: <code>awaitItem()</code> waits for and returns the next emitted value (or fails if the timeout expires), <code>awaitComplete()</code> asserts the flow completed normally, and <code>awaitError()</code> asserts an exception was thrown. You can also use <code>expectNoEvents()</code> to verify nothing was emitted during a time window.</p>

<p>Testing error cases is just as clean:</p>

<pre><code class="language-kotlin">@Test
fun loadMessages_emitsError() = runTest {
    val service = FakeChatService(shouldFail = true)
    val viewModel = ChatViewModel(service, StandardTestDispatcher(testScheduler))

    viewModel.loadMessages("room1").test {
        assertEquals(UiState.Loading, awaitItem())
        
        val error = awaitItem() as UiState.Error
        assertTrue(error.message.isNotEmpty())
        
        awaitComplete()
    }
}

Inject dispatchers into classes

All of this hinges on one principle: pass your CoroutineDispatcher as a constructor dependency. Don't hard-code Dispatchers.IO or Dispatchers.Main inside classes. This applies to all suspending code: repositories, view models, services.

The pattern is simple:

kotlin
// Good: dispatcher is injectable
class DataRepository(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) {
    suspend fun getData(): Data = withContext(dispatcher) {
        // network/database call
    }
}

// Bad: dispatcher is hard-coded, impossible to test
class DataRepository {
    suspend fun getData(): Data = withContext(Dispatchers.IO) {
        // network/database call
    }
}

In production, you pass the default (real dispatcher). In tests, you pass StandardTestDispatcher(testScheduler). For view models, use viewModelScope as normal—the CoroutineScope will use your injected dispatcher when you call withContext or launch.

If you're using dependency injection (Hilt, Koin, etc.), define a qualifier for test dispatchers:

kotlin
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TestDispatcher

@Module
@InstallIn(SingletonComponent::class)
object TestDispatcherModule {
    @Provides
    @TestDispatcher
    fun provideTestDispatcher(): CoroutineDispatcher = StandardTestDispatcher()
}

Then inject it:

kotlin
class MyRepository(
    @TestDispatcher private val dispatcher: CoroutineDispatcher
) { ... }

Common mistakes

Not injecting dispatchers: Hard-coded Dispatchers.Main or Dispatchers.IO means tests can't control timing. Always inject.

Using runBlocking instead of runTest: runBlocking blocks the thread and defeats the purpose of coroutines. Use runTest exclusively for coroutine tests.

Forgetting to call advanceUntilIdle(): If your code has delays or async operations, you must advance the test scheduler past them. Otherwise, assertions run before work completes.

Not catching Turbine timeouts: Turbine waits a default timeout for items. If you expect an item and none arrives, the test times out with a confusing error. Use expectNoEvents() for cases where you expect silence.

Mixing real and test dispatchers: If part of your flow uses Dispatchers.IO and part uses your test dispatcher, you'll have non-deterministic behavior. Inject consistently across the entire call stack.

Key takeaways

  • runTest runs your coroutines synchronously using a virtual clock, making tests fast and deterministic.
  • StandardTestDispatcher gives you fine control over time via advanceTimeBy, advanceUntilIdle, and currentTime.
  • Turbine simplifies flow testing with readable assertions: awaitItem(), awaitComplete(), awaitError().
  • Always inject CoroutineDispatcher as a constructor parameter with a sensible default; never hard-code it inside your logic.
  • Test both happy paths and edge cases: debouncing, retries, cancellation, and error handling all become trivial with these tools.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles