MVI-Architekturmuster in Kotlin Multiplatform. Teil 3: Testen





Dieser Artikel ist der letzte Teil einer Reihe zur Anwendung des MVI-Architekturmusters in Kotlin Multiplatform. In den beiden vorherigen Teilen ( Teil 1 und Teil 2 ) haben wir uns daran erinnert, was MVI ist, ein generisches Kittens-Modul zum Laden von Katzenbildern erstellt und es in iOS- und Android-Anwendungen integriert.



In diesem Teil behandeln wir das Kittens-Modul mit Unit- und Integrationstests. Wir werden die aktuellen Einschränkungen beim Testen in Kotlin Multiplatform kennenlernen, herausfinden, wie sie überwunden werden können, und sie sogar zu unserem Vorteil einsetzen.



Ein aktualisiertes Beispielprojekt ist auf unserem GitHub verfügbar .



Prolog



Es besteht kein Zweifel, dass das Testen ein wichtiger Schritt in der Softwareentwicklung ist. Natürlich verlangsamt es den Prozess, aber gleichzeitig:



  • Mit dieser Option können Sie Kantenfälle überprüfen, die nur schwer manuell zu erfassen sind.

  • Reduziert die Wahrscheinlichkeit einer Regression beim Hinzufügen neuer Funktionen, Beheben von Fehlern und Refactoring.

  • zwingt Sie, Ihren Code zu zerlegen und zu strukturieren.



Auf den ersten Blick mag der letzte Punkt als Nachteil erscheinen, weil er Zeit braucht. Dies macht den Code jedoch auf lange Sicht lesbarer und vorteilhafter.



„In der Tat liegt das Verhältnis von Lese- und Schreibzeit weit über 10 zu 1. Wir lesen ständig alten Code, um neuen Code zu schreiben. ... [Deshalb] erleichtert das Lesen das Schreiben. " - Robert C. Martin, "Clean Code: Ein Handbuch für agile Software-Handwerkskunst"


Kotlin Multiplatform erweitert die Testfunktionen. Diese Technologie fügt eine wichtige Funktion hinzu: Jeder Test wird automatisch auf allen unterstützten Plattformen durchgeführt. Wenn beispielsweise nur Android und iOS unterstützt werden, kann die Anzahl der Tests mit zwei multipliziert werden. Und wenn irgendwann Unterstützung für eine andere Plattform hinzugefügt wird, wird diese automatisch in Tests behandelt. 



Das Testen auf allen unterstützten Plattformen ist wichtig, da es Unterschiede im Verhalten des Codes geben kann. Zum Beispiel hat Kotlin / Native ein spezielles Speichermodell , Kotlin / JS liefert manchmal auch unerwartete Ergebnisse.



Bevor wir weiter gehen, sollten einige der Testbeschränkungen in Kotlin Multiplatform erwähnt werden. Das größte Problem ist das Fehlen einer Spottbibliothek für Kotlin / Native und Kotlin / JS. Dies mag als großer Nachteil erscheinen, aber ich persönlich betrachte es als Vorteil. Das Testen in Kotlin Multiplatform war für mich ziemlich schwierig: Ich musste Schnittstellen für jede Abhängigkeit erstellen und ihre Testimplementierungen (Fälschungen) schreiben. Es hat lange gedauert, aber irgendwann wurde mir klar, dass das Verbringen von Zeit für Abstraktionen eine Investition ist, die zu saubererem Code führt. 



Mir ist auch aufgefallen, dass spätere Änderungen an diesem Code weniger Zeit in Anspruch nehmen. Warum so? Weil die Interaktion einer Klasse mit ihren Abhängigkeiten nicht genagelt (verspottet) wird. In den meisten Fällen reicht es aus, die Testimplementierungen einfach zu aktualisieren. Es ist nicht erforderlich, sich eingehend mit den einzelnen Testmethoden zu befassen, um die Mocks zu aktualisieren. Infolgedessen habe ich die Verwendung von Spottbibliotheken auch in der Standard-Android-Entwicklung eingestellt. Ich empfehle den folgenden Artikel zu lesen: " Verspotten ist nicht praktikabel - Fälschungen verwenden " von Pravin Sonawane .



