Unit-Tests in Flutter: Von Workflow-Essentials zu komplexen Szenarien
Veröffentlicht: 2022-09-21Das Interesse an Flutter ist auf einem Allzeithoch – und es ist längst überfällig. Das Open-Source-SDK von Google ist mit Android, iOS, macOS, Web, Windows und Linux kompatibel. Eine einzige Flutter-Codebasis unterstützt sie alle. Und Unit-Tests sind entscheidend für die Bereitstellung einer konsistenten und zuverlässigen Flutter-App, die vor Fehlern, Fehlern und Defekten schützt, indem sie die Qualität des Codes präventiv verbessert, bevor er zusammengestellt wird.
In diesem Tutorial teilen wir Workflow-Optimierungen für Flutter-Unit-Tests, demonstrieren einen einfachen Flutter-Unit-Test und fahren dann mit komplexeren Flutter-Testfällen und -Bibliotheken fort.
Der Ablauf des Unit-Tests in Flutter
Wir implementieren Unit-Tests in Flutter auf die gleiche Weise wie in anderen Technologie-Stacks:
- Werten Sie den Code aus.
- Richten Sie Data Mocking ein.
- Definieren Sie die Testgruppe(n).
- Definieren Sie Testfunktionssignatur(en) für jede Testgruppe.
- Schreiben Sie die Tests.
Um Unit-Tests zu demonstrieren, habe ich ein Flutter-Beispielprojekt vorbereitet und ermutige Sie, den Code nach Belieben zu verwenden und zu testen. Das Projekt verwendet eine externe API, um eine Liste von Universitäten abzurufen und anzuzeigen, die wir nach Ländern filtern können.
Ein paar Anmerkungen zur Funktionsweise von Flutter: Das Framework erleichtert das Testen, indem es die flutter_test
Bibliothek automatisch lädt, wenn ein Projekt erstellt wird. Die Bibliothek ermöglicht es Flutter, Komponententests zu lesen, auszuführen und zu analysieren. Flutter erstellt auch automatisch den Testordner, in dem test
gespeichert werden. Es ist wichtig, das Umbenennen und/oder Verschieben des test
zu vermeiden, da dies seine Funktionalität und damit unsere Fähigkeit, Tests auszuführen, beeinträchtigt. Es ist auch wichtig, _test.dart
in unsere Testdateinamen aufzunehmen, da Flutter Testdateien an diesem Suffix erkennt.
Verzeichnisstruktur testen
Um Unit-Tests in unserem Projekt zu fördern, haben wir MVVM mit sauberer Architektur und Dependency Injection (DI) implementiert, wie in den für Quellcode-Unterordner gewählten Namen deutlich wird. Die Kombination von MVVM- und DI-Prinzipien sorgt für eine Trennung der Anliegen:
- Jede Projektklasse unterstützt ein einzelnes Ziel.
- Jede Funktion innerhalb einer Klasse erfüllt nur ihren eigenen Gültigkeitsbereich.
Wir erstellen einen organisierten Speicherplatz für die von uns geschriebenen Testdateien, ein System, in dem Gruppen von Tests leicht identifizierbare „Zuhause“ haben. Angesichts der Anforderung von Flutter, Tests im test
zu lokalisieren, spiegeln wir die Ordnerstruktur unseres Quellcodes unter test
. Wenn wir dann einen Test schreiben, speichern wir ihn im entsprechenden Unterordner: So wie saubere Socken in die Sockenschublade Ihrer Kommode und gefaltete Hemden in die Hemdenschublade kommen, landen Unit-Tests von Model
Klassen in einem Ordner namens model
, zum Beispiel.
Die Übernahme dieses Dateisystems schafft Transparenz in das Projekt und bietet dem Team eine einfache Möglichkeit, zu sehen, welche Teile unseres Codes mit zugehörigen Tests verbunden sind.
Wir sind jetzt bereit, Unit-Tests in die Tat umzusetzen.
Ein einfacher Flutter-Einheitentest
Wir beginnen mit den model
(in der data
des Quellcodes) und beschränken unser Beispiel auf nur eine model
, ApiUniversityModel
. Diese Klasse hat zwei Funktionen:
- Initialisieren Sie unser Modell, indem Sie das JSON-Objekt mit einer
Map
. - Erstellen Sie das Datenmodell der
University
.
Um die einzelnen Funktionen des Modells zu testen, passen wir die zuvor beschriebenen universellen Schritte an:
- Werten Sie den Code aus.
- Data Mocking einrichten: Wir definieren die Serverantwort auf unseren API-Aufruf.
- Definieren Sie die Testgruppen: Wir haben zwei Testgruppen, eine für jede Funktion.
- Definieren Sie Testfunktionssignaturen für jede Testgruppe.
- Schreiben Sie die Tests.
Nachdem wir unseren Code ausgewertet haben, sind wir bereit, unser zweites Ziel zu erreichen: das Einrichten von Data Mocking, das für die beiden Funktionen innerhalb der ApiUniversityModel
-Klasse spezifisch ist.
Um die erste Funktion zu verspotten (Initialisieren unseres Modells durch Verspotten des JSON mit einer Map
), erstellen wir fromJson
zwei Map
-Objekte, um die Eingabedaten für die Funktion zu simulieren. Außerdem erstellen wir zwei äquivalente ApiUniversityModel
Objekte, um das erwartete Ergebnis der Funktion mit der bereitgestellten Eingabe darzustellen.
Um die zweite Funktion (Erstellen des Datenmodells der University
) zu simulieren, toDomain
, erstellen wir zwei University
-Objekte, die das erwartete Ergebnis sind, nachdem diese Funktion in den zuvor instanziierten ApiUniversityModel
Objekten ausgeführt wurde:
void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }
Als nächstes fügen wir für unser drittes und viertes Ziel eine beschreibende Sprache hinzu, um unsere Testgruppen und Testfunktionssignaturen zu definieren:
void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }
Wir haben die Signaturen von zwei Tests definiert, um die fromJson
Funktion zu überprüfen, und zwei, um die toDomain
-Funktion zu überprüfen.
Um unser fünftes Ziel zu erreichen und die Tests zu schreiben, verwenden wir die Expect-Methode der expect
-Bibliothek, um die Ergebnisse der Funktionen mit unseren Erwartungen zu vergleichen:
void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }
Nachdem wir unsere fünf Ziele erreicht haben, können wir die Tests nun entweder über die IDE oder über die Befehlszeile ausführen.
An einem Terminal können wir alle im test
enthaltenen Tests ausführen, indem wir den flutter test
und sehen, ob unsere Tests bestanden werden.
Alternativ könnten wir einen einzelnen Test oder eine Testgruppe ausführen, indem wir den Befehl flutter test --plain-name "ReplaceWithName"
durch den Namen unseres Tests oder unserer Testgruppe ReplaceWithName
.
Komponententest eines Endpunkts in Flutter
Lassen Sie uns nach Abschluss eines einfachen Tests ohne Abhängigkeiten ein interessanteres Beispiel untersuchen: Wir testen die endpoint
, deren Geltungsbereich Folgendes umfasst:
- Ausführen eines API-Aufrufs an den Server.
- Umwandeln der API-JSON-Antwort in ein anderes Format.
Nachdem wir unseren Code ausgewertet haben, verwenden wir die setUp
-Methode der flutter_test-Bibliothek, um die Klassen in unserer Testgruppe zu initialisieren:
group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }
Um Netzwerkanfragen an APIs zu stellen, bevorzuge ich die Verwendung der Retrofit-Bibliothek, die den größten Teil des erforderlichen Codes generiert. Um die UniversityEndpoint
-Klasse richtig zu testen, zwingen wir die dio-Bibliothek – die Retrofit
zum Ausführen von API-Aufrufen verwendet –, das gewünschte Ergebnis zurückzugeben, indem wir das Verhalten der Dio
-Klasse über einen benutzerdefinierten Antwortadapter nachahmen.
Benutzerdefinierter Netzwerk-Interceptor-Mock
Mocking ist möglich, da wir die UniversityEndpoint
-Klasse über DI erstellt haben. (Wenn die UniversityEndpoint
-Klasse selbst eine Dio
-Klasse initialisieren würde, gäbe es für uns keine Möglichkeit, das Verhalten der Klasse zu simulieren.)
Um das Verhalten der Dio
-Klasse zu simulieren, müssen wir die in der Retrofit
-Bibliothek verwendeten Dio
-Methoden kennen – aber wir haben keinen direkten Zugriff auf Dio
. Daher verspotten wir Dio
mit einem benutzerdefinierten Netzwerkantwort-Interceptor:
class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }
Nachdem wir nun den Interceptor erstellt haben, um unsere Netzwerkantworten zu simulieren, können wir unsere Testgruppen und Testfunktionssignaturen definieren.
In unserem Fall müssen wir nur eine Funktion testen ( getUniversitiesByCountry
), also erstellen wir nur eine Testgruppe. Wir testen die Reaktion unserer Funktion auf drei Situationen:
- Wird die Funktion der
Dio
-Klasse tatsächlich vongetUniversitiesByCountry
? - Was passiert, wenn unsere API-Anfrage einen Fehler zurückgibt?
- Was passiert, wenn unsere API-Anfrage das erwartete Ergebnis zurückgibt?
Hier sind unsere Testgruppen- und Testfunktionssignaturen:
group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });
Wir sind bereit, unsere Tests zu schreiben. Für jeden Testfall erstellen wir eine Instanz von DioMockResponsesAdapter
mit der entsprechenden Konfiguration:
group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });
Nachdem unsere Endpunkttests abgeschlossen sind, testen wir unsere Datenquellenklasse UniversityRemoteDataSource
. Zuvor haben wir festgestellt, dass die UniversityEndpoint
-Klasse Teil des Konstruktors UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint})
ist, was darauf hinweist, dass UniversityRemoteDataSource
die UniversityEndpoint
-Klasse verwendet, um ihren Geltungsbereich zu erfüllen.
Spott mit Mockito
In unserem vorherigen Beispiel haben wir den Anforderungsadapter unseres Dio
-Clients mit einem benutzerdefinierten NetworkInterceptor
manuell verspottet. Hier verspotten wir eine ganze Klasse. Dies manuell zu tun – eine Klasse und ihre Funktionen zu verspotten – wäre zeitaufwändig. Glücklicherweise sind Scheinbibliotheken darauf ausgelegt, solche Situationen zu bewältigen, und können Scheinklassen mit minimalem Aufwand generieren. Lassen Sie uns die Mockito-Bibliothek verwenden, die Industriestandardbibliothek zum Spotten in Flutter.
Um Mockito
zu simulieren, fügen wir zuerst die Anmerkung „ @GenerateMocks([class_1,class_2,…])
“ vor dem Code des Tests hinzu – direkt über der void main() {}
Funktion. In die Anmerkung fügen wir eine Liste von Klassennamen als Parameter ein (anstelle von class_1,class_2…
).
Als Nächstes führen wir den flutter pub run build_runner build
von Flutter aus, der den Code für unsere Mock-Klassen im selben Verzeichnis wie der Test generiert. Der Name der resultierenden Scheindatei ist eine Kombination aus dem Namen der Testdatei und .mocks.dart
, wodurch das Suffix .dart des Tests .dart
wird. Der Inhalt der Datei enthält Scheinklassen, deren Namen mit dem Präfix Mock
beginnen. Beispielsweise wird UniversityEndpoint
zu MockUniversityEndpoint
.
Jetzt importieren wir university_remote_data_source_test.dart.mocks.dart
(unsere Scheindatei) in university_remote_data_source_test.dart
(die Testdatei).
Dann verspotten wir in der setUp
Funktion UniversityEndpoint
, indem wir MockUniversityEndpoint
verwenden und die UniversityRemoteDataSource
-Klasse initialisieren:
import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }
Wir haben UniversityEndpoint
erfolgreich verspottet und dann unsere UniversityRemoteDataSource
-Klasse initialisiert. Jetzt können wir unsere Testgruppen und Testfunktionssignaturen definieren:
group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });
Damit sind unsere Mocking-, Testgruppen- und Testfunktionssignaturen eingerichtet. Wir sind bereit, die eigentlichen Tests zu schreiben.
Unser erster Test überprüft, ob die UniversityEndpoint
-Funktion aufgerufen wird, wenn die Datenquelle das Abrufen von Länderinformationen initiiert. Wir beginnen damit, zu definieren, wie jede Klasse reagiert, wenn ihre Funktionen aufgerufen werden. Da wir die UniversityEndpoint
-Klasse verspottet haben, werden wir mit dieser Klasse arbeiten, indem wir die when( function_that_will_be_called ).then( what_will_be_returned )
verwenden.
Die Funktionen, die wir testen, sind asynchron (Funktionen, die ein Future
-Objekt zurückgeben), daher verwenden wir die when(function name).thenanswer( (_) {modified function result} )
, um unsere Ergebnisse zu ändern.
Um zu überprüfen, ob die getUniversitiesByCountry
Funktion die getUniversitiesByCountry
Funktion innerhalb der UniversityEndpoint
-Klasse aufruft, verwenden wir when(...).thenAnswer( (_) {...} )
, um die getUniversitiesByCountry
Funktion innerhalb der UniversityEndpoint
-Klasse zu simulieren:
when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));
Nachdem wir unsere Antwort verspottet haben, rufen wir die Datenquellenfunktion auf und prüfen – mit der verify
-Funktion – ob die UniversityEndpoint
-Funktion aufgerufen wurde:
test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });
Wir können die gleichen Prinzipien verwenden, um zusätzliche Tests zu schreiben, die prüfen, ob unsere Funktion unsere Endpunktergebnisse korrekt in die relevanten Datenströme umwandelt:
import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }
Wir haben eine Reihe von Flutter-Einheitentests durchgeführt und verschiedene Ansätze zum Spotten demonstriert. Ich lade Sie ein, mein Flutter-Beispielprojekt weiterhin zu verwenden, um zusätzliche Tests durchzuführen.
Flutter Unit Tests: Ihr Schlüssel zu überlegener UX
Wenn Sie bereits Komponententests in Ihre Flutter-Projekte integriert haben, hat dieser Artikel möglicherweise einige neue Optionen eingeführt, die Sie in Ihren Arbeitsablauf einfügen könnten. In diesem Tutorial haben wir gezeigt, wie einfach es wäre, Komponententests in Ihr nächstes Flutter-Projekt zu integrieren und wie Sie die Herausforderungen differenzierterer Testszenarien bewältigen können. Vielleicht möchten Sie nie wieder Unit-Tests in Flutter überspringen.
Das Redaktionsteam des Toptal Engineering Blog dankt Matija Becirevic und Paul Hoskins für die Überprüfung der Codebeispiele und anderer technischer Inhalte, die in diesem Artikel vorgestellt werden.