Testen von Kotlin Coroutines
Dies ist ein übersetztes Kapitel aus dem Buc Kotlin Coroutines. Wenn Sie mir helfen möchten, die Übersetzung zu verbessern, finden Sie die Quellen auf GitHub.
Das Testen von suspendierenden Funktionen unterscheidet sich in den meisten Fällen nicht vom Testen normaler Funktionen. Werfen Sie einen Blick auf die untenstehende fetchUserData
aus FetchUserUseCase
. Dank einiger Fake-Objekte1 (oder Mocks2) und einfachen Assert-Anweisungen kann überprüft werden, ob sie die Daten wie erwartet anzeigt.
Meine Methode zum Testen von Logik sollte nicht als Referenz verwendet werden. Es gibt viele widersprüchliche Vorstellungen davon, wie Tests aussehen sollten. Ich habe hier Fakes anstelle von Mocks verwendet, um keine externe Bibliothek einzuführen (ich persönlich bevorzuge sie auch). Ich habe auch versucht, alle Tests minimalistisch zu halten, um sie leichter lesbar zu machen.
Ähnlich verhält es sich in vielen anderen Fällen, wenn wir daran interessiert sind, was die "suspending function" tut, brauchen wir praktisch nichts anderes als runBlocking
und klassische Tools für Assertions. So sehen Unit Tests in vielen Projekten aus. Hier sind einige Unit Tests aus dem Backend der Kt. Academy:
IntegrationTests können auf die gleiche Weise implementiert werden. Wir verwenden einfach runBlocking
, und es gibt fast keinen Unterschied zwischen dem Testen, wie suspending und blocking Funktionen sich verhalten.
Testen von Zeitabhängigkeiten
Der Unterschied kommt ins Spiel, wenn wir beginnen wollen, Zeitabhängigkeiten zu testen. Denken Sie zum Beispiel an die folgenden Funktionen:
Beide Funktionen erzeugen das gleiche Ergebnis; der Unterschied besteht darin, dass die erste dies nacheinander tut, während die zweite dies gleichzeitig tut. Der Unterschied besteht darin, dass wenn das Abrufen des Profils und der Freunde jeweils 1 Sekunde dauert, würde die erste Funktion ungefähr 2 Sekunden benötigen, während die zweite nur 1 Sekunde benötigen würde. Wie würden Sie das testen?
Beachten Sie, dass der Unterschied nur auftritt, wenn die Ausführung von getProfile
und getFriends
tatsächlich Zeit in Anspruch nimmt. Wenn sie unmittelbar ausgeführt werden, sind beide Methoden zur Erzeugung des Benutzers nicht voneinander zu unterscheiden. Daher könnten wir uns selbst helfen, indem wir die Ausführung von simulierten Funktionen mit delay
verzögern, um ein Szenario mit verzögerter Datenladung nachzuahmen:
Jetzt wird der Unterschied in den Unit-Tests sichtbar: Der Aufruf von produceCurrentUserSeq
wird etwa 1 Sekunde dauern, und der Aufruf von produceCurrentUserSym
wird etwa 2 Sekunden dauern. Das Problem ist, dass wir nicht möchten, dass ein einzelner Unit-Test so viel Zeit in Anspruch nimmt. In unseren Projekten haben wir typischerweise Tausende von Unit-Tests, und wir möchten, dass alle so schnell wie möglich ausgeführt werden. Wie kann man beides unter einen Hut bekommen? Dafür müssen wir in simulierter Zeit arbeiten. Hier kommt die kotlinx-coroutines-test
Bibliothek mit ihrem StandardTestDispatcher
zur Rettung.
Dieses Kapitel stellt die kotlinx-coroutines-test Funktionen und Klassen vor, die in Version 1.6 eingeführt wurden. Wenn Sie eine ältere Version dieser Bibliothek verwenden, sollte es in den meisten Fällen ausreichen,
runBlockingTest
anstelle vonrunTest
,TestCoroutineDispatcher
anstelle vonStandardTestDispatcher
, undTestCoroutineScope
anstelle vonTestScope
zu verwenden. AuchadvanceTimeBy
in älteren Versionen ist wieadvanceTimeBy
undrunCurrent
in Versionen neuer als 1.6. Die detaillierten Unterschiede sind im Migrationsleitfaden unter https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md beschrieben.
TestCoroutineScheduler
und StandardTestDispatcher
Wenn wir delay
aufrufen, wird unsere Coroutine angehalten und nach einer festgelegten Zeit fortgesetzt. Dieses Verhalten kann dank TestCoroutineScheduler
aus kotlinx-coroutines-test
geändert werden, das delay
in virtueller Zeit arbeiten lässt, die vollständig simuliert ist und nicht von der realen Zeit abhängt.
TestCoroutineScheduler
sowieStandardTestDispatcher
,TestScope
undrunTest
sind immer noch experimentell.
Um TestCoroutineScheduler
bei Coroutinen zu verwenden, sollten wir einen Dispatcher verwenden, der ihn unterstützt. Die Standardoption ist StandardTestDispatcher
. Im Gegensatz zu den meisten Dispatchern wird er nicht nur dazu benutzt, zu bestimmen auf welchem Thread eine Koroutine laufen soll. Coroutinen, die mit einem solchen Dispatcher gestartet werden, werden nicht ausgeführt, bis wir die virtuelle Zeit weiterleiten. Die typischste Art, dies zu tun, ist die Verwendung von advanceUntilIdle
, welches die virtuelle Zeit weiterleitet und alle Operationen ausführt, die zu diesem Zeitpunkt aufgerufen werden würden, wenn es sich um die reale Zeit handeln würde.
StandardTestDispatcher
erstellt standardmäßig einen TestCoroutineScheduler
, daher müssen wir dies nicht ausdrücklich tun. Wir können darauf mit der Eigenschaft scheduler
zugreifen.
Es ist wichtig zu beachten, dass StandardTestDispatcher
die Zeit nicht selbstständig voranschreitet. Wir müssen dafür sorgen, dass die Zeit voranschreitet, ansonsten wird unsere coroutine nie wieder aufgenommen.
Eine andere Möglichkeit, die Zeit voranzuschreiten, ist advanceTimeBy
mit einer konkreten Anzahl von Millisekunden zu verwenden. Diese Funktion führt alle Operationen aus, die in der Zwischenzeit stattgefunden haben. Das bedeutet, wenn wir um 2 Millisekunden vorschieben, wird alles, was weniger als diese Zeit verzögert wurde, fortgesetzt. Um Operationen fortzusetzen, die genau zur zweiten Millisekunde wieder aufgenommen werden sollten, müssen wir zusätzlich die Funktion runCurrent
aufrufen.
Hier ist ein größeres Beispiel für die Verwendung von advanceTimeBy
zusammen mit runCurrent
.
Wie funktioniert das im Hintergrund? Wenn
delay
aufgerufen wird, prüft es, ob der Dispatcher (Klasse mit demContinuationInterceptor
Schlüssel) dasDelay
Interface (StandardTestDispatcher
tut das) implementiert. Für solche Dispatcher ruft es derenscheduleResumeAfterDelay
Funktion statt der vonDefaultDelay
, welche in Echtzeit wartet, auf.
Um zu sehen, dass die virtuelle Zeit wirklich unabhängig von der Echtzeit ist, siehst du im folgenden Beispiel. Das Hinzufügen von Thread.sleep
hat keinen Einfluss auf die Coroutine mit StandardTestDispatcher
. Beachte auch, dass der Aufruf von advanceUntilIdle
nur wenige Millisekunden dauert, also wartet er nicht auf Echtzeit. Er beschleunigt sofort die virtuelle Zeit und führt Coroutine-Operationen aus.
In den vorherigen Beispielen haben wir StandardTestDispatcher
verwendet und es in einen Scope eingewickelt. Stattdessen könnten wir TestScope
einsetzen, welches das Gleiche erreicht (und dabei alle Exceptions mit CoroutineExceptionHandler
sammelt). Das Besondere daran ist, dass wir in diesem Scope auch Funktionen wie advanceUntilIdle
, advanceTimeBy
oder die Eigenschaft currentTime
nutzen können, die alle an den Scheduler weitergeleitet werden, der in diesem Scope genutzt wird. Das ist sehr nützlich.
Wir werden später sehen, dass StandardTestDispatcher
oft direkt auf Android verwendet wird, um ViewModels, Presenters, Fragments usw. zu testen. Wir könnten es auch verwenden, um die Funktionen produceCurrentUserSeq
und produceCurrentUserSym
zu testen, indem wir sie in einer Coroutine starten, die Zeit vorrücken, bis sie im Leerlauf ist und überprüfen, wie viel simulierte Zeit sie in Anspruch genommen haben. Dies wäre jedoch ziemlich kompliziert; stattdessen sollten wir runTest
verwenden, das für solche Zwecke konzipiert ist.
runTest
runTest
ist die am häufigsten verwendete Funktion aus kotlinx-coroutines-test
. Es startet eine Coroutine mit TestScope
und rückt sie sofort bis zum Leerlauf vor. Innerhalb dieser Coroutine ist der Bereich vom Typ TestScope
, daher können wir currentTime
jederzeit überprüfen. So können wir nachvollziehen, wie die Zeit in unseren Coroutines verläuft, während unsere Tests nur Millisekunden in Anspruch nehmen.
Kehren wir zu unseren Funktionen zurück, wo wir Benutzerdaten sequenziell und parallel geladen haben. Mit runTest
ist es einfach, sie zu testen. Angenommen, unser simuliertes Repository benötigt 1 Sekunde für jeden Funktionsaufruf, sollte die sequenzielle Verarbeitung 2 Sekunden dauern, und die parallele Verarbeitung sollte nur 1 Sekunde dauern. Dank unserer Verwendung von virtueller Zeit, erfolgen unsere Tests sofort, und die Werte von currentTime
sind präzise.
Da es sich um einen wichtigen Anwendungsfall handelt, schauen wir uns ein vollständiges Beispiel für das Testen einer sequenziellen Funktion mit allen erforderlichen Klassen und Schnittstellen an:
runTest
enthält TestScope
, das StandardTestDispatcher
enthält, das TestCoroutineScheduler
enthält.Hintergrund Geltungsbereich
Die runTest
Funktion erstellt einen Geltungsbereich; wie alle solche Funktionen, wartet sie auf die Beendigung ihrer Unterprozesse. Das bedeutet, wenn Sie einen Prozess starten, der nie endet, wird Ihr Test nie enden.
Für solche Situationen bietet runTest
den backgroundScope
an. Dies ist ein Geltungsbereich, der ebenfalls auf virtueller Zeit arbeitet, jedoch wird runTest
nicht auf seine Fertigstellung warten. Aus diesem Grund wird der untenstehende Test problemlos bestanden. Wir nutzen den backgroundScope
, um alle Prozesse zu starten, für die unser Test nicht warten soll.
Testen der Abbruchbedingungen und Übertragung des Kontextes
Wenn Sie prüfen möchten, ob eine bestimmte Funktion die strukturierte parallele Ausführung berücksichtigt, ist der einfachste Weg, den Kontext von einer suspendierenden Funktion zu erfassen und dann zu überprüfen, ob dieser den erwarteten Wert enthält oder ob seine Aufgabe den entsprechenden Status hat. Als Beispiel nehmen wir die mapAsync
Funktion, die ich im Kapitel Rezepte näher erläutere.
Diese Funktion sollte Elemente asynchron mapeen, während ihre Reihenfolge beibehalten wird. Dieses Verhalten kann durch den folgenden Test überprüft werden:
Aber das ist noch nicht alles. Wir erwarten, dass eine richtig implementierte "suspending function" die Strukturierte Nebenläufigkeit respektiert.
Die einfachste Möglichkeit, dies zu überprüfen, besteht darin, einen Kontext wie CoroutineName
für die übergeordnete Coroutine anzugeben, und dann zu überprüfen, ob dieser in der transformation
Funktion immer noch derselbe ist.
Um den Kontext einer "suspending function" zu erfassen, können wir die Funktion currentCoroutineContext
oder die Eigenschaft coroutineContext
verwenden. In Lambda-Ausdrücken, die in "coroutine builders" oder "scope functions" verschachtelt sind, sollten wir die Funktion currentCoroutineContext
verwenden, da die Eigenschaft coroutineContext
aus CoroutineScope
Priorität über die Eigenschaft hat, die den aktuellen Coroutine-Kontext bereitstellt.
Der einfachste Weg, um eine Abbruch zu testen, besteht darin, die innere Funktion zu erfassen und ihren Abbruch nach dem Abbruch der äußeren Coroutine zu bestätigen.
Ich denke, solche Tests sind nicht in den meisten Anwendungen erforderlich, aber ich finde sie in Programmbibliotheken nützlich. Es ist nicht so offensichtlich, dass strukturierte Parallelität respektiert wird. Beide oben genannten Tests würden scheitern, wenn async
in einem äußeren Bereich gestartet würde.
UnconfinedTestDispatcher
Neben dem StandardTestDispatcher
haben wir auch UnconfinedTestDispatcher
. Der größte Unterschied besteht darin, dass der StandardTestDispatcher
keine Operationen auslöst, bis wir seinen Planer verwenden. Der UnconfinedTestDispatcher
führt unmittelbar alle Operationen vor der ersten Verzögerung auf laufenden Coroutinen aus, weshalb der untenstehende Code "C" anzeigt.
Die Funktion runTest
wurde in Version 1.6 von kotlinx-coroutines-test
implementiert. Zuvor haben wir runBlockingTest
benutzt, welches stark runTest
mit UnconfinedTestDispatcher
ähnelt. Wenn du also direkt von runBlockingTest
zu runTest
migrieren möchtest, könnten unsere Tests dann so aussehen:
Nutzung von Mocks
Die Anwendung von delay
in Fakes ist leicht, aber nicht sehr deutlich. Viele Entwickler bevorzugen es, delay
in der Testfunktion auszuführen. Eine Möglichkeit dies umzusetzen, ist die Nutzung von Mocks3:
Im obigen Beispiel wurde die MockK Bibliothek verwendet.
Testen von Funktionen, die den Dispatcher wechseln
Im Dispatchers Kapitel wurden typische Fälle vorgestellt, in denen konkrete Dispatcher festgelegt werden. Zum Beispiel verwenden wir Dispatcher.IO
(oder einen benutzerdefinierten Dispatcher) für blockierende Anrufe, oder Dispatchers.Default
für CPU-intensive Anrufe. Solche Funktionen müssen selten gleichzeitig ausgeführt werden, daher ist es normalerweise ausreichend, sie mit runBlocking
zu testen. Dieser Fall ist einfach und kaum zu unterscheiden vom Testen blockierender Funktionen. Betrachten Sie zum Beispiel die folgende Funktion:
Wir könnten das Verhalten solcher Funktionen mit Tests überprüfen, die mit runBlocking
versehen sind, aber wie wäre es, wenn wir kontrollieren, ob diese Funktionen den Dispatcher tatsächlich ändern? Dies kann ebenfalls erreicht werden, wenn wir die Funktionen, die wir aufrufen, simulieren und dabei den Namen des verwendeten Threads erfassen.
In der obigen Funktion konnte ich nicht Fakes benutzen, weil
CsvReader
eine Klasse ist, daher habe ich Mocks eingesetzt.
Bitte beachten Sie, dass
Dispatchers.Default
undDispatchers.IO
denselben Thread-Pool teilen.
In seltenen Fällen wollen wir jedoch Zeitabhängigkeiten in Funktionen testen, die den Dispatcher ändern. Dies ist ein kniffliger Fall, weil der neue Dispatcher unseren StandardTestDispatcher
ersetzt, sodass wir aufhören, in virtueller Zeit zu arbeiten. Um das deutlich zu machen, sollten wir die Funktion fetchUserData
mit withContext(Dispatchers.IO)
umgeben.
Nun werden all unsere zuvor implementierten Tests sich in Echtzeit aufhalten, und currentTime
wird weiterhin 0
zurückgeben. Der einfachste Weg, dies zu verhindern, besteht darin, den Dispatcher über einen Konstruktor zu injizieren und ihn in den Unit-Tests zu ersetzen.
Nun könnten wir in Unit-Tests anstelle von Dispatchers.IO
den StandardTestDispatcher
aus runTest
verwenden. Wir können ihn mit dem Schlüssel ContinuationInterceptor
aus dem coroutineContext
holen.
Eine weitere Möglichkeit besteht darin, ioDispatcher
als CoroutineContext
zu casten und in Unit-Tests durch EmptyCoroutineContext
zu ersetzen. Das letztendliche Verhalten bleibt gleich: Die Funktion wird den Dispatcher nie verändern.
Testen, was während der Funktionsausführung passiert
Denken Sie an eine Funktion, die während ihrer Ausführung zuerst einen Fortschrittsbalken anzeigt und diesen später ausblendet.
Wenn wir nur das Endergebnis überprüfen, können wir nicht bestätigen, dass der Fortschrittsbalken seinen Zustand während der Funktionsdurchführung geändert hat. Der hilfreiche Trick in solchen Fällen ist, diese Funktion in einer neuen Coroutine zu starten und die virtuelle Zeit von außerhalb zu steuern. Beachten Sie, dass runTest
eine Coroutine mit dem StandardTestDispatcher
Dispatcher erstellt und seine Zeit voranschreitet, bis sie untätig ist (unter Verwendung der Funktion advanceUntilIdle
). Das bedeutet, dass die Zeit der untergeordneten Prozesse beginnen wird, sobald das Hauptprogramm auf sie wartet, also sobald dieser abgeschlossen ist. Vorher können wir die virtuelle Zeit selbstständig vorrücken.
Beachten Sie, dass wir dank
runCurrent
genau überprüfen können, wann sich ein Wert ändert.
Ein ähnlicher Effekt könnte erzielt werden, wenn wir delay
verwenden würden. Das ist, als hätte man zwei unabhängige Prozesse: einer führt Aufgaben aus, während der andere prüft, ob der erste korrekt arbeitet.
Die Verwendung von expliziten Funktionen wie
advanceTimeBy
gilt als lesbarer, anstattdelay
zu nutzen.
Testen von Funktionen, die neue Coroutinen initiieren
Coroutinen müssen irgendwo beginnen. Im Backend werden sie häufig durch das Framework, welches wir nutzen (zum Beispiel Spring oder Ktor), gestartet. Es kann jedoch auch notwendig sein, dass wir selbst einen Scope erstellen und Coroutinen darauf ausführen.
Wie können wir sendNotifications
testen, wenn die Benachrichtigungen tatsächlich nebeneinander gesendet werden? Wiederum müssen wir in Unit-Tests StandardTestDispatcher
als Teil unseres Geltungsbereichs verwenden. Wir sollten Verzögerungen beim Aufrufen von send
und markAsSent
einfügen.
Beachten Sie, dass
runBlocking
im oben genannten Code nicht benötigt wird. SowohlsendNotifications
als auchadvanceUntilIdle
sind reguläre Funktionen.
Ersetzen des Haupt-Dispatchers
In Unit-Tests gibt es keine Hauptfunktion. Das bedeutet, wenn wir es zu benutzen versuchen, scheitern unsere Tests mit der Ausnahme "Modul mit dem Haupt-Dispatcher fehlt". Andererseits wäre es anspruchsvoll, den Haupt-Thread jedes Mal zu injizieren, daher bietet die "kotlinx-coroutines-test" Bibliothek stattdessen die setMain
Erweiterungsfunktion auf Dispatchers
an.
Wir definieren oft main in einer Setup-Funktion (Funktion mit @Before
oder @BeforeEach
) in einer Basisklasse, die von allen Unit-Tests erweitert wird. Als Ergebnis sind wir immer sicher, dass wir unsere Coroutinen auf Dispatchers.Main
ausführen können. Wir sollten auch den Haupt-Dispatcher mit Dispatchers.resetMain()
auf den ursprünglichen Zustand zurücksetzen.
Testen von Android-Funktionen, die Coroutinen starten
Auf Android starten wir typischerweise Coroutinen in ViewModels, Presentern, Fragmenten oder Aktivitäten. Dies sind sehr wichtige Klassen, und wir sollten sie testen. Denken Sie an die unten gezeigte MainViewModel
Implementierung:
Anstatt viewModelScope
könnte es unseren eigenen Gültigkeitsbereich geben und anstelle von ViewModel könnte es Presenter, Activity oder eine andere Klasse sein. Das ist für unser Beispiel egal. Wie in jeder Klasse, die Coroutinen startet, sollten wir StandardTestDispatcher
als Teil des Gültigkeitsbereichs verwenden. Früher mussten wir einen anderen Gültigkeitsbereich durch Dependency Injection injizieren, aber jetzt gibt es einen einfacheren Weg: Auf Android verwenden wir Dispatchers.Main
als den Standardmäßigen Dispatcher und wir können ihn dank der Funktion Dispatchers.setMain
durch StandardTestDispatcher
ersetzen:
Nachdem der Haupt-Dispatcher auf diese Weise eingestellt wurde, werden die onCreate
Coroutinen auf dem testDispatcher
laufen, so dass wir ihre Zeit steuern können. Wir können die Funktion advanceTimeBy
verwenden, um vorzutäuschen, dass eine bestimmte Zeit vergangen ist. Wir können auch advanceUntilIdle
verwenden, um alle Coroutinen auszuführen, bis sie abgeschlossen sind.
Einstellen eines Test-Dispatchers mit einem Regelwerk
JUnit 4 ermöglicht es uns, Regelwerke zu verwenden. Diese sind Klassen, die Logik enthalten, die bei bestimmten Testklassen-Lebenszyklusereignissen aufgerufen werden sollten. Ein Regelwerk kann beispielsweise definieren, was vor und nach allen Tests zu tun ist, daher kann es in unserem Fall verwendet werden, um unseren Test-Dispatcher einzustellen und ihn später aufzuräumen. Hier ist eine gute Implementierung eines solchen Regelwerks:
Diese Regel muss TestWatcher
erweitern, der Testlebenszyklusmethoden wie starting
und finished
bereitstellt, die wir überschreiben. Sie kombiniert TestCoroutineScheduler
und TestDispatcher
. Vor jedem Test in einer Klasse, die diese Regel verwendet, wird TestDispatcher
als Hauptdispatcher festgelegt. Nach jedem Test wird der Hauptdispatcher zurückgesetzt. Wir können über die scheduler
Eigenschaft dieser Regel auf den Scheduler zugreifen.
Wenn Sie
advanceUntilIdle
,advanceTimeBy
,runCurrent
undcurrentTime
direkt aufMainCoroutineRule
aufrufen möchten, können Sie diese als Erweiterungsfunktionen und -eigenschaften definieren.
Diese Art des Testens von Kotlin-Coroutinen ist auf Android weit verbreitet. Es wird sogar in den Codelabs-Materialien von Google erklärt (Advanced Android in Kotlin 05.3: Testing Coroutines and Jetpack integrations) (derzeit, für ältere kotlinx-coroutines-test
API).
Es ist ähnlich wie bei JUnit 5, wo wir eine Erweiterung definieren können:
Das Benutzen von MainCoroutineExtension
ist fast identisch mit dem Benutzen der MainCoroutineRule
Regel. Der Unterschied liegt darin, dass wir anstelle der @get:Rule
Annotation @JvmField
und @RegisterExtension
nutzen müssen.
Zusammenfassung
In diesem Kapitel haben wir die wichtigsten Anwendungsfälle für das Testen von Kotlin Coroutinen besprochen. Es gibt einige Tricks, die wir kennen müssen, aber am Ende können unsere Tests sehr elegant sein und alles kann recht einfach getestet werden. Ich hoffe, Sie fühlen sich inspiriert, gute Tests in Ihren Anwendungen mit Hilfe von Kotlin Coroutinen zu schreiben.
Ein Fake ist eine Klasse, die eine Schnittstelle implementiert, aber feste Daten und keine Logik enthält. Sie sind nützlich, um ein konkretes Verhalten zum Testen nachzuahmen.
Mocks sind universelle simulierte Objekte, die das Verhalten von echten Objekten auf kontrollierte Weise nachahmen. Wir erstellen sie in der Regel mit Bibliotheken, wie MockK, die das Mocken von suspendierenden Funktionen unterstützen. In den untenstehenden Beispielen habe ich mich dafür entschieden, Fakes zu verwenden, um eine externe Bibliothek zu vermeiden.
Nicht jeder mag Mocking. Einerseits haben Mocking-Bibliotheken viele mächtige Funktionen. Andererseits denken Sie an folgende Situation: Sie haben Tausende von Tests, und Sie ändern eine Schnittstelle eines Repositories, das von allen genutzt wird. Wenn Sie Fakes verwenden, reicht es normalerweise aus, nur einige wenige Klassen zu aktualisieren. Dies ist ein großes Problem, weshalb ich es in der Regel vorziehe, Fakes zu verwenden.