Planen



Erinnern wir uns, was wir im Kittens-Modul haben und was wir testen sollten.



  • KittenStore ist die Hauptkomponente des Moduls. Die KittenStoreImpl- Implementierung enthält den größten Teil der Geschäftslogik. Dies ist das erste, was wir testen werden.

  • KittenComponent ist die Modulfassade und der Integrationspunkt für alle internen Komponenten. Wir werden diese Komponente mit Integrationstests abdecken.

  • KittenView ist eine öffentliche Schnittstelle, die die UI-Abhängigkeit von KittenComponent darstellt.

  • KittenDataSource ist eine interne Webzugriffsschnittstelle mit plattformspezifischen Implementierungen für iOS und Android.



Zum besseren Verständnis der Struktur des Moduls werde ich sein UML-Diagramm geben:







Der Plan lautet wie folgt:



  • KittenStore testen
    • Erstellen einer Testimplementierung von KittenStore.Parser

    • Erstellen einer Testimplementierung von KittenStore.Network

    • Unit-Tests für KittenStoreImpl schreiben



  • Testen der KittenComponent
    • Erstellen einer Testimplementierung von KittenDataSource

    • Erstellen Sie eine Test-KittenView-Implementierung

    • Schreiben von Integrationstests für KittenComponent



  • Ausführen von Tests

  • Schlussfolgerungen





KittenStore Unit Testing



Die KittenStore-Schnittstelle verfügt über eine eigene Implementierungsklasse - KittenStoreImpl. Das werden wir testen. Es hat zwei Abhängigkeiten (interne Schnittstellen), die direkt in der Klasse selbst definiert sind. Beginnen wir damit, Testimplementierungen für sie zu schreiben.



Testen Sie die Implementierung von KittenStore.Parser



Diese Komponente ist für Netzwerkanforderungen verantwortlich. So sieht die Benutzeroberfläche aus:



Schnittstelle Netzwerk {
fun load () : Vielleicht < String >
}}
Ansicht roh KittenStoreImpl.kt gehostet mit ❤ von GitHub


Bevor wir eine Testimplementierung einer Netzwerkschnittstelle schreiben, müssen wir eine wichtige Frage beantworten: Welche Daten gibt der Server zurück? Die Antwort ist, dass der Server einen zufälligen Satz von Bildverknüpfungen zurückgibt, jedes Mal einen anderen Satz. Im wirklichen Leben wird das JSON-Format verwendet, aber da wir eine Parser-Abstraktion haben, ist uns das Format in Unit-Tests egal.



Eine echte Implementierung kann Streams wechseln, sodass Abonnenten in Kotlin / Native eingefroren werden können . Es wäre großartig, dieses Verhalten zu modellieren, um sicherzustellen, dass der Code alles korrekt verarbeitet.



Daher sollte unsere Testimplementierung von Network die folgenden Funktionen aufweisen:



  • muss für jede Anforderung einen nicht leeren Satz verschiedener Zeilen zurückgeben;

  • Das Antwortformat muss für Netzwerk und Parser gleich sein.

  • sollte in der Lage sein, Netzwerkfehler zu simulieren (sollte möglicherweise ohne Antwort abgeschlossen werden);

  • Es muss möglich sein, ein ungültiges Antwortformat zu simulieren (um im Parser nach Fehlern zu suchen).

  • Es sollte möglich sein, Antwortverzögerungen zu simulieren (um die Startphase zu testen).

  • sollte in Kotlin / Native einfrierbar sein (nur für den Fall).



Die Testimplementierung selbst könnte folgendermaßen aussehen:



Klasse TestKittenStoreNetwork (
privater Val Scheduler : TestScheduler
) : KittenStoreImpl . Netzwerk {
var images: List<String>? by AtomicReference<List<String>?>(null)
private var seed: Int by AtomicInt()
override fun load(): Maybe<String> =
singleFromFunction { images }
.notNull()
.map { it.joinToString(separator = SEPARATOR) }
.observeOn(scheduler)
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
private const val SEPARATOR = ";"
}
}


