Automatyzacja Testowania
Dariusz Cieślak
Kto z nas nie chciałby używać oprogramowania, które się nie wiesza i zawsze spełnia swoje zadanie ? Niezawodne systemy są rzadkością. Wynika to często z niedbałości programistów i braku systematycznego podejścia do spraw jakości. Jednym ze sposobów jej podniesienia jest testowanie. W tym artykule postaram się przedstawić techniki testowania stosowane w Extreme Programming (o XP pisaliśmy już kilkakrotnie na łamach Software 2.0).
Jednym z podstawowych postulatów XP jest automatyczne Testowanie Oprogramowania zarówno jednostkowe jak i integracyjne. Testy zapewniają nam sprzężenie zwrotne, które dostarcza informacji o jakości utworzonego kodu. XP posuwa się jeszcze o krok dalej -- stawia testy jako narzędzie wspomagające proces implementacji. Wyniki testów mówią nam o stanie zaawansowania projektu.
Testowanie na poziomie interfejsu użytkownika
Najprostsze testowanie można przeprowadzić interaktywnie, poprzez uruchomienie aplikacji, wykonaniu kilku operacji i obejrzenie wyników. Postulat nieinteraktywności testów, stawiany przez XP, wymaga jednak by aplikacja była testowana bez ingerencji człowieka. Oczywiście testy, jako takie, nie napiszą się same -- specyfikacja wymagań należy do człowieka. Automatyzacja obejmuje jedynie wykonanie testów.
Do automatycznego testowania potrzebujemy "bliższego" dostępu do aplikacji niż tylko przez jej najwyższą warstwę - interfejs użytkownika. Jakkolwiek istnieją narzędzia, które potrafią nagrywać sekwencję operacji w aplikacji GUI, ale obarczone są podstawową wadą - większość uzależnia powodzenie testu od fizycznego położenia kontrolek na ekranie. Kiedy nastąpi kosmetyczna zmiana "layoutu" - testy przestają działać i trzeba nagrywać je od początku. Stabilniejszą podstawę testów stanowi tzw. model aplikacji.
Testowanie na poziomie modelu
W pozycji "Design Patterns, Elements of Reusable Object-Oriented Software" dotyczącej wzorców projektowych w projektowaniu oprogramowania znajduje się opis wzorca projektowego "Model Widok Kontroler" (ang. Model View Controler, często opisywany w literaturze skrótem MVC). Wzorzec ten pokazuje jak separować warstwy aplikacji odpowiedzialne za logikę biznesową (Model) od warstwy opisującej interfejs użytkownika (Widok, Kontroler). W świecie MS wzorzec ten znany jest powszechnie jako Model-Dokument (terminologia przyjęta w bibliotece MFC).
Ponieważ nieinteraktywne testowanie aplikacji na poziomie widoku sprawia problemy, testujemy automatycznie model aplikacji. Typowe działanie to utworzenie instancji testowanej klasy (klas), wywołanie kilku metod, odebranie i sprawdzenie wyników poprzez assert(). Wydaje się, że żadnych dodatkowych narzędzi tu nie potrzeba, sprawa jest dosyć prosta. Jednak po dłuższej praktyce nasuwają się pewne wymagania:
- Przydała by się organizacja testów w zbiory tak, by można było uruchomić wybrane testy (przy dużej ilości testów czas uruchomienia całości może nie być mały)
- Niepowodzenie jednego testu nie powinno przerywać pracy programu, pozostałe testy powinny się wykonać by zebrać za jednym razem jak najwięcej informacji (biblioteczna implementacja assert() z języka C kończy program)
- Powinny być generowane raporty wykonania testów (ilość testów w sumie, ilość błędnych, czas wykonania testów)
- Asercje powinny pozwalać sprawdzać nie tylko wartości boolowskie, ale także móc porównywać obiekty różnych typów. W przypadku niepowodzenia, powinny się pokazać: wartość oczekiwana i otrzymana. Pozwoli to zmniejszyć użycie debugera przy uruchamianiu kodu (nie wszystkie środowiska dostarczają wygodnego narzędzia tego typu).
Zanim opiszemy sposób realizacji powyższych wymagań przyjrzyjmy się klasyfikacji testów i ich zastosowaniu w Extreme Programming.
Rodzaje testów
Testy jednostkowe - z definicji testują klasę / moduł w izolacji od innych komponentów. Są proste w stosowaniu jeśli sprzężenia z innymi komponentami systemu są prawidłowo zaprojektowane (klasa "poddaje się" testowaniu w izolacji). Testy te są tworzone przez programistów.
Testy akceptacyjne - wysokopoziomowa specyfikacja aplikacji na poziomie integracyjnym. Testy opisują, co klient chce od naszego systemu uzyskać. W sytuacji idealnej klient tworzy testy w jakimś prostym zapisie skryptowym (tak, by dawały się wykonywać). W ostateczności mogą to być wymagania wyrażone w języku naturalnym, transformowane potem do formy wykonywalnej (kodu) przez programistę.
Testy interaktywne - to nie jest pomyłka. System powinien być także testowany interaktywnie ponieważ z testowania automatycznego wyłączyliśmy warstwę interfejsu użytkownika. Zawsze pewien fragment kodu pozostaje niewykonany, dążymy do tego, by ten fragment był jak najmniejszy. Zwykle testy interaktywne pozwalają wykryć błędy wizualne np. nieporęczne rozmieszczenie kontrolek na ekranie, źle dobrane kolory. Do fazy testowania interaktywnego nie powinny przenikać błędu modelu np. błąd składni w zapytaniach SQL, ponieważ błędy te powinny zostać wykryte przez testy jednostkowe.
We wszystkich rodzajach testów pomocne będą narzędzia badające tzw. pokrycia (np. dla Pythona pycover). Narzędzia te umożliwiają wyznaczenie fragmentów kodu, który nie został poddany testom. Dzięki temu możemy udoskonalać nasze testy dodając przypadki testowe o których zapomnieliśmy.
Testowanie w Extreme Programming
Typowy cykl pracy podczas dodawania nowej funkcjonalności to:
- Zdefiniowanie testów dla wymagań. Nie powinno się za pierwszym razem starać opisać wszystkiego -- dodajemy funkcjonalność w szeregu małych kroków. Próba kompilacji takiego kodu powinna się zakończyć niepowodzeniem, ponieważ nie ma jeszcze testowanych obiektów.
- Na podstawie klas i metod opisanych w testach tworzymy szkielety klas, które mają realizować testy. Zanim dodamy jakąkolwiek funkcjonalność należy uruchomić testy. Uruchomienie powinno zakończyć się niepowodzeniem -- nie zdefiniowaliśmy jeszcze treści metod. Krok ten pozwala sprawdzić, czy testy rzeczywiście się wykonują (możemy przecież zapomnieć dodać wywołanie kodu testującego).
- Teraz implementujemy kolejne wymagania i za każdym razem uruchamiamy testy -- ilość błędów powinna maleć. Kiedy wszystkie testy będą "przechodziły", to można przystąpić do specyfikowania kolejnej "porcji" funkcjonalności (dodawać testy o których się zapomniało).
Inną, typową sytuacją spotykaną podczas programowania jest zmiana funkcjonalności. Szczególnie często ona występuje przy przyrostowym tworzeniu systemu. Zmieniając istniejącą funkcjonalność można jednak spowodować błędy w kodzie, który zależy od właśnie zmodyfikowanego kodu. Oczywiście znowu zaczniemy od testów:
- Zanim zmienimy kod należy poprawić testy, by odzwierciedlić zmianę specyfikacji dla testowanego modułu. Uruchomienie testów powinno pokazać błędy -- jeśli tak nie jest, to najprawdopodobniej testy się nie wykonują.
- Dopiero teraz zmieniamy implementację, by dopasować ją do testów aż do osiągnięcia zerowej ilości błędów. Jeśli zmieniona funkcjonalność była wymagana przez inny fragment systemu, to natychmiast otrzymamy informację o tym od testu rezydującego w innym obszarze systemu. Pozwala to na minimalizację niebezpieczeństwa "popsucia" istniejącego kodu.
Podobnie postępujemy w momencie znalezienia błędu -- najpierw dodajemy test ujawniający znaleziony błąd. Następnie uruchamiamy testy, by upewnić się, że błąd rzeczywiście jest wykrywany. Dopiero wtedy, posiadając testy "wyczulone" na zlokalizowany błąd, aktualizujemy implementację. Poprawka jest zakończona wtedy, gdy testy kończą się sukcesem.
Narzędzia wspomagające

