Android Coden
Android 7 min lesen

Strategie für asynchrone Tests

Du lernst, Coroutine- und Flow-Code stabil zu testen. Der Fokus liegt auf runTest, Fake-Zeit und reproduzierbaren Tests.

Asynchrone Android-Apps reagieren nicht in einer festen Reihenfolge wie ein kleines Konsolenprogramm. Daten kommen aus Netzwerk, Datenbank, Cache, Workern, ViewModels und Flows. Eine gute Async Testing Strategy hilft dir, dieses Verhalten zuverlässig zu prüfen, ohne Thread.sleep, zufällige Verzögerungen oder Tests, die nur auf deinem Rechner bestehen.

Was ist das?

Eine Async Testing Strategy ist dein Plan, wie du Coroutine-, Flow- und Hintergrundverhalten testest. Es geht nicht darum, möglichst viele Test-Helfer zu kennen. Es geht darum, Zeit, Dispatcher und externe Abhängigkeiten so zu kontrollieren, dass ein Test immer dieselbe Antwort liefert. Ein solcher Test ist deterministisch: Wenn der Code gleich bleibt, bleibt auch das Testergebnis gleich.

Im Android-Kontext ist das wichtig, weil moderne Apps stark asynchron arbeiten. Ein Repository lädt Daten aus einer API, schreibt sie in Room, stellt einen Flow bereit und wird vom ViewModel über viewModelScope verwendet. Compose beobachtet dann State und rendert die Oberfläche neu. Jeder Schritt kann zeitlich versetzt passieren. Wenn du in Tests echte Zeit abwartest, testest du nicht nur deinen Code, sondern auch Scheduler, Systemlast und Glück.

Das zentrale Werkzeug im Kotlin-Coroutines-Testumfeld ist runTest. Es startet einen Test in einer kontrollierten Coroutine-Umgebung. Verzögerungen wie delay(1_000) müssen dort nicht real eine Sekunde dauern, wenn sie über den Test-Scheduler laufen. Du kannst virtuelle Zeit weiterdrehen und ausstehende Aufgaben gezielt ausführen. Ergänzend helfen Fake-Clocks, wenn dein Produktionscode Uhrzeiten oder Zeitfenster auswertet, etwa bei Cache-Ablauf, Offline-Synchronisation oder Retry-Logik.

Das mentale Modell ist: In einem guten asynchronen Test besitzt der Test die Zeit. Dein Code darf asynchron bleiben, aber er darf nicht heimlich echte Zeit, echte Dispatcher oder echte Dienste erzwingen. Genau dadurch werden Tests schnell, stabil und aussagekräftig.

Wie funktioniert es?

runTest stellt einen TestScope bereit. In diesem Scope laufen Coroutines unter Kontrolle eines Test-Schedulers. Wenn dein Code delay nutzt und dabei einen Test-Dispatcher verwendet, kann der Test die virtuelle Uhr steuern. Wichtige Funktionen sind runCurrent, advanceTimeBy und advanceUntilIdle. Mit ihnen führst du geplante Arbeit aus, bewegst die virtuelle Zeit vorwärts oder wartest, bis keine geplanten Test-Coroutines mehr übrig sind.

Damit das funktioniert, darf dein Produktionscode Dispatcher nicht hart verdrahten. Ein Repository sollte nicht tief im Code Dispatchers.IO direkt verwenden, wenn du dieses Verhalten im Unit-Test kontrollieren willst. Besser ist es, Dispatcher über Konstruktorparameter oder eine kleine Dispatcher-Abstraktion zu injizieren. Dann kann die App im echten Betrieb Dispatchers.IO verwenden, während der Test einen StandardTestDispatcher oder UnconfinedTestDispatcher einsetzt.