TestKittenStoreNetwork verfügt über einen Zeichenfolgenspeicher (genau wie ein echter Server) und kann Zeichenfolgen generieren. Für jede Anforderung wird die aktuelle Liste der Zeilen in eine Zeile codiert. Wenn die Eigenschaft "images" Null ist, wird "Vielleicht" einfach beendet, was als Fehler angesehen werden sollte.



Wir haben auch TestScheduler verwendet . Dieser Scheduler hat eine wichtige Funktion: Er friert alle eingehenden Aufgaben ein. Somit friert der ObservOn-Operator, der in Verbindung mit dem TestScheduler verwendet wird, den Downstream-Stream sowie alle durch ihn fließenden Daten wie im realen Leben ein. Gleichzeitig wird Multithreading nicht erforderlich sein, was das Testen vereinfacht und zuverlässiger macht.



Darüber hinaus verfügt TestScheduler über einen speziellen "manuellen Verarbeitungsmodus", mit dem wir die Netzwerklatenz simulieren können.



Testen Sie die Implementierung von KittenStore.Parser



Diese Komponente ist für das Parsen der Antworten vom Server verantwortlich. Hier ist seine Schnittstelle:



Schnittstelle Parser {
Fun Parse ( json : String ) : Vielleicht < List < String >>
}}
Ansicht roh KittenStoreImpl.kt gehostet mit ❤ von GitHub


Was auch immer aus dem Web heruntergeladen wird, sollte in eine Liste von Links konvertiert werden. Unser Netzwerk verkettet Zeichenfolgen einfach mit einem Semikolon (;) - Trennzeichen. Verwenden Sie daher hier dasselbe Format.



Hier ist eine Testimplementierung:



class TestKittenStoreParser : KittenStoreImpl.Parser {
override fun parse(json: String): Maybe<List<String>> =
json
.toSingle()
.filter { it != "" }
.map { it.split(SEPARATOR) }
.observeOn(TestScheduler())
private companion object {
private const val SEPARATOR = ";"
}
}


Wie bei Network wird TestScheduler verwendet, um Abonnenten einzufrieren und ihre Kompatibilität mit dem Kotlin / Native-Speichermodell zu überprüfen. Antwortverarbeitungsfehler werden simuliert, wenn die Eingabezeichenfolge leer ist.



Unit-Tests für KittenStoreImpl



Wir haben jetzt Testimplementierungen aller Abhängigkeiten. Es ist Zeit für Unit-Tests. Alle Unit-Tests finden Sie im Repository , hier werde ich nur die Initialisierung und einige Tests selbst geben.



Der erste Schritt besteht darin, Instanzen unserer Testimplementierungen zu erstellen:



Klasse KittenStoreTest {
Privater val - Parser = TestKittenStoreParser ()
private val networkScheduler = TestScheduler ()
private val network = TestKittenStoreNetwork (networkScheduler)
privater Fun Store () : KittenStore = KittenStoreImpl (Netzwerk, Parser)
// ...
}}
Ansicht roh KittenStoreTest.kt gehostet mit ❤ von GitHub


KittenStoreImpl verwendet mainScheduler. Der nächste Schritt besteht darin, es zu überschreiben:



Klasse KittenStoreTest {
private val network = TestKittenStoreNetwork ()
private val parser = TestKittenStoreParser()
private fun store(): KittenStore = KittenStoreImpl(network, parser)
@BeforeTest
fun before() {
overrideSchedulers(main = { TestScheduler() })
}
@AfterTest
fun after() {
overrideSchedulers()
}
// ...
}
view raw KittenStoreTest.kt hosted with ❤ by GitHub


Jetzt können wir einige Tests durchführen. KittenStoreImpl sollte Bilder sofort nach der Erstellung laden. Dies bedeutet, dass eine Netzwerkanforderung abgeschlossen sein muss, ihre Antwort verarbeitet werden muss und der Status mit dem neuen Ergebnis aktualisiert werden muss.



@Prüfung
Spaß load_images_WHEN_created () {
val images = network.generateImages ()
val store = store ()
assertEquals ( State.Data.Images (urls = images), store.state. data )
}}
Ansicht roh KittenStoreTest.kt gehostet mit ❤ von GitHub