Rysunek 1. JUnit podczas wystąpienia błędu
Testowanie, jako jedna z podstawowych technik zapewnienia jakości, dorobiło się wielu narzędzi wspomagających. Ich zadaniem jest uruchamianie testów i pokazywanie raportów opisujących wyniki. Raporty mogą być wykonane w środowisku tekstowym lub graficznym. Na rysunku 1 pokazane jest typowy wygląd formatki po wystąpieniu błędu. Pasek zaawansowania przybiera kolor czerwony kiedy jeden z testów zakończy się niepowodzeniem. Kiedy wszystkie testy zakończą się sukcesem, na ekranie pojawia się pasek w kolor zielonym (Rysunek 2).

Rysunek 2. JUnit po wykonaniu testów zakończonych sukcesem
JU nit dla Javy
Chyba najbardziej znaną biblioteką służącą do konstruowania testów jest JUnit dla języka Java. Rekomendacją tej biblioteki są niewątpliwie znane nazwiska autorów: Kent Beck (Extreme Programming) i Erich Gamma (wzorce projektowe). Kontrukcja biblioteki zresztą wyraźnie świadczy o rodowodzie JUnit. Idea biblioteki wspomagającej wywodzi się z języka Smalltalk, w którym Kent Beck napisał pierwszą jej implementację.
Kiedy wiemy już jak wydzielić z aplikacji model, możemy zabrać się do przygotowania testów. Przykładem, którym się zajmniemy będzie klasa operująca na liczbach wymiernych.
Listing 1
public class WymiernaTest extends TestCase
{
public void runTest()
{
Wymierna w1 = new Wymierna(1,2);
Wymierna w2 = new Wymierna(1,4);
Wymierna wynik = new Wymierna(3,4);
w1.dodaj(w2);
assert(w1.equals(wynik));
}
}
Została utworzona specjalna klasa testowa (Wymierna Test, Listing 1), która dziedziczy z klasy Test Case należącej do JUnit. W Test Case została zaimplementowana metoda assert(), która sprawdza, czy wyrażenie podane jako argument jest prawdą.
To jest tylko jeden test. Jeśli zamierzamy dodać kolejne (a zapewne będziemy chcieli to zrobić), testy te będą wyglądały bardzo podobnie. W każdym z takich testów można wyróżnić następujące fazy:
- Utworzenie obiektu(ów), które będą testowane
- Wykonanie określonych operacji
- Zwolnienie zasobów (jeśli jakieś zasoby zostały pobrane)
Kod tworzący obiekty do testów i zwalniający zasoby może być współdzielony pomiędzy poszczególnymi testami. Kod taki umieszczamy w metodzie setUp(), a metoda zwalniająca zasoby ("sprzątająca") ma nazwę tearDown() (Listing 2). Zbiór obiektów, który podlega testowaniu nazywany jest Fixture.
Listing 2
public class WymiernaTest extends TestCase
{
Wymierna w1;
Wymierna w2;
protected void setUp()
{
w1 = new Wymierna(1,2);
w2 = new Wymierna(1,4);
}
protected void tearDown()
{
/* tu nie trzeba zwalniać zasobów */
}
public void testDodaj()
{
Wymierna wynik = new Wymierna(3,4);
w1.dodaj(w2);
assert(w1.equals(wynik));
}
public void testMnoz()
{
Wymierna wynik = new Wymierna(1,8);
w1.mnoz(w2);
assert(w1.equals(wynik));
}
}

