Diversity w polskim IT
Mateusz Wojtczak
Mateusz WojtczakFlutter Lead Developer @ LeanCode

Jak testować kod we Flutterze

Sprawdź, jak testować aplikacje we Flutterze i poznaj kilka frameworków wspomagających sprawdzanie poprawności Twojego kodu.
30.03.20206 min
Jak testować kod we Flutterze

Flutter to jeden z najgłośniejszych tematów IT w ostatnim czasie. Wiele osób, zwłaszcza developerów mobilnych, zaczyna przygodę z Flutterem od stworzenia prostej aplikacji. W ferworze Hot Reload szybko przybywa kodu i funkcjonalności, ale razem z nimi także błędy. Pojawić się może wtedy pytanie: jak teraz przetestować całą aplikację? Mamy przecież sporo logiki, widoków oraz zachowań UX, które dają się opisać praktycznie tylko przez pokazanie apki w użyciu na żywo. Na szczęście twórcy Fluttera dostarczają nam swoje propozycje testowania aplikacji razem z kilkoma frameworkami wspomagającymi sprawdzanie poprawności naszego kodu.

Co na początek?

Domyślnie Flutter zakłada, że nasze pliki z testami (niezależnie od rodzaju testu) znajdują się w podkatalogu test (pliki z tego katalogu nie są dołączone do kodu aplikacji). Jeśli utworzyliśmy projekt przy pomocy flutter create, to taki folder powinien już istnieć. W tym miejscu będziemy dodawać pliki z kolejnymi testami, należy jednak pamiętać przy tym o jednej ważnej rzeczy - wszystkie pliki z testami muszą mieć w nazwie pliku suffix _test (np. username_validator_test.dart). Dzięki temu możemy uruchamiać nasze testy poprzez wywołanie polecenia flutter test z głównego katalogu aplikacji.


Przykładowe drzewo plików aplikacji

Logika, widgety i testy integracyjne

Testy automatyczne we Flutterze zostały podzielone już od początku istnienia technologii na 3 kategorie: testy jednostkowe, testy widgetów i testy integracyjne (end2end):

  1. Testy jednostkowe sprawdzają poprawność pojedynczych funkcji, metod czy klas.
  2. Testy widgetów mają za zadanie przetestować działanie pojedynczego widgetu.
  3. Testy integracyjne to sprawdzenie funkcjonowania całej aplikacji poprzez automatyczną interakcję z interfejsem symulującą zachowanie użytkownika.


W tym artykule przejdziemy po krótce przez wszystkie te kategorie, aby uwidocznić proces testowania aplikacji w technologii Flutter.


Porównanie charakterystyki każdej z kategorii testów. https://flutter.dev/docs/testing

Testy jednostkowe

Co do zasady działania, testy jednostkowe przy aplikacjach we Flutterze nie różnią się niczym od testów jednostkowych w innych technologiach/frameworkach. Są najłatwiejsze do wdrożenia, więc dobrym pomysłem jest rozpoczęcie testowania swojego projektu właśnie od testów jednostkowych. Poza tym mają bardzo szybki czas uruchomienia, więc ich częste wywoływanie nie jest problematyczne. (porównując do innych rodzajów testów, zwłaszcza integracyjnych). 

Jako przykład testowanej funkcji weźmiemy validateUsername, która waliduje poprawność podanej nazwy użytkownika. Nasz użytkownik może mieć nazwę złożoną ze wszystkich znaków oprócz %, a jej długość musi wynosić od 3 do 15 znaków (włącznie).


Przykładowa implementacja funkcji validateUsername

Aby napisać test, otwieramy nowy plik i zaczynamy od funkcji main,  w której po prostu wywołujemy metodę test z frameworka flutter_test. Metoda ta przyjmuje nazwę testu i funkcję, która ma przeprowadzić test. Jeśli w tej funkcji zostanie rzucony błąd, test kończy się wynikiem negatywnym, w przeciwnym wypadku wynik testu jest pozytywny. Poniżej pokazany został przykład kilku testów sprawdzających zachowanie naszego walidatora.


Plik test/username_validator_test.dart

Testy widgetów

Przed rozpoczęciem pisania testów widgetów, musimy przygotować naszą aplikację. Dodajemy zatem prosty widget, który zajmuje się przyjęciem i walidacją nazwy użytkownika. Poniżej podana jest metoda build(BuildContext) naszej klasy. Jest to StatefulWidget z dwoma polami: username (do przechowania podanej nazwy) i isValid, która przechowuje wynik walidacji.


Metoda build naszego widgetu

Klasa ma też metodę _signIn() do wywołania faktycznej walidacji przy wciśnięciu przycisku.


Implementacja metody _signIn()

Dla tych, którzy nie uruchamiają kodu na bieżąco: tak obecnie wygląda nasz widget w akcji.

Celem naszych testów jest sprawdzenie następujących zachowań:

  • tytuł jest domyślnie widoczny,
  • błąd jest domyślnie ukryty,
  • po wciśnięciu przycisku i złym inpucie błąd jest widoczny.


Dodajmy więc nasze widget testy!


Plik my_home_page_test.dart

Nasz plik zawiera dwa testy: sprawdzenie domyślnego stanu aplikacji oraz sprawdzenie zachowania przy podaniu niepoprawnej nazwy użytkownika. Jak widać na początku każdego testu następuje “napompowanie” widgetu. Służy to zbudowaniu drzewa widgetów i wyzwala jedną klatkę. Dzięki temu możemy potem eksplorować zbudowane drzewo widgetu tak, jakby wystąpiło ono jako poddrzewo widgetów naszej aplikacji. Czasem istnieje potrzeba wyzwolenia klatki jeszcze raz w trakcie testu, ma to miejsce w teście sprawdzającym podanie niepoprawnych danych. Po symulacji dotknięcia ikony na FloatingActionButtonie musimy wywołać metodę tester.pump(), aby drzewo widgetów przebudowało się i wystąpił w nim tekst naszego błędu.