Was wir gemacht haben:



  • generierte Bilder im Netzwerk;

  • hat eine neue Instanz von KittenStoreImpl erstellt;

  • Stellen Sie sicher, dass der Status die richtige Liste von Zeichenfolgen enthält.



Ein weiteres Szenario, das wir berücksichtigen müssen, ist das Abrufen von KittenStore.Intent.Reload. In diesem Fall muss die Liste aus dem Netzwerk neu geladen werden.



@Prüfung
Spaß reloads_images_WHEN_Intent_Reload () {
network.generateImages ()
val store = store ()
val newImages = network.generateImages ()
store.onNext ( Intent.Reload )
assertEquals ( State.Data.Images (urls = newImages), store.state. data )
}}
Ansicht roh KittenStoreTest.kt gehostet mit ❤ von GitHub


Testschritte:



  • Quellbilder erzeugen;

  • Erstellen Sie eine Instanz von KittenStoreImpl.

  • neue Bilder erzeugen;

  • send Intent.Reload;

  • Stellen Sie sicher, dass die Bedingung neue Bilder enthält.



Schauen wir uns zum Schluss das folgende Szenario an: Wenn das isLoading-Flag gesetzt ist, während Bilder geladen werden.



@Prüfung
Spaß isLoading_true_WHEN_loading () {
networkScheduler.isManualProcessing = true
network.generateImages ()
val store = store ()
assertTrue (store.state.isLoading)
}}
Ansicht roh KittenStoreTest.kt gehostet mit ❤ von GitHub


Wir haben die manuelle Verarbeitung für TestScheduler aktiviert - jetzt werden Aufgaben nicht automatisch verarbeitet. Auf diese Weise können wir den Status überprüfen, während wir auf eine Antwort warten.



KittenComponent Integrationstest



Wie oben erwähnt, ist KittenComponent der Integrationspunkt des gesamten Moduls. Wir können es mit Integrationstests abdecken. Werfen wir einen Blick auf die API:



interne Klasse KittenComponent interner Konstruktor ( dataSource : KittenDataSource ) {
Konstruktor () : this ( KittenDataSource ())
Spaß onViewCreated ( Ansicht : KittenView ) { / * ... * / }
fun onStart () { / * ... * / }
Spaß onStop () { / * ... * / }
Spaß onViewDestroyed () { / * ... * / }
Spaß onDestroy () { / * ... * / }
}}


Es gibt zwei Abhängigkeiten, KittenDataSource und KittenView. Wir benötigen hierfür Testimplementierungen, bevor wir mit dem Testen beginnen können.



Der Vollständigkeit halber zeigt dieses Diagramm den Datenfluss innerhalb des Moduls:







Testen Sie die Implementierung von KittenDataSource



Diese Komponente ist für Netzwerkanforderungen verantwortlich. Es gibt separate Implementierungen für jede Plattform, und wir benötigen eine andere Implementierung für die Tests. So sieht die KittenDataSource-Oberfläche aus:



interne Schnittstelle KittenDataSource {
Spaß Last ( Limit : Int , Offset : Int ) : Vielleicht < String >
}}


TheCatAPI unterstützt die Paginierung, daher habe ich sofort die entsprechenden Argumente hinzugefügt. Ansonsten ist es KittenStore.Network sehr ähnlich, das wir zuvor implementiert haben. Der einzige Unterschied besteht darin, dass wir das JSON-Format verwenden müssen, wenn wir echten Code in der Integration testen. Wir leihen uns also nur die Implementierungsidee aus:



interne Klasse TestKittenDataSource (
privater Val Scheduler : TestScheduler
) : KittenDataSource {
private var images by AtomicReference<List<String>?>(null)
private var seed by AtomicInt()
override fun load(limit: Int, page: Int): Maybe<String> =
singleFromFunction { images }
.notNull()
.map {
val offset = page * limit
it.subList(fromIndex = offset, toIndex = offset + limit)
}
.mapIterable { it.toJsonObject() }
.map { JsonArray(it).toString() }
.onErrorComplete()
.observeOn(scheduler)
private fun String.toJsonObject(): JsonObject =
JsonObject(mapOf("url" to JsonPrimitive(this)))
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
}
}


