Unit-Tests in Flutter: Von Workflow-Essentials zu komplexen Szenarien

Veröffentlicht: 2022-09-21

Das 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:

  1. Werten Sie den Code aus.
  2. Richten Sie Data Mocking ein.
  3. Definieren Sie die Testgruppe(n).
  4. Definieren Sie Testfunktionssignatur(en) für jede Testgruppe.
  5. 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:

  1. Jede Projektklasse unterstützt ein einzelnes Ziel.
  2. 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.

Dateiordnerstruktur mit zwei Ordnern der ersten Ebene: lib und test. Unter lib verschachtelt haben wir den Ordner „features“, weiter verschachtelt ist university_feed und weiter verschachtelt sind data. Der Datenordner enthält das Repository und die Quellordner. Unterhalb des Quellordners ist der Netzwerkordner verschachtelt. Unter dem Netzwerk sind die Endpunkt- und Modellordner sowie die Datei university_remote_data_source.dart verschachtelt. Im Modellordner befindet sich die Datei api_university_model.dart. Auf der gleichen Ebene wie der zuvor erwähnte Ordner university_feed befinden sich die Ordner Domain und Präsentation. Unter der Domäne ist der Anwendungsfallordner verschachtelt. Unter der Präsentation sind die Modelle und Bildschirmordner verschachtelt. Die Struktur des zuvor erwähnten Testordners ahmt die von lib nach. Unter dem Ordner „test“ ist der Ordner „unit_test“ verschachtelt, der den Ordner „universities_feed“ enthält. Seine Ordnerstruktur ist die gleiche wie die des obigen university_feed-Ordners, wobei seine dart-Dateien an ihren Namen "_test" angehängt haben.
Die Struktur des Testordners des Projekts spiegelt die Struktur des Quellcodes wider

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:

  1. Werten Sie den Code aus.
  2. Data Mocking einrichten: Wir definieren die Serverantwort auf unseren API-Aufruf.
  3. Definieren Sie die Testgruppen: Wir haben zwei Testgruppen, eine für jede Funktion.
  4. Definieren Sie Testfunktionssignaturen für jede Testgruppe.
  5. 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.

Screenshot, der zeigt, dass fünf von fünf Tests bestanden wurden. Header lautet: Run: tests in api_university_model_test.dart. Auf der linken Seite des Bildschirms wird angezeigt: Testergebnisse---loading api_university_model_test.dart---api_university_model_test.dart---Test ApiUniversityModel initialization from json---Test using json one---Test using json two---Tests ApiUniversityModel toDomain ---Testen Sie toDomain mit json one---Testen Sie toDomain mit json two. Das rechte Feld des Bildschirms lautet: Tests bestanden: fünf von fünf Tests --- Flattertest test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

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:

  1. Wird die Funktion der Dio -Klasse tatsächlich von getUniversitiesByCountry ?
  2. Was passiert, wenn unsere API-Anfrage einen Fehler zurückgibt?
  3. 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.