Flow Testing in Android mit Kotlin
Du prüfst Flow-Streams gezielt auf Reihenfolge, Ende und Fehler. So werden asynchrone Datenpfade in Android verlässlicher.
Flow Testing bedeutet, dass du einen Kotlin-Flow nicht nur „irgendwie“ ausführst, sondern sein Verhalten gezielt prüfst: Welche Werte werden gesendet, in welcher Reihenfolge kommen sie an, endet der Stream korrekt, und was passiert bei Fehlern? In echten Android-Apps ist das wichtig, weil ViewModels, Repositories und Use Cases häufig mit asynchronen Datenströmen arbeiten. Wenn du diese Streams sauber testest, findest du Probleme früher: falsche Ladezustände, doppelte Emissions, fehlende Fehlerzustände oder Flows, die nie beendet werden.
Was ist das?
Ein Flow ist in Kotlin ein asynchroner Datenstrom. Er kann keinen Wert, einen Wert oder viele Werte nacheinander ausgeben. Diese ausgegebenen Werte nennt man häufig Emissions. Ein Flow läuft normalerweise erst dann los, wenn ihn jemand sammelt. Diese sammelnde Seite ist der Collector. Genau dieses Zusammenspiel ist der Kern beim Flow Testing: Du musst nicht nur wissen, was ein Flow theoretisch enthält, sondern was ein Collector im Test tatsächlich beobachtet.
In Android begegnet dir Flow an vielen Stellen. Ein Repository kann einen Flow<List<Article>> liefern, ein DAO aus Room kann Datenbankänderungen als Flow anbieten, ein ViewModel kann einen StateFlow<UiState> für Compose bereitstellen, und ein Use Case kann Statuswerte wie Loading, Success und Error senden. Die UI sammelt diese Werte dann zum Beispiel mit collectAsStateWithLifecycle, während Tests meistens direkter sammeln.
Flow Testing prüft also zeitliches Verhalten. Das unterscheidet es von einem einfachen Unit-Test für eine pure Funktion. Bei einer Funktion gibst du Eingaben hinein und vergleichst eine Ausgabe. Bei einem Flow fragst du zusätzlich: Kommt zuerst der Ladezustand? Kommt danach das Ergebnis? Wird ein Fehler als Zustand gesendet oder als Exception geworfen? Bleibt der Flow offen oder ist er abgeschlossen? Diese Fragen sind für reale Android-Qualität relevant, weil asynchrone Fehler oft erst unter Last, bei langsamen Datenquellen oder bei schnellen UI-Wechseln sichtbar werden.
Für Lernende ist das wichtigste mentale Modell: Ein Flow ist eine Abfolge von Ereignissen, nicht nur ein Container. Dein Test ist ein kontrollierter Beobachter dieser Ereignisse. Du stellst eine Testumgebung bereit, startest einen Collector, wartest auf bestimmte Emissions, prüfst die Reihenfolge und beendest den Test kontrolliert. Wenn du diesen Ablauf verstehst, wirken auch Tools wie Turbine deutlich nachvollziehbarer.
Wie funktioniert es?
Ein Flow-Test besteht meistens aus vier Schritten. Zuerst baust du eine kontrollierte Datenquelle auf, etwa ein Fake-Repository oder einen Fake-Service. Danach rufst du die Funktion auf, die den Flow liefert. Anschließend sammelst du den Flow im Test. Zum Schluss prüfst du die empfangenen Werte, Completion oder Fehler.
Der Collector ist dabei nicht nebensächlich. Ohne Collector startet ein normaler Cold Flow nicht. Wenn du also nur val flow = repository.observeItems() schreibst, ist noch nichts passiert. Erst collect, first, toList oder ein Turbine-Testblock startet die Ausführung. Das ist eine häufige Quelle für Missverständnisse: Der Code sieht aktiv aus, aber der Flow wurde noch gar nicht ausgeführt.
Für sehr einfache Flows reichen Standardoperatoren. Wenn ein Flow genau einen Wert liefern soll, kannst du first() verwenden. Wenn ein Flow endlich ist und mehrere Werte sendet, kann toList() passen. Sobald du aber Reihenfolge, Zeitpunkte, Zwischenzustände oder Fehler kontrolliert prüfen willst, wird der Test mit einfachen Collectors schnell unübersichtlich. Dann ist Turbine praktisch. Turbine ist eine Testbibliothek für Kotlin Flow, mit der du Emissions nacheinander mit awaitItem() abfragst, Completion mit awaitComplete() prüfst und Fehler mit awaitError() erwartest.
Wichtig ist auch der Testkontext. Android-Code nutzt Coroutines, Dispatcher und oft ViewModel-Scopes. In Tests solltest du vermeiden, echte UI- oder IO-Dispatcher direkt zu benötigen. Stattdessen arbeitest du mit Coroutine-Testwerkzeugen wie runTest und injizierst Dispatcher dort, wo dein Produktionscode sie braucht. Das macht Tests deterministischer. Ein deterministischer Test hängt nicht zufällig davon ab, wie schnell dein Rechner gerade ist.
Beim Testen von Flow gibt es mehrere typische Prüfziele:
Du prüfst Emissions, wenn du wissen willst, welche Werte gesendet werden. Beispiel: Ein ViewModel sendet zuerst Loading, danach Content.
Du prüfst Reihenfolge, wenn mehrere Zustände fachlich aufeinander aufbauen. Beispiel: Ein Fehler darf nicht vor dem Ladezustand erscheinen, wenn die UI diesen Ablauf erwartet.
Du prüfst Completion, wenn ein Flow bewusst endlich ist. Beispiel: Ein Use Case sendet drei Fortschrittswerte und endet danach.
Du prüfst Fehlerfälle, wenn ein Flow eine Exception weitergibt oder einen Fehlerzustand sendet. Beides sind unterschiedliche Designs. Ein Test sollte klar zeigen, welche Variante du erwartest.
Bei Android-Architektur ist Flow Testing besonders nützlich an Grenzen zwischen Schichten. Im Repository testest du, ob Datenquellen korrekt in Domain-Modelle übersetzt werden. Im Use Case testest du fachliche Reihenfolgen. Im ViewModel testest du, ob aus einem Flow ein stabiler UI-State entsteht. Für Compose selbst testest du normalerweise nicht jeden Flow-Operator direkt in der UI. Du testest lieber die Logik im ViewModel und prüfst die UI separat mit Compose-Tests, wenn Darstellung oder Interaktion relevant sind.
In der Praxis
Nimm an, ein Use Case lädt Profilinformationen und sendet UI-nahe Zustände. Der Flow soll zuerst Loading senden. Danach kommt entweder Content oder Error. Dieses Verhalten ist klein genug für einen fokussierten Test, aber realistisch genug für Android-Alltag.
sealed interface ProfileState {
data object Loading : ProfileState
data class Content(val name: String) : ProfileState
data class Error(val message: String) : ProfileState
}
class ProfileRepository(
private val service: ProfileService
) {
fun observeProfile(): Flow<ProfileState> = flow {
emit(ProfileState.Loading)
val profile = service.loadProfile()
emit(ProfileState.Content(profile.name))
}.catch { throwable ->
emit(ProfileState.Error(throwable.message ?: "Unbekannter Fehler"))
}
}
interface ProfileService {
suspend fun loadProfile(): Profile
}
data class Profile(val name: String)
Ein Test mit Turbine kann die Reihenfolge direkt ausdrücken:
@Test
fun `observeProfile sends loading then content`() = runTest {
val service = object : ProfileService {
override suspend fun loadProfile(): Profile = Profile(name = "Mina")
}
val repository = ProfileRepository(service)
repository.observeProfile().test {
assertEquals(ProfileState.Loading, awaitItem())
assertEquals(ProfileState.Content("Mina"), awaitItem())
awaitComplete()
}
}
Dieser Test prüft drei Dinge. Erstens startet der Flow mit Loading. Zweitens folgt der erwartete Inhalt. Drittens ist der Flow danach beendet. Ohne awaitComplete() könntest du übersehen, dass der Flow offen bleibt oder später noch unerwartete Werte sendet. Genau solche Details sind bei Flow Testing relevant, weil ein Datenstrom mehr Verhalten hat als nur seinen letzten Wert.
Der Fehlerfall sollte separat getestet werden:
@Test
fun `observeProfile sends error when service fails`() = runTest {
val service = object : ProfileService {
override suspend fun loadProfile(): Profile {
error("Netzwerk nicht erreichbar")
}
}
val repository = ProfileRepository(service)
repository.observeProfile().test {
assertEquals(ProfileState.Loading, awaitItem())
assertEquals(ProfileState.Error("Netzwerk nicht erreichbar"), awaitItem())
awaitComplete()
}
}
Hier entscheidest du bewusst, dass der Flow keinen Fehler nach außen wirft, sondern einen Fehlerzustand sendet. Das ist ein Design, das gut zu UI-State passt. Eine Compose-Oberfläche kann Loading, Content und Error anzeigen, ohne dass die UI-Schicht Exceptions behandeln muss. In anderen Fällen, etwa bei internen Datenpipelines, kann es sinnvoll sein, Exceptions weiterzureichen. Dann würdest du mit Turbine eher awaitError() prüfen.
Eine wichtige Entscheidungsregel lautet: Teste genau die Vertragsebene, die der Flow verspricht. Wenn ein Repository dokumentiert oder durch seine Signatur zeigt, dass es UI-Zustände sendet, prüfst du diese Zustände. Wenn ein niedrigerer Datenstrom technische Fehler weitergeben soll, prüfst du Exceptions. Vermische diese Erwartungen nicht im selben Test, sonst wird unklar, ob du Verhalten oder Implementierungsdetails testest.
Eine typische Stolperfalle ist das Testen von StateFlow. Ein StateFlow hat immer einen aktuellen Wert und sendet diesen sofort an neue Collectors. Wenn du im Test nicht daran denkst, interpretierst du den Initialzustand schnell als „unerwartete“ Emission. Beispiel: Ein ViewModel hat initial Loading, später Content. Dein Turbine-Test muss den Initialwert entweder bewusst prüfen oder überspringen. Das sollte keine zufällige Entscheidung sein. Wenn der Initialwert Teil des UI-Vertrags ist, prüfe ihn. Wenn er für den konkreten Test nicht relevant ist, überspringe ihn klar und absichtlich.
Eine zweite Stolperfalle sind Tests, die nie enden. Viele Android-Flows sind dauerhaft aktiv, zum Beispiel Datenbankbeobachtungen oder UI-State-Flows. Bei solchen Streams ist awaitComplete() falsch, weil keine Completion erwartet wird. Stattdessen prüfst du die relevanten Emissions und beendest den Turbine-Block mit cancelAndIgnoreRemainingEvents() oder beendest den Collector kontrolliert. Ein Test darf nicht darauf warten, dass ein endloser Stream fertig wird.
Eine dritte Stolperfalle ist zu viel Zeitlogik im Test. Wenn dein Flow delay, Debounce oder Dispatcher-Wechsel verwendet, solltest du mit Coroutine-Testwerkzeugen arbeiten und virtuelle Testzeit nutzen, statt echte Wartezeiten einzubauen. Echte Wartezeiten machen Tests langsam und instabil. In Android-Projekten ist das besonders unangenehm, weil Test-Suites mit der Zeit wachsen und instabile Tests Vertrauen kosten.
Im Code-Review kannst du bei Flow-Tests auf konkrete Fragen achten. Wird der Flow wirklich gesammelt? Werden alle erwarteten Emissions geprüft? Ist die Reihenfolge relevant und wird sie sichtbar getestet? Wird Completion nur dort erwartet, wo der Stream wirklich endlich ist? Ist klar, ob ein Fehler als Wert oder als Exception behandelt wird? Diese Fragen helfen dir, Tests von bloßen Ausführungsproben zu echten Verhaltensprüfungen zu machen.
Für deinen Lernweg ist außerdem hilfreich, kleine Experimente zu bauen. Schreibe einen Flow, der drei Zahlen sendet, und teste mit Turbine die Reihenfolge. Ergänze danach eine Exception und prüfe, ob sie als awaitError() sichtbar wird. Baue anschließend eine catch-Stufe ein und beobachte, wie aus der Exception eine normale Emission wird. Dadurch verstehst du nicht nur die API, sondern auch den Unterschied zwischen Fehler als Ereignis und Fehler als Zustand.
Fazit
Flow Testing ist ein präzises Werkzeug, um asynchrone Android-Logik verlässlich zu prüfen: Du beobachtest Emissions, kontrollierst Collectors, prüfst Reihenfolge, Completion und Fehlerfälle. Übe das an kleinen Flows, bevor du komplexe ViewModels testest. Setze im Debugger Breakpoints in flow, catch und im Test-Collector, damit du siehst, wann der Flow wirklich startet. Prüfe anschließend in Code-Reviews, ob deine Tests den fachlichen Vertrag des Streams beschreiben und nicht nur zufällig aktuelle Implementierungsdetails nachbauen.