Testy jednostkowe w Objective-C

Zbigniew Sobiecki opublikował wpis dnia 2009/03/13 w kategorii: Cocoa

Oprogramowanie można testować na wiele sposobów, w zależności od tego na jakim poziomie chcemy operować, jakie aspekty działania aplikacji chcemy przetestować i do czego wyniki testów mają nam posłużyć. Prawdopodobnie najszerzej praktykowanym sposobem testowania jest tworzenie testów jednostkowych (ang. unit tests). Testy te uruchamiają fragmenty tworzonego kodu i sprawdzają czy efekt ich działania jest zgodny z oczekiwanym.

Jak łatwo się domyślić, żeby wykorzystać pełne zalety unit testów, w miarę tworzenia nowych funkcjonalności aplikacji powinniśmy dbać o to, aby były one przetestowane, a zatem pisać równolegle kod samych testów. Jeszcze lepiej, kiedy zgodnie z metodologią TDD (Test-Driven Development) testy powstają jako specyfikacja, zanim jeszcze zostanie stworzony kod mogący ją spełniać.

Na pierwszy rzut oka wydawać by się mogło, że pisanie testów to trochę sztuka dla sztuki, zwłaszcza że pochłania dosyć znaczącą ilość czasu podczas rozwoju aplikacji. Jest to niestety często spotykany sposób myślenia. Mniemam, że ma on korzenie w podobnym rozumowaniu jak zasłyszana ostatnio wypowiedź prezesa sporej firmy developerskiej – “Czy my naprawdę potrzebujemy tego działu QA? Czy programiści nie mogliby po prostu przestać robić błędy w oprogramowaniu?”

Dla samych developerów, programowanie testów jednostkowych (zwłaszcza w ‘kieracie’ TDD/BDD) też jest zwykle na początku zadaniem uciążliwym, do którego trzeba ich przekonać. Nie ma w końcu efektu “instant gratification” – rezultatem napisania testu w odróżnieniu od pozostałych części programu nie jest nowa funkcjonalność lub poprawka błędu.

Jednak tworzenie testów jednostkowych bardzo podnosi jakość tworzonego oprogramowania i paradoksalnie, w dłuższej perspektywie wpływa pozytywnie także na szybkość jego rozwoju. Do postawowych korzyści płynących z unit testów można zaliczyć:

* Testowanie regresji – częste uruchamianie testów w miarę tworzenia nowego kodu aplikacji pozwala łatwo ujawnić popełnione błędy, objawiające się często w najmniej oczekiwanych miejscach, znalezienie których mogłoby być znacznie bardziej problematyczne. Błędy regresyjne bywają szczególnie nieprzyjemne podczas przeprowadzania refaktoryzacji – tutaj wytworzona
* Weryfikacja poprawności projektu – często problemy jakie napotykamy przy tworzeniu testów wynikają z konieczności rozebrania struktury naszego kodu na mniejsze części. Nawet w programowaniu obiektowym łatwo o uzyskanie sytuacji, kiedy nie jest to zadanie banalne. Testy uwypuklają taką sytuację na wczesnym etapie i pozwalają zaradzić złemu projektowi w porę, chroniąc nas przed karkołomnymi konstrukcjami w przyszłości.
* Dokumentacja – nikt nie ma czasu na tworzenie lub/i aktualizowanie dokumentacji. Posługiwanie się językiem innym niż taki, który da się skompilować lub przepuścić przez interpreter zajmuje w procesie tworzenia oprogramowania zwykle poślednie miejsce. Testy przychodzą z pomocą – często stanowią zestaw przykładów użycia dostępnych klas i fundament na którym możemy zbudować faktyczny kod w innych miejscach. Tutaj szczególnie sprawdza się metodologia BDD.
* Kontrola wydajności – często narzędzia wspomagające tworzenie i uruchamianie testów jednostkowych umożliwiają mierzenie czasu wykonania pojedynczego testu lub całego ich zestawu, a co za tym idzie – wydajności samego kodu aplikacji.

Jak to się robi?