Rysunek 3. Ślad stosu po wykryciu błędu w JUnit
Aby uruchomić kod należy skonstruować instancję testów z podaną nazwą metody z testem. Metoda run() wywoła odpowiednią metodę testową korzystając a refleksji Javy (Listing 3).
Listing 3
TestResult result =
(new WymiernaTest("testDodaj")).run();
TestResult result =
(new WymiernaTest("testMnoz")).run();
Domyślna implementacja metody suite() tworzy zbiór testów, korzystając z refleksji Javy. Wybierane są wszystkie metody klasy zaczynające się od "test". Podczas wykonywania zbioru testów kod setUp() musi wykonywać się przed każdą metodą testową a tearDown() po każdej metodzie testowej. Jest to spowodowane postulatem izolacji testów tak, by stan zmieniony przez jeden test (np. testDodaj()) nie był widoczny w innym teście (testMnoz()). Gdyby nie takie działanie metod tworzących/zwalniających dane testowe jeden z testów by się nie powiódł w skutek zmiany zmiennej w 1.
Kolejność wykonania metod testowych nie jest zdefiniowana, nie należy wykorzystywać zależności pomiędzy poszczególnymi testami (powinny one być od siebie izolowane). Zauważmy, że setUp() pełni rolę niejako "wielokrotnego konstruktora", a tearDown() wielokrotnego destruktora w kontekście jednego obiektu testowego.
Grupujemy testy
Kiedy mamy przygotowanych kilka takich przypadków testowych można je zgrupować w zbiór testowy (Test Suite, Listing 4). Po co grupujemy? - aby zapewnić spójność podawanych statystyk testów. Przy użyciu klasy Test Suite można zamknąć w jedną grupę testów całą aplikację i wszystkie testy "odpalać" poprzez uruchomienie jednego Test Suite.
Listing 4
public static Test suite()
{
TestSuite suite= new TestSuite();
suite.addTest(new
MoneyTest("testMoneyEquals"));
suite.addTest(new
MoneyTest("testSimpleAdd"));
return suite;
}
Warto jeszcze dodać, że JUnit rozróżnia pomiędzy błędami oczekiwanymi wykrytymi przez asercje (errors) a błędami nieoczekiwanymi jak np. przekroczenie zakresu tablicy (failure).
Inne języki

