Testowanie jednostkowe w Flutter: od podstaw przepływu pracy do złożonych scenariuszy

Opublikowany: 2022-09-21

Zainteresowanie Flutterem osiągnęło najwyższy poziom w historii – i jest już dawno spóźnione. Open-source SDK firmy Google jest zgodny z systemami Android, iOS, macOS, web, Windows i Linux. Pojedyncza baza kodu Flutter obsługuje je wszystkie. Testy jednostkowe mają kluczowe znaczenie w dostarczaniu spójnej i niezawodnej aplikacji Flutter, chroniącej przed błędami, wadami i defektami poprzez prewencyjną poprawę jakości kodu przed jego złożeniem.

W tym samouczku udostępniamy optymalizacje przepływu pracy dla testów jednostkowych Flutter, demonstrujemy podstawowy test jednostkowy Flutter, a następnie przechodzimy do bardziej złożonych przypadków testowych i bibliotek Flutter.

Przepływ testów jednostkowych we Flutterze

Testy jednostkowe wdrażamy we Flutterze w taki sam sposób, jak w innych stosach technologicznych:

  1. Oceń kod.
  2. Skonfiguruj podszycie danych.
  3. Zdefiniuj grupy testowe.
  4. Zdefiniuj sygnatury funkcji testowych dla każdej grupy testowej.
  5. Napisz testy.

Aby zademonstrować testowanie jednostkowe, przygotowałem przykładowy projekt Flutter i zachęcam do używania i testowania kodu w wolnym czasie. Projekt wykorzystuje zewnętrzne API do pobierania i wyświetlania listy uczelni, które możemy filtrować według kraju.

Kilka uwag o tym, jak działa Flutter: Framework ułatwia testowanie poprzez automatyczne ładowanie biblioteki flutter_test podczas tworzenia projektu. Biblioteka umożliwia Flutterowi odczytywanie, uruchamianie i analizowanie testów jednostkowych. Flutter również automatycznie tworzy folder test , w którym będą przechowywane testy. Bardzo ważne jest unikanie zmiany nazwy i/lub przenoszenia folderu test , ponieważ zakłóca to jego funkcjonalność, a tym samym naszą zdolność do uruchamiania testów. Niezbędne jest również dołączenie _test.dart do naszych nazw plików testowych, ponieważ ten przyrostek jest sposobem, w jaki Flutter rozpoznaje pliki testowe.

Testuj strukturę katalogu

Aby promować testy jednostkowe w naszym projekcie, wdrożyliśmy MVVM z czystą architekturą i wstrzykiwaniem zależności (DI), o czym świadczą nazwy wybrane dla podfolderów kodu źródłowego. Połączenie zasad MVVM i DI zapewnia oddzielenie obaw:

  1. Każda klasa projektu obsługuje jeden cel.
  2. Każda funkcja w klasie spełnia tylko swój własny zakres.

Stworzymy zorganizowaną przestrzeń do przechowywania plików testowych, które napiszemy, system, w którym grupy testów będą miały łatwe do zidentyfikowania „domy”. W świetle wymagania Fluttera, aby zlokalizować testy w folderze test , odwzorujmy strukturę folderów naszego kodu źródłowego w test . Następnie, gdy napiszemy test, zapiszemy go w odpowiednim podfolderze: Tak jak czyste skarpetki trafiają do szuflady na skarpety w Twojej komodzie, a złożone koszule do szuflady z koszulami, tak testy jednostkowe klas Model trafiają do folderu o nazwie model , na przykład.