StandardTestDispatcher ist oft die bessere Wahl, wenn du Reihenfolge und Scheduling bewusst prüfen möchtest. Er startet Aufgaben nicht sofort vollständig, sondern lässt dich mit dem Scheduler steuern, wann Arbeit weiterläuft. UnconfinedTestDispatcher kann für einfache Tests kompakter sein, weil er Arbeit schneller direkt ausführt. Für Lernende ist die wichtigste Regel: Verwende bewusst einen Test-Dispatcher und verstehe, ob dein Test gerade automatische Ausführung oder explizite Kontrolle erwartet.

Fake-Clocks lösen ein anderes, aber verwandtes Problem. Nicht jede Zeitlogik ist ein delay. Wenn dein Code prüft, ob ein Cache älter als zehn Minuten ist, reicht advanceTimeBy allein nicht, falls der Code direkt System.currentTimeMillis() oder Instant.now() aufruft. Dann brauchst du eine injizierbare Uhr. Im Test gibst du dieser Uhr einen festen Startwert und verschiebst ihn gezielt. So prüfst du fachliche Zeitregeln, ohne auf die echte Systemuhr zu vertrauen.

Bei Flow kommt hinzu, dass Werte über die Zeit emittiert werden. Ein Flow-Test sollte nicht raten, wann ein Wert kommt. Du sammelst Werte in einer Coroutine, stößt die auslösende Aktion an und führst dann die ausstehenden Tasks aus. Bei State-Flows aus ViewModels oder Repositorys prüfst du meist konkrete Zustände: erst Loading, dann Daten, bei Fehlern eine Fehlermeldung oder ein Fallback aus dem Cache. Besonders in Offline-First-Architekturen ist das wichtig, weil lokale Daten, Netzwerk-Refresh und Synchronisation zusammenwirken.

Eine Async Testing Strategy besteht deshalb aus drei Entscheidungen: Welche Dispatcher kontrolliert der Test? Welche Uhr verwendet der Code? Welche externen Quellen werden durch Fakes ersetzt? Wenn diese drei Punkte sauber sind, brauchst du keine Schlafpausen im Test.

In der Praxis

Stell dir ein Repository vor, das Artikel aus einem Cache liefert und nach zehn Minuten neu laden soll. In der echten App wäre die Uhr die Systemzeit, der Dispatcher könnte Dispatchers.IO sein und die Datenquelle eine API. Im Unit-Test willst du nur die Regel prüfen: Frische Daten kommen aus dem Cache, alte Daten werden neu geladen. Dafür injizierst du Dispatcher, Uhr und API-Fake.

interface Clock {
    fun nowMillis(): Long
}

class FakeClock(startMillis: Long) : Clock {
    var currentMillis: Long = startMillis

    override fun nowMillis(): Long = currentMillis

    fun advanceBy(millis: Long) {
        currentMillis += millis
    }
}

class ArticleRepository(
    private val api: ArticleApi,
    private val clock: Clock,
    private val ioDispatcher: CoroutineDispatcher,
) {
    private var cached: Article? = null
    private var cachedAtMillis: Long = 0L

    suspend fun article(): Article = withContext(ioDispatcher) {
        val ageMillis = clock.nowMillis() - cachedAtMillis
        val cacheIsFresh = cached != null && ageMillis < 10 * 60 * 1000

        if (cacheIsFresh) {
            cached!!
        } else {
            api.loadArticle().also { article ->
                cached = article
                cachedAtMillis = clock.nowMillis()
            }
        }
    }
}

Der Test kontrolliert jetzt sowohl Coroutine-Scheduling als auch fachliche Zeit. Er wartet nicht real zehn Minuten. Er verschiebt die Fake-Uhr und prüft das Ergebnis.

@OptIn(ExperimentalCoroutinesApi::class)
class ArticleRepositoryTest {

    private val testDispatcher = StandardTestDispatcher()

    @Test
    fun `old cache triggers reload`() = runTest(testDispatcher) {
        val api = FakeArticleApi(
            first = Article(id = "1", title = "Alt"),
            second = Article(id = "1", title = "Neu")
        )
        val clock = FakeClock(startMillis = 1_000L)
        val repository = ArticleRepository(
            api = api,
            clock = clock,
            ioDispatcher = testDispatcher
        )

        val first = repository.article()
        clock.advanceBy(11 * 60 * 1000)

        val second = repository.article()

        assertEquals("Alt", first.title)
        assertEquals("Neu", second.title)
        assertEquals(2, api.loadCount)
    }
}

