Tematyka testowania na Androidzie jest mało poruszana, a z testami jest trochę jak z siłownią: wszyscy mówią, że warto, a mało kto chodzi!
Dużo ludzi mówi, że warto testować, ale w projektach, które robi się na co dzień, rzadko kto stosuje testy. Chciałbym to zmienić i pokazać, jak można tworzyć testy, które będą wartościowe dla projektów.
Dlaczego warto testować?
Zapobieganie regresji
Najważniejsze jest to, że testy pozwalają zapobiegać regresji, czyli zapobiegają sytuacji, w której kawałek kodu, który kiedyś napisaliśmy, w wyniku dodawania nowych funkcjonalności, zepsuliśmy. Jeżeli mamy testy, które pokrywają nam istniejący kawałek kodu, to pisząc nowy kod, nie powinniśmy być w stanie zepsuć istniejącego kawałku kodu, bo mamy siatkę bezpieczeństwa w postaci testów.
Jakość kodu
Testy wpływają na jakość kodu, bo już na etapie pisania będziemy myśleli o sytuacjach, które potencjalnie mogą zepsuć nasze testy. Podobnie jak w pierwszym przypadku, mamy coś takiego jak siatka bezpieczeństwa przy refactoringu. Jeśli postanowimy poprawić coś w naszej aplikacji, to taki refactoring może spowodować, że rzeczy, które do tej pory działały, przestaną działać. Jeśli mamy siatkę bezpieczeństwa w postaci odpowiedniej ilości testów, to jesteśmy w stanie bezpiecznie wykonywać taki refactoring.
Lepsza architektura
Jeśli tworzymy testy już na etapie pisania kodu, to wynikiem jest lepsza architektura aplikacji. Aplikacja jest bardziej testowalna, więc jednocześnie jej komponenty mają cechy dobrego kodu. Są w pewnym stopniu zamknięte, ale jednocześnie otwarte na możliwości modyfikacji i nie ma tak dużej zależności między nimi.
Testy to koszt
Należy pamiętać, że testy to również koszt. To powoduje, że w znaczącej liczbie przypadków projekty nie mają testów. Na etapie pisania musimy napisać test, czyli musimy wykonać dodatkową pracę, która w pewnym stopniu nie przynosi nam wartości w postaci wyprodukowanego kodu. Daje to natomiast dodatkową wartość w przyszłości, kiedy aplikacja będzie rozwijana. Jeśli aplikacja jest jednorazowym strzałem i ma zniknąć za miesiąc ze sklepu, to prawdopodobnie pisanie testów będzie tylko kosztem, który nie będzie miał większego uzasadnienia.
Jeżeli projekt będzie rozwijany, to co jakiś czas będziemy musieli aktualizować testy. Dokonanie pewnych zmian czy dodanie nowych elementów będzie oznaczało, że za każdym razem będziemy musieli modyfikować test po to, żeby cały czas był aktualny. Nie ma nic gorszego niż zaprzestanie działania przez testy i ignorowanie tego przez nas. Wtedy tracimy w nie wiarę i przestajemy z nimi cokolwiek robić.
Jeżeli dodajemy nowe testy, czasami musimy też poprawić stare testy. Może się okazać, że one nie były napisane przez nas, tylko przez kogoś. Należy je zrozumieć, poprawić je i zdecydować, czy nadal mają wartość. Jeśli nie mają wartości i nie odnoszą się kodu, który aktualnie jest napisany, to nie ma sensu ich poprawiać. Czasami lepiej je po prostu usunąć, natomiast należy pamiętać, że to jest dodatkowy koszt.
Trzeba pamiętać, że testy dają nam dużo wartości, ale jednocześnie testy są kosztem. Aby dostawać jak najwięcej wartości z testów, to warto, żeby nasze testy były szybkie. Jeśli odpalenie całego zestawu testów będzie trwało godzinę, to może się okazać, że przestaniemy używać tych testów, a to będzie oznaczało, że zaczną one gnić. Wtedy prawdopodobnie po chwili zdezaktualizują się i przestaną mieć jakikolwiek sens.
Jakie powinny być testy?
Warto również, aby pokrywały jak największy obszar kodu. Tutaj pojawia się podstawowy problem, bo pokrycie dużej ilości kodu oznacza, że albo musimy napisać dużo testów, albo napisać testy, które dotykają wielu elementów jednocześnie i testować wszystkie opcje. Możemy pójść albo szeroko, albo głęboko. W testowaniu wszystkich opcji staramy się znaleźć dla jakiegoś komponentu wszystkie edge case’y, które jesteśmy w stanie wymyślić i wszystkie te edge case’y przetestować. Oznacza to, że musimy napisać wiele testów dla różnych sytuacji. Tutaj pojawia się klasyczny trójkąt „Dobrze-Tanio-Szybko” i musimy tu wybrać balans pomiędzy tymi opcjami, żeby być w stanie jak najwięcej uzyskać.
Jest coś takiego, jak taka piramida testów, gdzie mamy podejście, w którym mamy testy unitowe, czyli testy jednostkowe testujące bardzo mały kawałek kodu, ale możemy testować bardzo szybko i bardzo szczegółowo, oraz mamy testy integracyjne, gdzie testujemy kilka komponentów w połączeniu ze sobą, żeby przetestować, czy będą one dobrze ze sobą współpracowały. Oprócz tego mamy testy UI, które sprawdzają na poziomie całej aplikacji, czy aplikacja się odpala i czy wyświetlają się odpowiednie dane.
W przypadku Androida pisanie testów unitowych często oznacza, że mamy określony czas i musimy w nim zawrzeć jak najwięcej testów, które dadzą nam jak największą wartość. Proponuję położyć dużo większy nacisk na testy UI, ponieważ one są w stanie wyłapać rzeczy związane z wyglądem, z integracją komponentów i pozwalają przejść przez wiele warstw i komponentów jednocześnie. Najlepszym połączeniem są testy UI po to, żeby przetestować aplikację jak najszerzej, a testy unitowe po to, żeby przetestować bardzo dokładnie pewne fragmenty aplikacji.
Jak powinien wyglądać dobry test?
Są dwa podobne podejścia. Podejścia Given-When-Then albo Arrange-Act-Assert to podejścia, w których nasz test składa się z trzech głównych elementów. Pierwszy to element przygotowania, gdzie przygotowujemy naszą scenę, na której będzie wykonywany test. Przykładowo tworzymy obiekty, ustalamy pewne wartości na nich i ustawiamy jakiś stan. Następnie wykonujemy jakąś akcję na naszym komponencie, czyli przykładowo przygotowujemy dane do logowania, które będą zwracane przez nasz serwer, zaślepiamy te dane i wchodzimy na odpowiedni ekran. W dalszej kolejności sprawdzamy, czy wystąpiła pewna akcja w odpowiedzi na naszą akcję. Przykładowo, jeżeli wpisałem tylko user name i nacisnąłem login, to powinno się pojawić, że brakuje hasła. Taki test ma wtedy największą wartość, bo możemy stwierdzić, że dla danej sytuacji, jeśli wykonamy daną akcję, następuje pewien błąd. Będziemy mieli informację, co się zepsuło i prawdopodobnie z dużą dokładnością będziemy w stanie namierzyć, w którym miejscu się to zepsuło. Oczywiście, zależnie od tego, jak dokładny jest nasz test.
W przypadku testów jednostkowych warto stosować założenie jednej asercji na test. Czyli na końcu testów powinniśmy umieszczać tylko jedno założenie sprawdzające jakiś warunek. Nie należy stosować Assert Equals, gdzie podajemy najpierw wartość oczekiwaną, a potem wartość faktyczną, bo tutaj łatwo to pomylić. Dużo lepiej użyć Fluent Assertions, gdzie pojawiają się nam wszystkie możliwości, które możemy wykonać na tej wartości. Jest to dużo lepsze i dużo czytelniejsze.
Z testami UI jest tak, że czasami to założenie, że mamy jedną asercję na test, możemy trochę naruszyć. Samo odpalenie takiego testu zajmuje dużo dłużej niż w przypadku testów jednostkowych, więc w związku z tym, żeby nie tworzyć dziesięciu testów, w których każdy ma jedną asercję, czasami tworzymy test, który ma kilka asercji i potem kolejny, żeby oszczędzić na czasie.
Testy jednostkowe mają wartość w przypadku Androida, może nieco mniejszą niż testy UI, natomiast warto testować pewne rzeczy. Jeżeli piszemy testy jednostkowe, to warto pamiętać o tym, żeby nie wchodzić za głęboko w szczegóły implementacyjne, bo możemy doprowadzić do sytuacji, w której następuje testowanie konkretnej implementacji. Jeśli wtedy postanowimy zmienić cokolwiek, to nagle okaże się, że nasze testy przestają przechodzić. Staramy się zatem testować publiczne API i nie testujemy implementacji wewnętrznej. Ważną rzeczą jest to, że testy jednostkowe powinny być super szybkie. Oznacza to, że nie dotykamy w nich dysku i sieci, co oznacza, że nie odczytujemy plików, bazy danych i przede wszystkim nie wykonujemy zapytań sieciowych. Najlepiej też, jeśli testy jednostkowe nie dotykają Androida, bo biblioteka standardowa Androida jest dosyć mocno powiązana z biblioteką, która nie jest dostępna. W związku z tym powstały różnego rodzaju atrapy, na przykład Robolectric, który jest implementacją Androida, którą możemy odpalać na własnym komputerze.
Test Driven Development
Mówiąc o testach, warto wspomnieć o teście Driven Development, czyli o podejściu, w którym kod pisany jest tylko w odpowiedzi na testy. Czyli piszemy najpierw testy, a potem kod produkcyjny. Założenie jest takie, że nie piszemy żadnego kodu bez testów, czyli każda linijka kodu, który faktycznie wyląduje w naszej aplikacji, powinien powstawać w wyniku tego, że napisaliśmy jakiś test. W związku z tym architektura będzie powstawała sama w odpowiedzi na to, jak będziemy nadawali kolejne testy. Stosujemy tu takie podejście, jak Red Green Refactor, które zakłada, że mamy trzy fazy pisania naszego kodu i pisania naszych testów. W fazie Red wybieramy jakiś test do napisania, czyli zakładamy to, że wiemy, co jest naszym celem. Wybieramy najbliższy test, który zbliży nas do tego celu, piszemy go tak, aby ten test miał sens i potem sprawiamy, aby ten test się kompilował. Na koniec tego ten test powinien być czerwony i to jest właśnie główne założenie. Oznacza to, że napisaliśmy test, w którym kod produkcyjny nie jest w stanie go zrealizować, więc musimy napisać kod produkcyjny, który będzie realizował ten test. W fazie Green robimy minimalny fix. To jest mocno kontrowersyjna rzecz, bo w czystym podejściu TDD zakładamy, że robimy najmniejszy kawałek implementacji, który sprawi, że test przejdzie na zielono. Po sprawdzeniu, czy wszystkie inne testy, które do tej pory napisaliśmy, przeszły, możemy przejść do fazy Refactor. Jest to faza, w której możemy robić modyfikacje zarówno w kodzie testów, jak i produkcyjnych, ale takie, żeby nadal cały czas wszystkie testy przechodziły. Tutaj najczęściej usuwa się jakieś powtórzenia w kodzie czy dodaje się wzorce projektowe, żeby ten kod był lepszy. Ważne jest to, żeby mieć kod, który jest Green w trakcie przeprowadzania Refactora. To jest też dobry moment, żeby posprzątać także testy, czyli jeśli zauważamy, że w teście za każdym razem pierwsze trzy kroki są takie same, to może warto przenieść to do jakiejś metody inicjalizacyjnej.
Test doubles
W pewnym momencie, jeżeli zaczynamy działać różnymi komponentami, dochodzimy do momentu, kiedy potrzebujemy zaślepić jakiś inny komponent, który nie podlega testowi. Tutaj jest kilka podejść, których możemy użyć. Są tak zwane duble i fake. Fake to najprostsze podejście, gdzie jeśli mamy interfejs, który jest implementowany przez naszą klasę, to możemy stworzyć kolejną klasę na potrzebę testów, która też implementuje ten interfejs, ale zwraca nam takie wartości, jakich oczekujemy. Czyli robimy taką fałszywą implementację, która pośrodku może mieć jakąś logikę i udawać, że faktycznie wykonuje to, co powinna. Kolejne podejście to stub. Tutaj najczęściej zaczynamy wchodzić w sytuację, w której mamy jakieś biblioteki, które będą nam generowały te stuby. Stub jest warstwą, w której jesteśmy w stanie stworzyć obiekt na podstawie podanej klasy i powiedzieć temu obiektowi, że jeśli zostanie zawołana ta metoda, to zwróć konkretne wartości. Rozwinięciem stubu jest mock, który nie dość, że potrafi zaślepiać zwracane wartości, to dodatkowo mogę wykonać jeszcze tak zwaną weryfikację. To pozwala sprawdzać, czy nasz kod dobrze współdziała z innymi komponentami, z którymi ma się komunikować.
Testy UI
Odnośnie do testów UI, czyli testów, które faktycznie testują interfejs użytkownika naszej aplikacji, to żeby to działało poprawnie, nasza aplikacja musi zostać uruchomiona na faktycznym urządzeniu i jest to realizowane przez testy instrumentacyjne. To jest specjalny mechanizm, który jest wbudowany w Androida, pozwalający na uruchomienie naszej aplikacji oraz osobnej aplikacji, która będzie odpalana na tym samym urządzeniu i która zawiera tylko nasze testy. Te dwie aplikacje są połączone ze sobą w specjalny sposób i dzięki temu nasza aplikacja testująca może klikać po naszej aplikacji i robić różnego rodzaju sprawdzenia wewnątrz aplikacji, którą piszemy. Jest to jeden z najważniejszych typów testów, jeśli chodzi o Androida, ponieważ niewielkim nakładem pracy możemy uzyskać największy zysk w postaci przetestowania wielu warstw i elementów aplikacji. Ważną rzeczą, o której trzeba pamiętać, że testy te są zdecydowanie wolniejsze niż testy unitowe i w związku z tym często stosujemy wiele asercji na test. Można je odpalać z Android Studio, natomiast ja odpalam je często ze skryptu, czyli z linii poleceń, ponieważ w ten sposób można je odpalać zdecydowanie szybciej.
Biblioteka Espresso
Najpopularniejszym sposobem na tworzenie testów UI jest biblioteka Espresso. Daje bardzo duże możliwości testowania. Co prawda ma taką składnię, która jest dosyć nietypowa i trzeba się do niej przyzwyczaić. Natomiast po przyzwyczajeniu się do niej, jesteśmy w stanie testować bardzo wiele różnych sytuacji. Należy pamiętać, że testy Espresso mogą się łatwo okazać testami flaky, czyli niestabilnymi testami. Dlatego też jest zestaw rzeczy, które warto zrobić na urządzeniu lub emulatorze, żeby zminimalizować niestabilność tych testów. Pierwszą rzeczą jest wyłączenie animacji, co należy zrobić zawsze.
Android Testing 101: Wprowadzenie do testowania
Wolisz słuchać niż czytać. Posłuchaj jak opowiadam o testach na moim kanale YouTube.