Testy jednostkowe tworzy się z reguły w oparciu o jeden z dostępnych frameworków służących do tego celu, zwanych kolektywnie xUnit. Wszystkie są oparte o architekturę stworzoną przez Kenta Becka i zaimplementowaną pierwotnie w SUnit – wersji dla Smalltalka. Większość używanych obecnie języków programowania ma przynajmniej jedną taką bibliotekę – mamy zatem JUnit dla Javy, JSUnit dla JavaScriptu, oraz PyUnit, HUnit itd. Pełna lista znajduje się tutaj.

Na przykładzie użycia jednego z frameworków dostępnych dla Objective-C przybliżę w dalszej części główne elementy architektury unit testów.

Narzędzia dostępne dla Objective-C

Najpopularniejszą biblioteką z serii xUnit dla Objective-C jest OCUnit, framework stworzony przez szwajcarską firmę Sen:te. Został on wbudowany w Xcode 2.1 i od tego czasu jest dystrybuowany razem z nim. W styczniu 2008 została wydana biblioteka Google Toolbox for Mac, zawierająca wiele wartych uwagi rozszerzeń do OCUnit, umożliwiających między innymi testowanie oprogramowania tworzonego dla iPhone oraz tworzenie testów interfejsu użytkownika. Zautomatyzowane testy UI mogą stanowić nieoszacowaną pomoc w budowaniu aplikacji zawierających interfejs użytkownika, ponieważ są w stanie porównać dowolny jego fragment z plikiem zawierającym wzór, jaki oczekujemy zobaczyć.

Podstawy OCUnit

Podobnie jak wiele frameworków z serii xUnit, OCUnit udostępnia klasę bazową, z której należy dziedziczyć przy tworzeniu klas zawierających poszczególne metody testujące. Klasa ta, zwana SenTestCase, zapewnia również mechanizmy przygotowujące środowisko i ewentualne dane do przeprowadzenia testu, jak również czyszczące je po jego wykonaniu.

Załóżmy, że mamy klasę MCDocumentItem, będącą odwzorowaniem pozycji na fakturze i chcielibyśmy przetestować poprawność wyliczania sumy brutto posiadając sumę netto i stawkę podatkową. Pomińmy w tej chwili wszystkie księgowe niuanse i skupmy się na najprostszej ewentualności. Interfejs odpowiedniej klasy MCDocumentItemTest mógłby wyglądać następująco:

#import <SenTestingKit/SenTestingKit.h>
 
@class MCDocumentItem;
 
@interface MCDocumentItemTest : SenTestCase {
    MCDocumentItem *documentItem;
}
 
- (void)setUp;
- (void)tearDown;
 
- (void)testGrossCalculationFromNetAndVatValue;
@end

Jak widać posiadamy jedną metodę testującą w naszym teście – testGrossCalculationFromNetAndVatValue. OCUnit działa zgodnie z konwencją, że wszystkie metody zawierający nasz kod testujący będą posiadały nazwę zaczynającą się od “test”. Dwie pozostale metody, czyli setUp i tearDown są uruchamiane odpowiednio przed i po każdej z metod testowych. Implementacja naszej klasy mogłaby wyglądać w następujący sposób:

#import "MCDocumentItem.h"
#import "MCDocumentItemTest.h"
 
@implementation MCDocumentItemTest
- (void)setUp {
    documentItem = [MCDocumentItem new];
    [documentItem setName:@"Suchy chleb dla konia"];
    [documentItem setUnitName:@"kwintal"];
}
 
- (void)tearDown {
   documentItem = nil;
}
 
- (void)testGrossCalculationFromNetAndVatValue {
   [documentItem setNetPrice:[NSDecimalNumber decimalNumberWithString:@"100"]];
   [documentItem setVatRate:[NSDecimalNumber decimalNumberWithString:@"0.22"]];
   [documentItem setQuantity:[NSDecimalNumber decimalNumberWithString:@"1"]];
 
   STAssertEqualObjects([NSDecimalNumber decimalNumberWithString:@"122"], [documentItem grossValue], @"grossValue should be 122, but it was %f instead!", [[documentItem grossValue] floatValue]);
}
@end