Nach wie vor generieren wir verschiedene Listen von Zeichenfolgen, die bei jeder Anforderung in ein JSON-Array codiert werden. Wenn keine Bilder generiert werden oder die Anforderungsargumente falsch sind, wird Vielleicht nur ohne Antwort beendet.



Die Bibliothek kotlinx.serialization wird zum Bilden eines JSON-Arrays verwendet . Der getestete KittenStoreParser verwendet es übrigens zum Dekodieren.



Testimplementierung von KittenView



Dies ist die letzte Komponente, für die wir eine Testimplementierung benötigen, bevor wir mit dem Testen beginnen können. Hier ist seine Schnittstelle:



Schnittstelle KittenView : MviView < Modell , Ereignis > {
Daten Klasse Modell (
val isLoading : Boolean ,
val isError : Boolean ,
val imageUrls : List < String >
)
versiegelte Klasse Event {
Objekt RefreshTriggered : Event ()
}}
}}


Diese Ansicht nimmt nur Modelle auf und löst Ereignisse aus. Die Testimplementierung ist daher sehr einfach:



Klasse TestKittenView : AbstractMviView < Modell , Ereignis > (), KittenView {
lateinit var model : Modell
Spaß- Render überschreiben ( Modell : Modell ) {
dieses .model = Modell
}}
}}


Wir müssen uns nur das zuletzt akzeptierte Modell merken - so können wir die Richtigkeit des angezeigten Modells überprüfen. Wir können Ereignisse auch im Auftrag von KittenView mithilfe der Versandmethode (Ereignis) auslösen, die in der geerbten AbstractMviView-Klasse deklariert ist.



Integrationstests für KittenComponent



Die vollständigen Tests finden Sie im Repository . Hier werde ich nur einige der interessantesten nennen.



Beginnen wir wie zuvor damit, Abhängigkeiten zu instanziieren und zu initialisieren:



Klasse KittenComponentTest {
private val dataSourceScheduler = TestScheduler ()
private val dataSource = TestKittenDataSource (dataSourceScheduler)
private val view = TestKittenView ()
privater Spaß startComponent () : KittenComponent =
KittenComponent (dataSource). bewerben {
onViewCreated (Ansicht)
am Start ()
}}
// ...
}}


Derzeit werden zwei Scheduler für das Modul verwendet: mainScheduler und computationScheduler. Wir müssen sie überschreiben:



Klasse KittenComponentTest {
private val dataSourceScheduler = TestScheduler ()
private val dataSource = TestKittenDataSource (dataSourceScheduler)
private val view = TestKittenView ()
privater Spaß startComponent () : KittenComponent =
KittenComponent (dataSource). bewerben {
onViewCreated (Ansicht)
am Start ()
}}
// ...
@BeforeTest
Spaß vor () {
overrideSchedulers (main = { TestScheduler ()}, computation = { TestScheduler ()})
}}
@ AfterTest
Spaß nach () {
overrideSchedulers ()
}}
}}


Wir können jetzt einige Tests schreiben. Lassen Sie uns zuerst das Hauptskript überprüfen, um sicherzustellen, dass die Bilder beim Start geladen und angezeigt werden:



@Prüfung
Spaß lädt_und_shows_images_WHEN_created () {
val images = dataSource.generateImages ()
startComponent ()
assertEquals (images, view.model.imageUrls)
}}


Dieser Test ist dem sehr ähnlich, den wir geschrieben haben, als wir uns die Unit-Tests für KittenStore angesehen haben. Erst jetzt ist das gesamte Modul beteiligt.



Testschritte:



  • Generieren Sie Links zu Bildern in TestKittenDataSource.

  • KittenComponent erstellen und ausführen;

  • Stellen Sie sicher, dass die Links TestKittenView erreichen.



Ein weiteres interessantes Szenario: Bilder müssen neu geladen werden, wenn KittenView das RefreshTriggered-Ereignis auslöst.



@Prüfung
Spaß reloads_images_WHEN_Event_RefreshTriggered () {
dataSource.generateImages ()
startComponent ()
val newImages = dataSource.generateImages ()
view.dispatch ( Event.RefreshTriggered )
assertEquals (newImages, view.model.imageUrls)
}}


