Problem
Kotlin Flows sind aus der Android-Entwicklung nicht mehr wegzudenken. Mit ihrer Hilfe können Daten sequenziell über Datenströme gesendet werden. Diese Daten können dann im Code asynchron abgefragt werden. Natürlich müssen Flows auch getestet werden – leider ist das in Android nicht ganz so einfach.
Wenn man beim Implementieren der Tests nicht aufpasst, muss man mit Fehlern wie „This Job has not completed yet“ oder „UncompletedCoroutinesError“ kämpfen. Wer mit diesen Fehlern zu tun hatte, weiß, wie aufwändig eine Fehlersuche sein kann, um sie zu beheben. Die Tests enthalten außerdem viel Boilerplate-Code, der die Komplexität zusätzlich erhöht und die Lesbarkeit verschlechtert.
Lösung
Cashapp hat eine kleine Library für das Testen von Flows veröffentlicht: Turbine. Sie bietet zahlreiche Hilfsfunktionen, die das Testen von Flows deutlich bequemer machen.
Eine davon ist die test-Extension-Function. Sie startet eine neue Coroutine, ruft collect() auf und schickt die Daten an Turbine. Zum Schluss wird der Validation-Block aufgerufen.
Wenn der Validation-Block durchgelaufen ist, wird die Coroutine automatisch beendet. Innerhalb des Validation-Blocks können Methoden aufgerufen werden, um die Daten abzurufen und zu validieren.
Beispiel
Hier sind einige Beispiele – es werden jedoch noch viel mehr unterstützt:
- awaitItem(): Unterbricht die Coroutine und wartet, bis ein Item gesendet wurde
- awaitError(): Unterbricht die Coroutine und wartet darauf, dass eine Exception geschmissen wird
- skipItems(n: Int): Ignoriert n Items
- expectMostRecentItem(): Holt sich alle bisher gesendeten Items und gibt das neueste zurück
Das nachfolgende Beispiel zeigt einen Unit-Test einmal ohne (links) und einmal mit Turbine (rechts). Für das Mocking wurde die Library mockk verwendet.
interface Repository {
fun items(): SharedFlow<Int>
}
@Test
fun testFlowWithoutTurbine() = runTest {
val flow = MutableSharedFlow<Int>()
val repository = mockk<Repository>()
every { repository.items() }
returns flow
val dispatcher =
UnconfinedTestDispatcher(testScheduler)
val values = mutableListOf<Int>()
backgroundScope.launch(dispatcher) {
repository.items().toList(values)
}
flow.emit(1)
assertEquals(1, values[0])
flow.emit(2)
flow.emit(3)
assertEquals(3, values[2])
}
@Test
fun testFlowWithTurbine() = runTest {
val flow = MutableSharedFlow<Int>()
val repository = mockk<Repository>()
every { repository.items() }
returns flow
repository.items().test {
flow.emit(1)
assertEquals(1, awaitItem())
flow.emit(2)
skipItems(1)
flow.emit(3)
assertEquals(3, awaitItem())
}
}