Zaimplementowaliśmy zatem wszystkie trzy metody, jakie były nam potrzebne. W setUp tworzymy obiekt documentItem, z którego potem możemy korzystać w metodach testujących. W tearDown “sprzątamy” środowisko pozostawione przez nasze testy. Na dobrą sprawę nie musielibyśmy przypisywać pustej wartości do documentItem, ale gdybyśmy nie korzystali z Garbage Collectora, zapewne chcielibyśmy w tym miejscu zwolnić pamięć zajętą przez stworzone obiekty, lub przywrócić stan aplikacji/danych sprzed testu.

Na początku naszej metody testowej przypisujemy utworzonemu wcześniej przez setUp obiektowi wartości liczbowe, które posłużą nam do przeprowadzenia tego konkretnego testu. Ustalamy zatem cenę netto produktu na 100, wartość podatku na 0.22 oraz ilość produktu na 1. Oczekiwalibyśmy, że po ustaleniu takich parametrów obiektu klasy MCDocumentItem, wywołanie na nim metody grossValue zwróci wartość brutto równą 122. Sprawdzeniem, czy nasze oczekiwania zostaną spełnione zajmuje się w tym przypadku makro STAssertEqualObjects. Porównuje ono obiekt oczekiwany (zgodnie z obowiązującą konwencją jest pierwszym argumentem) z faktycznym obiektem, zwróconym przez wywołanie metody grossValue. Pozostałe argumenty są opcjonalne. Trzeci argument to wiadomość, jaką chcemy otrzymać kiedy okaże się, że obiekty się od siebie różnią. Jeśli nie podamy tego argumentu, dostaniemy standardowy komunikat informujący nas o różnicach w porównywanych obiektach, jednak czasem chcielibyśmy bardziej zaznaczyć okoliczności zdarzenia lub odpowiednio sformatować podawane informacje – trzeci i kolejne argumenty służą do tego celu. Stanowią one kolejne argumenty w metodzie stringWithFormat z klasy NSString, pozwalającej odpowiednio zbudować ciąg znaków.

OCUnit dostarcza wiele makr z rodziny STAssert*, takich jak:

STAssertNil
STAssertNotNil
STAssertTrue
STAssertFalse
STAssertEqualObjects
STAssertEquals
STAssertEqualsWithAccuracy
STAssertThrows
STAssertThrowsSpecific
STAssertNoThrow
STAssertNoThrowSpecific
STFail

Argumenty dla wymienionych makr budowane są analogicznie do opisanego przykładu – podajemy oczekiwaną wartość lub wartości wraz z komunikatem błędu.

Jak to działa w Xcode?

Aby uruchamiać testy z poziomu naszego projektu w XCode należy dodać nowy target. Na szczęście XCode dostarczany jest z OCUnitem, a bodajże od wersji 3.0 ustawianie testów nie wymaga już takiej ilości dodatkowego voodoo jak ongiś. Obecnie wystarczy właściwie dodać nowy target typu Unit Test Bundle i w zakładce General dodać do “Direct dependencies” istniejący target naszej aplikacji.

Uruchomienie testów polega na zmianie aktywnego targetu, następnie klasyczne “Build & Go”. Same komunikaty błędów sygnalizowane są wtedy inline w kodzie – jako standardowe ostrzeżenia kompilacji ze wskazaniem linii, w której znajduje się newralgiczna asercja:

Błędy testów w XCode

Błędy testów w XCode

Testy można uruchamiać także z poziomu linii poleceń, co otwiera drogę do automatyzacji tego procesu i zastosowania w praktyce Continous Integration.

Debugowanie samych testów to inna bajka – jeśli chcielibyśmy z jakiegoś powodu wstrzelić się naszym ulubionym debuggerem do kodu naszych testów (nie do testowanego kodu, tylko do kodu który go testuje :-), to niestety potrzebna jest odrobina magii, która polega na dodaniu nowej pozycji w projekcie, tym razem w sekcji Executables. Nowy executable to po prostu otrzymana w wyniku zwykłej kompilacji binarka naszego projektu z dodatkowym parametrem (informującym OCUnit o uruchomieniu wszystkich testów) i dodatkowymi zmiennymi środowiskowymi, które odczyniają pożądane voodoo. Po konkrety odsyłam do postu Chrisa Hansona.