Stufen:



  • Quelllinks zu Bildern generieren;

  • KittenComponent erstellen und ausführen;

  • neue Links generieren;

  • send Event.RefreshTriggered im Auftrag von KittenView;

  • Stellen Sie sicher, dass neue Links TestKittenView erreichen.





Ausführen von Tests



Um alle Tests auszuführen, müssen wir die folgende Gradle-Aufgabe ausführen:



./gradlew :shared:kittens:build


Dadurch wird das Modul kompiliert und alle Tests auf allen unterstützten Plattformen ausgeführt: Android und iosx64.



Und hier ist der JaCoCo-Bericht:







Fazit



In diesem Artikel haben wir das Kittens-Modul mit Unit- und Integrationstests behandelt. Das vorgeschlagene Moduldesign ermöglichte es uns, die folgenden Teile abzudecken:



  • KittenStoreImpl - enthält den größten Teil der Geschäftslogik;

  • KittenStoreNetwork - verantwortlich für Netzwerkanforderungen auf hoher Ebene;

  • KittenStoreParser - verantwortlich für das Parsen von Netzwerkantworten;

  • alle Transformationen und Verbindungen.



Der letzte Punkt ist sehr wichtig. Eine Abdeckung ist dank der MVI-Funktion möglich Es liegt in der alleinigen Verantwortung der Ansicht, Daten und Versandereignisse anzuzeigen. Alle Abonnements, Konvertierungen und Links erfolgen innerhalb des Moduls. Somit können wir mit allgemeinen Tests alles außer dem Display selbst abdecken.



Solche Tests haben folgende Vorteile:



  • Verwenden Sie keine Plattform-APIs.

  • sehr schnell durchgeführt;

  • zuverlässig (nicht blinken);

  • auf allen unterstützten Plattformen ausführen.



Wir konnten den Code auch auf Kompatibilität mit dem komplexen Kotlin / Native-Speichermodell testen. Dies ist auch wegen der mangelnden Sicherheit zur Erstellungszeit sehr wichtig: Der Code stürzt nur zur Laufzeit mit Ausnahmen ab, die schwer zu debuggen sind.



Hoffe das hilft dir bei deinen Projekten. Vielen Dank für das Lesen meiner Artikel! Und vergiss nicht, mir auf Twitter zu folgen .



...





Bonusübung



Wenn Sie mit Testimplementierungen arbeiten oder mit MVI spielen möchten, finden Sie hier einige praktische Übungen.



Refactoring der KittenDataSource



Das Modul enthält zwei Implementierungen der KittenDataSource-Schnittstelle: eine für Android und eine für iOS. Ich habe bereits erwähnt, dass sie für den Netzwerkzugriff verantwortlich sind. Sie haben jedoch tatsächlich eine andere Funktion: Sie generieren die URL für die Anforderung basierend auf den Eingabeargumenten "limit" und "page". Gleichzeitig haben wir eine KittenStoreNetwork-Klasse, die nichts anderes tut, als den Aufruf an KittenDataSource zu delegieren.



Herausforderung: Verschieben Sie die Logik zur Generierung von URL-Anforderungen von KittenDataSourceImpl (unter Android und iOS) nach KittenStoreNetwork. Sie müssen die KittenDataSource-Oberfläche wie folgt ändern:







Sobald Sie dies getan haben, müssen Sie Ihre Tests aktualisieren. Die einzige Klasse, die Sie berühren müssen, ist TestKittenDataSource.



Seitenladen hinzufügen



TheCatAPI unterstützt die Paginierung, sodass wir diese Funktionalität für eine bessere Benutzererfahrung hinzufügen können. Sie können beginnen, indem Sie ein neues Event.EndReached-Ereignis für KittenView hinzufügen. Danach wird der Code nicht mehr kompiliert. Anschließend müssen Sie das entsprechende Intent.LoadMore hinzufügen, das neue Ereignis in einen Intent konvertieren und diesen in KittenStoreImpl verarbeiten. Sie müssen auch die KittenStoreImpl.Network-Schnittstelle wie folgt ändern:







Schließlich müssen Sie einige Testimplementierungen aktualisieren, einen oder zwei vorhandene Tests korrigieren und dann einige neue schreiben, um die Paginierung abzudecken.






All Articles