Struktura folderów plików z dwoma folderami pierwszego poziomu: lib i test. Zagnieżdżony pod lib mamy folder funkcji, dalej zagnieżdżony jest katalog_informacji, a dalej zagnieżdżone są dane. Folder data zawiera foldery repozytorium i źródła. Zagnieżdżony pod folderem źródłowym jest folder sieciowy. Poniżej sieci znajdują się foldery punktów końcowych i modeli, a także plik university_remote_data_source.dart. W folderze modelu znajduje się plik api_university_model.dart. Na tym samym poziomie, co wcześniej wspomniany folder university_feed, znajdują się foldery domeny i prezentacji. Pod domeną zagnieżdżony jest folder usecase. Pod prezentacją zagnieżdżone są modele i foldery ekranu. Wspomniana wcześniej struktura folderu testowego naśladuje strukturę lib. Poniżej folderu test znajduje się folder unit_test, który zawiera folder university_feed. Jego struktura folderów jest taka sama jak w powyższym folderze university_feed, a pliki rzutek mają dodany „_test” do ich nazw.
Struktura folderów testowych projektu odzwierciedlająca strukturę kodu źródłowego

Przyjęcie tego systemu plików zapewnia przejrzystość projektu i zapewnia zespołowi łatwy sposób przeglądania, które części naszego kodu mają powiązane testy.

Jesteśmy teraz gotowi do wdrożenia testów jednostkowych.

Prosty test jednostkowy trzepotania

Zaczniemy od klas model (w warstwie data kodu źródłowego) i ograniczymy nasz przykład do tylko jednej klasy model , ApiUniversityModel . Ta klasa posiada dwie funkcje:

  • Zainicjuj nasz model przez mockowanie obiektu JSON za pomocą Map .
  • Zbuduj model danych University .

Aby przetestować każdą z funkcji modelu, dostosujemy opisane wcześniej uniwersalne kroki:

  1. Oceń kod.
  2. Skonfiguruj mockowanie danych: zdefiniujemy odpowiedź serwera na nasze wywołanie API.
  3. Zdefiniuj grupy testowe: Będziemy mieć dwie grupy testowe, po jednej dla każdej funkcji.
  4. Zdefiniuj sygnatury funkcji testowych dla każdej grupy testowej.
  5. Napisz testy.

Po przeanalizowaniu naszego kodu jesteśmy gotowi do realizacji drugiego celu: skonfigurowania mockowania danych specyficznych dla dwóch funkcji w klasie ApiUniversityModel .

Aby zakpić pierwszą funkcję (zainicjować nasz model przez zafałszowanie JSON za pomocą Map ), fromJson , utworzymy dwa obiekty Map do symulacji danych wejściowych dla funkcji. Stworzymy również dwa równoważne obiekty ApiUniversityModel , które będą reprezentować oczekiwany wynik funkcji z podanymi danymi wejściowymi.

Aby zakpić drugą funkcję (budującą model danych University ), toDomain , utworzymy dwa obiekty University , które są oczekiwanym wynikiem po uruchomieniu tej funkcji we wcześniej utworzonych obiektach ApiUniversityModel :

 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"], ); }

Następnie, dla naszego trzeciego i czwartego celu, dodamy opisowy język, aby zdefiniować nasze grupy testowe i sygnatury funkcji testowych:

 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', () {}); }); }

Zdefiniowaliśmy sygnatury dwóch testów sprawdzających funkcję fromJson i dwóch sprawdzających funkcję toDomain .

Aby spełnić nasz piąty cel i napisać testy, użyjmy metody expect biblioteki flutter_test do porównania wyników funkcji z naszymi oczekiwaniami:

 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); }); }); }

Po zrealizowaniu naszych pięciu celów możemy teraz uruchomić testy, albo z IDE, albo z wiersza poleceń.

Zrzut ekranu wskazujący, że pięć z pięciu testów zakończyło się pomyślnie. Nagłówek brzmi: Uruchom: testy w api_university_model_test.dart. Lewy panel ekranu wyświetla: Wyniki testu --- ładowanie api_university_model_test.dart --- api_university_model_test.dart --- Testowanie inicjalizacji ApiUniversityModel z json --- Testowanie przy użyciu json one --- Testowanie przy użyciu json two --- Testy ApimainversityModel do ---Testuj toDomain za pomocą json one---Testuj toDomain za pomocą json two. Prawy panel ekranu wyświetla: Testy zdane: pięć z pięciu testów --- test testu trzepotania/test_jednostki/kanał_universities/data/source/network/model/api_university_model_test.dart