Der Code zeigt eine wichtige Entscheidungsregel: Wenn du Zeitverhalten testest, unterscheide zwischen virtueller Coroutine-Zeit und fachlicher Uhrzeit. delay gehört dem Test-Scheduler. Cache-Zeitpunkte, Ablaufdaten und Timestamps gehören einer injizierten Clock. Vermischst du diese Ebenen, entstehen Tests, die zufällig stabil wirken, aber bei einer kleinen Änderung im Code brechen.

Eine typische Stolperfalle ist Thread.sleep im Test. Das wirkt anfangs bequem: Du startest eine Coroutine, wartest 200 Millisekunden und prüfst dann den Zustand. Dieser Test ist langsam und unsicher. Auf einem schnellen Rechner besteht er, in CI unter Last schlägt er fehl. Noch schlimmer: Er verdeckt Designprobleme. Wenn ein Test nur mit Schlafpause funktioniert, fehlt häufig Kontrolle über Dispatcher, Scope oder Uhr.

Eine zweite Stolperfalle ist hart codiertes Dispatchers.IO. Dann läuft ein Teil deiner Logik außerhalb der Testkontrolle. runTest kann nur Coroutines sauber steuern, die über den Test-Scheduler laufen oder korrekt angebunden sind. Darum ist Dispatcher-Injektion kein akademisches Muster, sondern eine praktische Testvoraussetzung.

Bei Flows prüfst du ähnlich kontrolliert. Du sammelst Werte in einer Test-Coroutine, löst Änderungen aus und verwendest advanceUntilIdle, damit geplante Arbeit abgeschlossen wird. Danach vergleichst du die beobachteten Zustände. Wichtig ist, die Sammel-Coroutine am Ende zu beenden, wenn der Flow dauerhaft aktiv ist, etwa bei StateFlow oder Datenbankbeobachtung. Sonst bleibt dein Test hängen oder meldet offene Coroutines.

Im Alltag taucht diese Strategie in Repository-Tests, ViewModel-Tests und Tests für Sync-Logik auf. Ein ViewModel ruft vielleicht load() auf, setzt einen Loading-State, sammelt einen Flow aus dem Repository und aktualisiert UI-State für Compose. Dein Test sollte dann nicht prüfen, ob nach 500 Millisekunden etwas passiert ist. Er sollte den Dispatcher steuern und die Zustandsfolge prüfen. Das passt gut zur Android-Architektur: UI reagiert auf State, Datenlogik sitzt in der Data Layer, und Tests prüfen die Verträge zwischen diesen Schichten.

Für Code-Reviews kannst du dir eine kurze Prüfliste merken: Gibt es Thread.sleep in Tests? Werden Dispatcher injiziert? Gibt es direkte Systemzeitaufrufe in fachlicher Logik? Sind externe Quellen durch Fakes ersetzt? Wird bei Flows klar beendet, was gesammelt wurde? Wenn du diese Fragen beantworten kannst, erkennst du viele instabile Async-Tests früh.

Fazit

Eine Async Testing Strategy macht asynchronen Android-Code überprüfbar, ohne echte Wartezeiten und ohne Timing-Risiko. Nutze runTest, TestDispatcher und Fake-Clocks, damit dein Test die Zeit kontrolliert und dein Produktionscode trotzdem sauber mit Coroutines, Flow und Architektur-Schichten arbeitet. Prüfe dein Verständnis praktisch: Ersetze in einem vorhandenen Test ein Thread.sleep durch runTest mit TestDispatcher, injiziere eine Clock für Zeitregeln und beobachte im Debugger, wann Coroutines wirklich ausgeführt werden. Danach sollte dein Test schneller laufen, klarer lesen und in CI genauso bestehen wie lokal.

Quellen (6)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.