Rysunek 4. Biblioteki wspomagające powstały dla wielu języków - tu wersja dla Perla
Oczywiście implementacje bibliotek wspomagających testowanie powstały chyba dla wszystkich języków programowania. Na rysunku 4 pokazany jest interfejs dla perlunit - implementacji dla Perla. Powstały również wersje dla języków "komercyjnych": np. Visual Basica (VBUnit).
Pakiet unittest znajduje się w standardowej dystrybucji Pythona. Zawiera w sobie wszystkie udogodnienia niezbędne do uruchamiania testów. Na listingu 5 pokazano test o treści identycznej jak powyższy przykład, ale napisany w Pythonie.

Rysunek 5. Przykład niespełnienia asercji w pyunit
Listing 5
class WymiernaTest(TestCase):
def setUp(self):
self.w1 = Wymierna(1,2)
self.w2 = Wymierna(1,4)
def tearDown(self):
# tu nie trzeba zwalniać zasobów
pass
def testDodaj(self):
wynik = Wymierna(3,4)
self.w1.dodaj(w2);
self.assert_(self.w1 == wynik)
def testMnoz(self):
wynik = Wymierna(1,8)
self.w1.mnoz(self.w2)
self.assert_(self.w1 == wynik)
Przykładowe zastosowania
Bardzo ciekawym systemem, w którym zastosowano na szeroką skalę TDD (Test Driven Development) jest projekt Open Source o nazwie "Eclipse". Ma on na celu utworzenie środowiska programistycznego (IDE). Jak piszą sami autorzy "The Eclipse platform is an IDE for anything and, for nothing in particular" - w wolnym tłumaczeniu środowisko programistyczne do wszystkiego.
Dzięki zaawansowanej architekturze Eclipse pozwala rozszerzać swoją funkcjonalność przez dodawanie tzw. "wtyczek", które rozszerzają podstawową funkcjonalność. System powstaje w języku Java i korzysta z JUnit jako platformy uruchamiania testów. "Eclipse" dostarczone jest z ogromnym zestawem testów zaimplementowanych przy użyciu właśnie tej biblioteki. Dla każdej wersji Eclipse przygotowany jest raport pokazujący, które testy zakończyły się pomyślnie, a które nie.
Innym, godnym wspomnienia projektem, który jest silnie związany z testowaniem jest Fit. Stworzony przez uznane autorytety w świecie XP Warda Cunningham'a i Jima Little'a, pozwala definiować testy akceptacyjne poprzez tabelki HTML. Wymagania te następnie możemy sprawdzić poprzez wykonanie tak przygotowanych testów. Całość dostępna jest przez przeglądarkę WWW przez mechanizm tzw. Wiki. Myślę, że warto przyjrzeć się temu projektowi, ponieważ stanowi nową jakość w dziedzinie testów akceptacyjnych.
Podsumowanie
Testowanie jest podstawowym składnikiem Extreme Programming i "dorobiły się" zestawu dedykowanych narzędzi. Należy jednak podkreślić, że to nie narzędzia ani technologia odgrywają podstawową rolę w skutecznym testowaniu oprogramowania. Najważniejsza praca to konsekwentne definiowanie testów dla dodawanej funkcjonalności. Istnieje pokusa, by fragmenty aplikacji dopisać bez wcześniej przygotowanych testów - jest to szybsze. Jednakże czas "oszczędzony" na przygotowaniu testów będzie później "marnowany" na poprawianie nieprzetestowanego kodu, a jak wiemy koszt poprawek znacznie rośnie z czasem.
Ileż razy zdarzyło nam się poprawiać błędy tuż przed oddaniem systemu klientowi ? Ile nowych błędów zakradło się do naszego systemu podczas takich operacji ? Zautomatyzowane testowanie w stylu XP pozwala takich sytuacji uniknąć - w kilkadziesiąt sekund kolejno wykonujące się testy upewniają nas, że produkt, który opracowaliśmy można oddać klientowi. Testowanie nie gwarantuje nam braku błędów, ale pozwala je wcześnie wykryć i usunąć.
- http://www.extremeprogramming.com - wprowadzenie do Extreme Programming
- http://www.junit.org - strona poświęcona bibliotece JUnit
- news:comp.software.testing - grupa dyskusyjna poświęcona testowaniu
- http://www.eclipse.org/ - strona domowa projektu Eclipse
- http://fit.c2.com - strona domowa projektu Fit