Na terminalu możemy uruchomić wszystkie testy zawarte w folderze test , wprowadzając komendę flutter test i sprawdzić, czy nasze testy przeszły pomyślnie.

Alternatywnie moglibyśmy uruchomić pojedynczy test lub grupę testową, wprowadzając flutter test --plain-name "ReplaceWithName" , zastępując nazwę naszego testu lub grupy testowej za ReplaceWithName .

Testowanie jednostki punktu końcowego w Flutter

Po wykonaniu prostego testu bez zależności, przyjrzyjmy się ciekawszemu przykładowi: Przetestujemy klasę endpoint , której zakres obejmuje:

  • Wykonywanie wywołania API do serwera.
  • Przekształcenie odpowiedzi API JSON na inny format.

Po przeanalizowaniu naszego kodu użyjemy metody setUp biblioteki setUp , aby zainicjować klasy w naszej grupie testowej:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

Aby wysyłać żądania sieciowe do API, wolę używać biblioteki retrofit, która generuje większość niezbędnego kodu. Aby poprawnie przetestować klasę UniversityEndpoint , zmusimy bibliotekę dio — używaną przez Retrofit do wykonywania wywołań API — do zwrócenia pożądanego wyniku przez mocowanie zachowania klasy Dio za pomocą niestandardowego adaptera odpowiedzi.

Niestandardowa makieta przechwytująca sieć

Mockowanie jest możliwe dzięki zbudowaniu przez nas klasy UniversityEndpoint poprzez DI. (Gdyby klasa UniversityEndpoint sama zainicjowała klasę Dio , nie byłoby możliwości, abyśmy zakpili zachowanie klasy).

Aby zakpić zachowanie klasy Dio , musimy znać metody Dio używane w bibliotece Retrofit — ale nie mamy bezpośredniego dostępu do Dio . Dlatego będziemy kpić z Dio za pomocą niestandardowego przechwytywacza odpowiedzi sieci:

 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); }

Teraz, gdy stworzyliśmy przechwytywacz, aby zakpić nasze odpowiedzi sieciowe, możemy zdefiniować nasze grupy testowe i sygnatury funkcji testowych.

W naszym przypadku mamy tylko jedną funkcję do przetestowania ( getUniversitiesByCountry ), więc utworzymy tylko jedną grupę testową. Przetestujemy reakcję naszej funkcji na trzy sytuacje:

  1. Czy funkcja klasy Dio jest faktycznie wywoływana przez getUniversitiesByCountry ?
  2. Jeśli nasze żądanie API zwróci błąd, co się stanie?
  3. Jeśli nasze żądanie API zwróci oczekiwany wynik, co się stanie?

Oto nasza grupa testowa i sygnatury funkcji testowych:

 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 {}); });

Jesteśmy gotowi do napisania naszych testów. Dla każdego przypadku testowego utworzymy instancję DioMockResponsesAdapter z odpowiednią konfiguracją:

 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()); }); });

Teraz, gdy nasze testowanie punktów końcowych zostało zakończone, przetestujmy naszą klasę źródła danych, UniversityRemoteDataSource . Wcześniej zaobserwowaliśmy, że klasa UniversityEndpoint jest częścią konstruktora UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , co wskazuje, że UniversityRemoteDataSource wykorzystuje klasę UniversityEndpoint do wypełnienia swojego zakresu, więc to jest klasa, którą będziemy kpić.

Kpiąc z Mockito

W naszym poprzednim przykładzie ręcznie sfałszowaliśmy adapter żądania naszego klienta Dio przy użyciu niestandardowego NetworkInterceptor . Tutaj kpimy z całej klasy. Robienie tego ręcznie — naśladowanie klasy i jej funkcji — byłoby czasochłonne. Na szczęście biblioteki mock są zaprojektowane do obsługi takich sytuacji i mogą generować klasy mock przy minimalnym wysiłku. Użyjmy biblioteki mockito, standardowej biblioteki branżowej do wyśmiewania we Flutterze.