Używane w tych testach metody są dostępne z poziomu biblioteki flutter_test. Są to pomocnicze funkcje i Matchery, dzięki którym możemy łatwo sprawdzić, czy w drzewie znajduje się widget o danym tekście lub kluczu albo na przykład zasymulować naciśnięcie przycisku. Najważniejsze funkcje są opisane wraz z przykładami użycia w oficjalnej dokumentacji Fluttera.

Testy integracyjne

Ostatnią formą testów aplikacji są testy integracyjne - polegają one na uruchomieniu aplikacji na emulatorze lub fizycznym urządzeniu i symulacji zachowań użytkownika imitując jego interakcje takie jak na przykład wywoływanie zdarzeń dotknięcia ekranu czy potrząśnięcie telefonem. Testy integracyjne testują aplikację całościowo i uruchamiają się najdłużej, ponieważ w trakcie testu aplikacja musi zostać “przeklikana” wielokrotnie, dużo trudniej też takie testy zrównoleglić. Dlatego też dobrym pomysłem jest rozpoczęcie testowania od najważniejszych ścieżek użytkownika i wykonania tzw. “smoke testów”, które pomogą upewnić się, że aplikacja nie doznała żadnej krytycznej regresji.

Flutter dostarcza specjalny framework pozwalający na tworzenie testów integracyjnych -  flutter_driver. Było to potrzebne, gdyż standardowe biblioteki do testów integracyjnych na Androidzie i iOS nie są w stanie efektywnie testować widoków Flutterowych (cała aplikacja Flutter jest widziana z ich perspektywy jako jeden widok - tester nie wie o istnieniu wewnętrznego drzewa widgetów). W tym celu dodajemy w pliku pubspec.yaml dodatkową zależność do paczki flutter_driver jako dev_dependency (dzięki temu driver nie zostanie dodany jako zależność naszej aplikacji, będzie dostępny tylko przy developmencie).


Pubspec.yaml

W poprzednich kategoriach wszystkie pliki z testami znajdowały się w katalogu test. W przypadku testów integracyjnych struktura plików wygląda inaczej. Tworzymy jeszcze jeden katalog test_driver, w którym umieszczamy dwa pliki: app.dart i app_test.dart. Pierwszy jest zintrumentalizowaną wersją naszej aplikacji, a drugi zawiera faktyczny zestaw testów, które operują na aplikacji z pierwszego pliku. Dla każdego oddzielnego zestawu, tworzymy kolejną parę takich plików w odpowiedniej konwencji nazewniczej (np. happy_path.dart i happy_path_test.dart).

W app.dart musimy uruchomić odpowiednią metodę (enableFlutterDriverExtension()) z biblioteki flutter_driver, aby driver nawiązał komunikację z Dart VM w celu pozyskiwaniu informacji o aktualnym stanie drzewa widgetów w trakcie działania testów. W kolejnej linii musimy po prostu uruchomić metodę main() naszej aplikacji.

W app_test.dart definiujemy grupę testów, w której na początku inicjujemy połączenie z driverem. Dzięki temu możemy w testach odpytywać driver o stan widoków, a także wysyłać komendy takie jak wpisanie tekstu.


test_driver/app.dart


test_driver/app_test.dart

Nasz test sprawdza “happy path”: użytkownik wpisuje poprawną nazwę, wciska przycisk i powinien zobaczyć tekst mówiący, że udało się zalogować. Warto zauważyć, że obiekt driver jest używany do wszystkich interakcji z testowaną aplikacją. Spróbujmy uruchomić nasz test: flutter drive --target=test_driver/app.dart. Niestety prawdopodobnie skończy się to błędem TimeoutException jak na poniższym screenshocie.


Output konsoli po próbie uruchomienia testów

Wygląda na to, że driver nie mógł znaleźć odpowiedniego widgetu, na którym miał wywołać “tap”. Kluczowe są pierwsze linijki pliku, gdzie definiujemy pomocnicze findery. Używamy metody byValueKey, która znajduje widgety sprawdzając ich klucze. W takim wypadku, żeby nasze widgety zostały poprawnie zidentyfikowane, musimy dodać do nich klucze. Jest to stały element występujący przy pisaniu testów integracyjnych we Flutterze: definiujemy klucze dla widgetów, z którymi chcemy wchodzić w jakiekolwiek interakcje przy testach.


Przykład dodania klucza do naszego FABa

Po dodaniu odpowiednich kluczy do TextFielda, FloatingActionButtona i tekstu na drugiej stronie możemy jeszcze raz uruchomić nasze testy. Teraz już wszystko działa!

Co dalej?

Jak widać, testowanie aplikacji we Flutterze jest dosyć przyjemne i nie trzeba pisać wiele boilerplate code. Ale jak zacząć testować w naszym projekcie? Myślę, że dobrym pomysłem jest zacząć reżim testów od pisania testów jednostkowych naszej logiki i zarządzania stanem (np. BLoC czy Redux). Takie biblioteki często udostępniają przykłady testowania ich użycia. Dodatkowo, testy jednostkowe są najlżejsze do uruchomienia i nie zajmują wiele czasu developera, dlatego proponuję rozpocząć zachęcanie swoich kolegów z zespołu do testowania aplikacji właśnie od unit testów.

Cały kod występujący jako przykłady w tym artykule jest dostępny tutaj

<p>Loading...</p>