W poprzednich tekstach skupiałem się na języku programowania i narzędziach potrzebnych do pisania aplikacji na platformę iOS. Tym razem temat będzie bardziej konkretny i bezpośrednio związany z pisaniem aplikacji.
Zgodnie z założeniami TDD (z którymi nie zawsze się zgadzam, ale o tym innym razem) rozpocznę od testów jednostkowych. Przedstawię również najpopularniejsze narzędzia wykorzystywane w tym celu.
Wyzwania
Tak jak chyba na każdej innej platformie, tak i na iOS korzysta się z testów jednostkowych. Wyzwania w testowaniu na iOS są zbliżone do tych na Androidzie.
UIViewController
Podstawowym elementem interfejsu na iOS, poza UIView
, jest UIViewController
. Spaja on modele z widokami i odpowiada za cykl życia danego ekranu lub jego fragmentu. Krótko mówiąc: czy chcemy, czy nie – dużo tam się dzieje.
No dobra, ale w czym tu jest problem? Ano w tym, że nasze klasy muszą dziedziczyć po UIViewController
, a w testach jednostkowych trzeba symulować zachowanie systemu w niezliczonej liczbie sytuacji, zaś samo to symulowanie nie należy do najprostszych.
Co z tym fantem zrobić? Wielu doświadczonych programistów, których miałem przyjemność poznać, sugeruje, żeby po prostu… nie pisać testów jednostkowych do tych klas. Oczywiście nie ma to być wymówką do wrzucania tam możliwie dużej ilości logiki, która „nie będzie musiała” zostać przetestowana. Wręcz przeciwnie – aby ułatwić sobie życie, najlepiej jest nie implementować logiki biznesowej w UIViewController
, a warstwę prezentacji przetestować w inny sposób, np. przy użyciu testów UI.
Warto wspomnieć również o tym, że UIViewController
jest dość ciężki, szczególnie jeśli jest tworzony z pliku NIB (wypluwanego przez Interface Buildera), i samo tworzenie wielu jego instancji potrafi pochłonąć większość czasu wykonywania się testów jednostkowych.
Systemowe API
Jeśli nasza aplikacja intensywnie używa systemowych frameworków, to mamy duży problem. Nie chcemy oczywiście testować nieswojego kodu, dlatego warto tak zaprojektować swoje klasy, aby systemowe API było łatwe do zamockowania, np. poprzez użycie wstrzykiwania zależności.
Niestety w większości przypadków mocki są konieczne, a jedyną formą zweryfikowania, czy API zostało użyte poprawnie, jest sprawdzenie, czy została wywołana odpowiednia systemowa metoda z właściwymi argumentami. Wymaga to oczywiście perfekcyjnej wiedzy na temat działania danego API.
Co więcej, nie wszystkie API są dostępne na symulatorze i pełne mockowanie ich jest jedyną możliwością testowania kodu, który je wykorzystuje.
Stan globalny
O ile w przypadku naszego kodu możemy sobie pozwolić na unikanie singletonów albo kodu posiadającego efekty uboczne, o tyle w przypadku systemu nie jest to możliwe. Względnie duża liczba obiektów polegających na stanie globalnym jest bezpośrednio związana z tym, że urządzenie fizycznie jest tylko jedno, a jego funkcje są dostępne globalnie.
Niestety Apple nie udostępnia prostej i szybkiej możliwości zresetowania stanu symulatora przed każdym testem. Trzeba więc bardzo uważać na to, czy testowany kod nie wywołuje efektów ubocznych, które mogą wpłynąć na inne testy. Jest to szczególnie dotkliwe podczas pracy w dużych zespołach, gdy nie jesteśmy w stanie śledzić wszystkiego, co jest dodawane do repozytorium.
Ciekawy przypadek tego problemu obserwuję w swoim obecnym (bardzo dużym) projekcie. Gdzieś w kodzie lub testach jest błąd, który powoduje kolejkowanie się zadań na głównym wątku. Wraz z wykonywaniem się testów (wszystko na głównym wątku) ta kolejka rośnie, a gdy natrafi na test asynchroniczny – który czeka np. maksymalnie sekundę na wykonanie operacji – ten skończy się błędem. Dzieje się tak, ponieważ w ciągu tej sekundy nie zdążą wykonać się wszystkie wcześniej zakolejkowane zadania. Z uwagi na bardzo dużą ilość kodu, testów i zależności w projekcie nie udało nam się niestety jeszcze znaleźć źródła.
Mockowanie i stubowanie Swifta
Swift jako język programowania jest bardzo szybki. Zawdzięcza to w dużej mierze wielu możliwym optymalizacjom, które mogą się odbyć dzięki statycznemu wywoływaniu metod. Ma to jednak też swoje minusy.
W przeciwieństwie do np. Objective-C albo Javy czy innych bardziej dynamicznych języków programowania możliwości refleksji w Swifcie są bardzo ograniczone. Nie można łatwo mockować wywołań ani stubować wartości zwracanych przez metody na istniejącym obiekcie. Swift wymusza więc pisanie testowalnego kodu.
Co w takim razie z mockowaniem systemowych API? Ono na szczęście zostało praktycznie w całości napisane w Objective-C i jego mockowanie jest bardzo proste przy użyciu odpowiedniej biblioteki.
Narzędzia
Tak jak na każdej innej platformie, tak i tutaj powstało wiele rozwiązań wspomagających pisanie testów jednostkowych.
Apple dostarcza tylko podstawowy framework do wykonywania testów XCTest
, a Xcode potrafi dobrze zwizualizować pokrycie kodu.
Matchery
O ile wraz z XCTest
dostarczany jest zestaw asercji, o tyle wielu osobom nie przypadają one do gustu.
Z tego powodu powstały takie biblioteki jak Nimble, Expecta lub OCHamcrest. Pozwalają one poprawić czytelność testów, lepiej wyrazić, co chcieliśmy sprawdzić, i – chyba przede wszystkim – uniknąć ręcznego pisania komentarzy wyrzucanych do konsoli, gdy test się nie powiedzie.
Mockowanie i Stubowanie
W przypadku Objective-C sytuacja jest prosta. Używa się głównie OCMock, ewentualnie OCMockito. Przy użyciu którejkolwiek z tych bibliotek w bardzo łatwy sposób można sprawdzić, czy metoda została wywołana (lub nie), albo zmienić wartość zwracaną przez obiekt. Można też tworzyć stuby obiektów bez potrzeby tworzenia nowych klas.
W przypadku Swifta jest wyraźnie gorzej. Możemy znaleźć narzędzia takie jak Cuckoo, które obchodzą część ograniczeń poprzez generowanie kodu, ale nadal nie da się zrobić wszystkiego, np. mockować finalnych metod.
BDD
Czytelność i jasność intencji w testach jest kwestią kluczową, więc bardzo często w projektach używa się frameworków do BDD. Obecnie najpopularniejszym na iOS jest Quick, choć nadal funkcjonują też Specta oraz Kiwi.
Polecam pisanie testów w ten sposób, ponieważ nie tylko stają się one bardziej czytelne i łatwiej je zorganizować – sprzyja to też ograniczaniu liczby błędów i unikaniu powtarzania kodu.
Podsumowanie
Testowanie aplikacji iOS nie należy do najprostszych zadań, ale warto (a może nawet trzeba) to robić, a po nabraniu pewnej wprawy napotykane problemy nie są już takimi wyzwaniami jak na początku. Sprzyja to też pogłębianiu wiedzy na temat mechanizmów działania systemu. Warto również pamiętać, że testowanie aplikacji mobilnych nie kończy się na testach jednostkowych… ale o tym w kolejnej części cyklu!
A teraz…
Zobacz poprzednie teksty!