Coroutine Test Dispatchers: Deterministische Tests mit runTest
Coroutines in Tests beherrschbar machen erfordert die richtigen Dispatcher. Lerne, wie du mit runTest und virtueller Zeit deterministische Tests schreibst.
Coroutines machen asynchronen Code lesbar – aber genau diese Asynchronität wird zum Problem, sobald du Unit-Tests schreiben willst. Ein delay(3000) im ViewModel lässt deinen Test drei echte Sekunden warten, und ein nicht abgeschlossener Flow führt dazu, dass Assertions niemals erreicht werden. Coroutine Test Dispatchers lösen dieses Problem, indem sie die Zeit im Test vollständig unter deine Kontrolle bringen.
Was ist das?
Ein Dispatcher entscheidet, auf welchem Thread oder Thread-Pool eine Coroutine ausgeführt wird. Im Produktionscode kennst du Dispatchers.Main, Dispatchers.IO und Dispatchers.Default. Für Tests existiert eine eigene Dispatcher-Familie aus dem Modul kotlinx-coroutines-test: TestCoroutineScheduler, StandardTestDispatcher und UnconfinedTestDispatcher.
Ihr entscheidender Unterschied zu echten Dispatchers: Sie nutzen keine echte Systemzeit, sondern eine virtuelle Uhr. Diese Uhr tickt nur dann weiter, wenn du sie im Test explizit vorwärts bewegst – etwa mit advanceUntilIdle() oder advanceTimeBy(3_000). Ein delay(3000) im Produktionscode dauert im Test damit null Millisekunden, weil du die Uhr einfach vorzuspulen kannst.
Im Kontext der Roadmap steht dieses Thema am Beginn von Phase 10 (Testing, Debugging and Quality Engineering). Wer Coroutines im ViewModel oder Repository einsetzt – also eigentlich jede moderne Android-App – kommt nicht daran vorbei, die Dispatcher beim Test auszutauschen. Ohne diesen Austausch sind Coroutine-lastige Tests entweder langsam, nicht deterministisch oder schlicht falsch.
Wie funktioniert es?
runTest
Der Einstiegspunkt ist runTest { } aus kotlinx-coroutines-test. Es ersetzt das ältere runBlocking { } für Coroutine-Tests und richtet automatisch einen TestScope mit einem StandardTestDispatcher ein.
@Test
fun `Ladevorgang abgeschlossen nach Delay`() = runTest {
val viewModel = MyViewModel()
viewModel.loadData()
advanceUntilIdle()
assertEquals(UiState.Success, viewModel.uiState.value)
}
runTest lässt die virtuelle Zeit nach jedem Schritt automatisch bis zum nächsten Resumption-Punkt vorrücken, wenn du advanceUntilIdle() aufrufst. Alle ausstehenden delay-Aufrufe und geplanten Coroutinen werden abgearbeitet, ohne echte Zeit zu verbrauchen.
StandardTestDispatcher vs. UnconfinedTestDispatcher
StandardTestDispatcher – der Standard in runTest – startet eine neue Coroutine, pausiert sie aber sofort nach dem ersten Suspension-Point. Du musst den Scheduler explizit vorantreiben, was dir maximale Kontrolle über die Ausführungsreihenfolge gibt.
UnconfinedTestDispatcher führt Coroutinen hingegen eagerly aus: Die neue Coroutine läuft sofort bis zum ersten echten Suspension-Point weiter. Das vereinfacht Tests für StateFlow-Collector oder UI-State-Snapshots, bei denen du einfach den aktuellen Zustand prüfen willst.
@Test
fun `StateFlow-Emission ohne manuelles Vorantreiben`() = runTest(UnconfinedTestDispatcher()) {
val viewModel = MyViewModel()
val states = mutableListOf<UiState>()
backgroundScope.launch { viewModel.uiState.collect { states.add(it) } }
viewModel.loadData()
assertEquals(UiState.Loading, states.first())
}
Virtuelle Zeit und TestCoroutineScheduler
Hinter beiden Dispatcher-Varianten arbeitet der TestCoroutineScheduler. Er verwaltet eine Warteschlange geplanter Coroutinen und eine virtuelle Uhr. Mit currentTime kannst du abfragen, wie viel virtuelle Zeit bereits vergangen ist – nützlich für Timeout-Logik oder Retry-Mechanismen mit exponential Backoff.
advanceTimeBy(500) // Zeit um 500 ms vorrücken
runCurrent() // Alle gerade fälligen Coroutinen ausführen
advanceUntilIdle() // Alle ausstehenden Coroutinen bis zum Ende abarbeiten
In der Praxis
Dispatcher-Injektion ist Pflicht
Der häufigste Fehler beim Einstieg: Der Dispatcher ist hart im ViewModel verdrahtet.
// FALSCH – nicht testbar
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch(Dispatchers.IO) { /* ... */ }
}
}
Damit erzwingst du im Test den echten Dispatchers.IO, der keinen Test-Scheduler kennt. Assertions nach dem Launch laufen ins Leere, weil die Coroutine auf einem anderen Thread ausgeführt wird, über den runTest keine Kontrolle hat. Die Lösung ist konsequente Dependency Injection des Dispatchers:
// RICHTIG – testbar
class MyViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
fun loadData() {
viewModelScope.launch(ioDispatcher) { /* ... */ }
}
}
// Im Test:
val testDispatcher = StandardTestDispatcher()
val viewModel = MyViewModel(ioDispatcher = testDispatcher)
Für Dispatchers.Main gibt es zusätzlich Dispatchers.setMain(testDispatcher), das im @Before-Block gesetzt und im @After-Block mit Dispatchers.resetMain() zurückgesetzt wird. Ohne diesen Schritt schlagen ViewModel-Tests mit einer IllegalStateException fehl, weil viewModelScope intern Dispatchers.Main verwendet.
Stolperfalle: TestScope nicht sauber aufräumen
Ein TestScope, der nach dem Test noch aktive Coroutinen enthält, wirft eine UncompletedCoroutinesError-Exception. runTest erledigt das Aufräumen automatisch – doch wenn du manuell einen TestScope erstellst, musst du ihn mit scope.cancel() im @After-Block beenden. Andernfalls können laufende Hintergrund-Coroutinen in den nächsten Test hineinsickern und zu schwer nachvollziehbaren Flaky-Tests führen.
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
testScope.cancel()
Dispatchers.resetMain()
}
backgroundScope für Flow-Collector
Innerhalb von runTest steht backgroundScope zur Verfügung. Coroutinen, die du dort startest, werden am Ende des Tests automatisch abgebrochen – ideal für Flow-Collector, die du nicht selbst canceln möchtest. Startest du den Collector stattdessen im normalen TestScope, wartet runTest darauf, dass er abgeschlossen wird, was bei einem unendlichen Flow zu einem Timeout führt.
Fazit
Coroutine Test Dispatchers geben dir vollständige Kontrolle über die zeitliche Ausführung asynchronen Codes. Mit runTest, virtueller Zeit und den richtigen Scheduler-Aufrufen werden auch komplexe Coroutine-Szenarien zu vorhersehbaren, blitzschnellen Unit-Tests. Prüfe jetzt dein bestehendes Projekt: Injizierst du Dispatcher konsequent per Konstruktor? Nutzt du runTest statt runBlocking? Füge currentTime-Assertions in einen bestehenden Retry-Test ein und beobachte, ob die virtuelle Uhr wirklich nur so weit springt, wie dein Code es erwartet – das ist die schnellste Methode, dein Verständnis dieser Mechanik zu festigen.