Aby zakpić przez Mockito , najpierw dodajemy adnotację „ @GenerateMocks([class_1,class_2,…]) ” przed kodem testu — tuż nad funkcją void main() {} . W adnotacji dołączymy listę nazw klas jako parametr (zamiast class_1,class_2… ).

Następnie uruchamiamy polecenie flutter pub run build_runner build , które generuje kod dla naszych mock klas w tym samym katalogu, co test. Nazwa wynikowego pliku makiety będzie kombinacją nazwy pliku testowego plus .mocks.dart , zastępując sufiks .dart testu. Zawartość pliku będzie zawierać klasy mock, których nazwy zaczynają się od przedrostka Mock . Na przykład UniversityEndpoint staje się MockUniversityEndpoint .

Teraz importujemy university_remote_data_source_test.dart.mocks.dart (nasz plik próbny) do university_remote_data_source_test.dart (plik testowy).

Następnie w funkcji setUp UniversityEndpoint za pomocą MockUniversityEndpoint i inicjujemy klasę UniversityRemoteDataSource :

 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); }); }

Pomyślnie wykpiliśmy UniversityEndpoint , a następnie zainicjowaliśmy naszą klasę UniversityRemoteDataSource . Teraz jesteśmy gotowi do zdefiniowania naszych grup testowych i sygnatur funkcji testowych:

 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', () {}); });

Dzięki temu tworzone są nasze mockowanie, grupy testowe i sygnatury funkcji testowych. Jesteśmy gotowi do napisania rzeczywistych testów.

Nasz pierwszy test sprawdza, czy funkcja UniversityEndpoint jest wywoływana, gdy źródło danych inicjuje pobieranie informacji o kraju. Zaczynamy od zdefiniowania, jak każda klasa będzie reagowała na wywołanie jej funkcji. Ponieważ wykpiliśmy klasę UniversityEndpoint , to jest to klasa, z którą będziemy pracować, używając struktury kodu when( function_that_will_be_called ).then( what_will_be_returned ) .

Testowane przez nas funkcje są asynchroniczne (funkcje, które zwracają obiekt Future ), więc do zmodyfikowania naszych wyników użyjemy struktury kodu when(function name).thenanswer( (_) {modified function result} ) .

Aby sprawdzić, czy funkcja getUniversitiesByCountry wywołuje funkcję getUniversitiesByCountry w klasie UniversityEndpoint , użyjemy when(...).thenAnswer( (_) {...} ) do zakłamania funkcji getUniversitiesByCountry w klasie UniversityEndpoint :

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

Teraz, gdy wykpiliśmy naszą odpowiedź, wywołujemy funkcję źródła danych i verify — za pomocą funkcji Verify — czy wywołano funkcję UniversityEndpoint :

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

Na tych samych zasadach możemy pisać dodatkowe testy, które sprawdzają, czy nasza funkcja poprawnie przekształca wyniki naszego punktu końcowego w odpowiednie strumienie danych:

 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) ]), ); }); }); }

Przeprowadziliśmy szereg testów jednostkowych Fluttera i zademonstrowaliśmy różne podejścia do wyśmiewania. Zapraszam do dalszego korzystania z mojego przykładowego projektu Flutter w celu przeprowadzenia dodatkowych testów.

Testy Flutter Unit: Twój klucz do doskonałego UX

Jeśli już włączyłeś testy jednostkowe do swoich projektów Flutter, ten artykuł mógł wprowadzić nowe opcje, które możesz wprowadzić do swojego przepływu pracy. W tym samouczku pokazaliśmy, jak proste byłoby włączenie testów jednostkowych do następnego projektu Flutter i jak stawić czoła wyzwaniom bardziej zniuansowanych scenariuszy testowych. Możesz już nigdy nie chcieć pominąć testów jednostkowych we Flutterze.

Zespół redakcyjny Toptal Engineering Blog wyraża wdzięczność Matiji Becirevicowi i Paulowi Hoskinsowi za przejrzenie próbek kodu i innych treści technicznych przedstawionych w tym artykule.