Rozszerzenia w GTM

Google Toolbox for Mac wprowadza do OCUnit wiele przydatnych rozszerzeń. Pojawiły się nowe makra STAssert*, zbudowano prosty serwer HTTP do testowania aplikacji pobierających dane za pomocą tego protokołu, dostosowano bibliotekę testującą do iPhone i umożliwiono testowanie bindings.

Najciekawszym, moim zdaniem, rozszerzeniem jest wsparcie dla testowania interfejsu użytkownika. Wsparcie to dzieli się na dwa zestawy funkcjonalności. Pierwszy umożliwia porównywanie wewnętrznego stanu kontrolek z oczekiwanym, a drugi działa analogicznie do fragmentów UI zapisanych w plikach graficznych. Automatyczne testy UI nie są rzeczą trywialną, jednak GTM wydaje sie rozwiązywać je w bardzo przystępny sposób. Jeśli porównanie widoku (i jego subviews) wygenerowanego przez nasz kod z oczekiwanym zawiedzie, utworzy wtedy na pulpicie developera plik zawierający różnice pomiędzy wersjami a także obraz otrzymany w teście. Analogicznie dla porównywania stanu widoków i kontrolek, GTM zachowa się operując na plikach w formacie .plist.

Co bardzo ważne, GTM zawiera także wsparcie dla ustawiania przed testami UI spójnego środowiska, które pozwoli nam uniknąć niezgodności pomiędzy zmienionymi przez użytkownika ustawieniami systemu. Musimy jednak pamiętać, że jeśli test ma przechodzić na różnych wersjach systemu i rodzajach sprzętu musimy tworzyć oddzielne wersje obrazów testowych z uwagi na różnice jakich nie da się w takim przypadku uniknąć.

Podsumowanie

Objective-C nie ma zbyt wielu zaawansowanych narzędzi do testowania. W porównaniu do Ruby’ego, gdzie za pomocą RSpec możemy pisać testy (czy też, z uwagi na nomenklaturę BDD, specyfikacje) niemalże w języku angielskim, sprawdzać pokrycie kodu przy użyciu rcova, automatyzując to wszystko za pomocą CruiseControl.rb, czy autotesta, Objective-C wypada blado. Należy jednak pamiętać że nie jest to język interpretowany, w którym za pomocą metaprogramistycznych sztuczek możemy zdziałać cuda.

Jednak i na poletku makowych developerów (makowym poletku?) zaczynają pojawiać się rozwiązania wypełniające tą lukę. Bardzo obiecująco wygląda GH-Unit dostarczający atrakcyjny interfejs do wykonywania testów i kilka innych ważnych funkcjonalności. W podobnym kierunku idzie OCRunner pozwalający z kolei wybierać testy, które chcemy uruchamiać. Przy większej ich ilości, ciągle testowanie całej aplikacji może być dosyć uciążliwe.

Testowanie nie mogłoby się obejść również bez udawanych obiektów – tutaj z pomocą przychodzi OCMock.

Jeśli udało mi się zaciekawić Cię tematyką testów w Objective-C, dalej poprowadzą Cię artykuły z poniższych linków:

http://chanson.livejournal.com/tag/unit+testing

http://developer.apple.com/tools/unittest.html

http://code.google.com/p/google-toolbox-for-mac/wiki/CodeVerificationAndUnitTesting

Jedna odpowiedź do “Testy jednostkowe w Objective-C”

  1. Norbert napisał(a):

    Naprawdę interesujący ficzer rozszerzeń Google’a do OCUnitu to testowanie interfejsów użytkownika. Macie jakieś doświadczenia jak się sprawdza w praniu?

    Ciekawe też, ile ma wspólnego z testami renderingu prowadzonymi przez Google’a przed startem bety Chrome’a :)