Automatyczne testowanie oprogramowania
Łukasz Juszkiewcz
Automatyczne testowanie jest jednym z głównych postulatów eXtreme
Programming (XP). Automatyzacja polega na użyciu oprogramowania do sterowania
wykonaniem testów, porównywania ich wyników z oczekiwanymi, czy raportowania.
Testowanie oprogramowania wymaga dostarczenia przypadku testowego (test
case) – częściej całego zestawu przypadków testowych dla testowanego systemu (test
case suite) – przez programistę, uruchomienia programu z użyciem przygotowanego
zestawu przypadków testowych i sprawdzeniu, czy przy danych wejściowych
pochodzących z przpadku testowego zachowanie programu jest zgodne z oczekiwanym.
Wszystkie te trzy aspekty testowania oprogramowania mogą zostać zautomatyzowane w
mniejszym lub większym stopniu.
Z powyższych aspektów, najprostszym do zautomatyzowania jest wykonanie
przypadków testowych. Dla aplikacji, które nie wchodzą w interakcję z użytkownikami
(lub ich interakcja ogranicza się do komend wpisywanych klawiaturą), łatwo jest napisać
mały program, nazywany skryptem testowym (test script), generujący odpowiednie dane
wejściowe. Nieco trudniej wykonać to dla programów z graficznym interfejsem, jednak
istnieją narzędzia pozwalające testować tego typu aplikacje. Potrafią one nagrywać
sekwencje wykonywanych operacji w testowanym programie, jednak podstawową wadą
tych narzędzi jest zależność wyniku testu od umiejscowienia poszczególnych
komponentów interfejsu na ekranie. W razie zmiany ich ułożenia lub usunięcia
istniejącego komponentu testy przestają działać poprawnie i należy je ponownie
zdefiniować.
Czasami także sprawdzenie, czy dane wyjściowe aplikacji są zgodne z
oczekiwanymi jest łatwe do zautomatyzowania – na przykład w programie wykonującym
jakieś matematyczne obliczenia na danych wejściowych i zapisującym ich rezultat w
pliku wyjściowym. Istnieje jednak wiele sytuacji, kiedy sprawdzenie zachowania
aplikacji nie jest możliwe poprzez zwykłe porównanie z oczekiwanymi danymi –
przykładowo, w programie do kompresji audio test prawidłowego działania aplikacji
polega na wysłuchaniu skompresowanej próbki i subiektywnej ocenie jej jakości.
Jednak w rozwoju sporej części oprogramowania automatyczne testowanie jest
powszechną praktyką, a narzędzia je wspomagające są szeroko dostępne, wiele z nich to
wolne czy otwarte oprogramowanie.
Automatycznie generowane przypadki testowe są jednak dużo bardziej złożonym
problemem i użycie takich automatycznie tworzonych przypadków testowych jest o
wiele rzadsze. Jednym ze sposobów generowania przypadków testowych jest
testowanie na poziomie modelu (model-based testing), gdzie model systemu jest
używany do automatycznego tworzenia przypadków testowych, jednak nie zakończono
badań nad sposobami realizacji tego zadania.
W książce Wzorce projektowe. Elementy oprogramowania obiektowego
wielokrotnego użytku John Vlissides, Ralph Johnson, Erich Gamma, Richard Helm
(“Banda Czworga” - “Gang of Four”) przedstawili wzorzec projektowy Model-Widok-
Kontroler (Model-View-Controler – MVC). Pokazuje on, jak separować warstwy aplikacji
odpowiedzialne za logikę (Model) od warstwy opisującej interfejs użytkownika (Widok,
Kontroler). Jak wiadomo, automatyczne testowanie aplikacji na poziomie Widoku
sprawia problemy, automatycznie testuje się głównie model aplikacji. Standardowa
metoda to stworzenie obiektu testowanej klasy (klas), wykonaine kilku metod i
sprawdzenie wyników ich działania. Dodatkowo powinny być spełnione następujące
warunki:
1. możliwośc uruchomienia poszczególnych testów z całego zestawu;
2. niezależne wykonywanie testów: wynik (np. niepowodzenie) jednego testu nie
powinno mieć wpływu na pozostałe – umożliwi to uzyskanie jak najawiększej ilości
informacji na temat działania całego systemu;
3. tworzenie raportów z wykonanych testów ze szczegółowymi danymi i statystykami
– np. w przypadku niepowodzenia powinny zawierać wartości oczekiwane i
otrzymane.
Testowanie w eXtreme Programming.
XP testy dzieli się na trzy rodzaje:
1. jednostkowe – służą do testowania pojedynczej klasy (modułu) odizolowanej od
pozostałych komponentów; pisane przez programistów;
2. akceptacyjne – opisują oczekiwania klienta wobec systemu; tworzone przez
klienta – z reguły w prostym języku skryptowym, rzadziej w języku naturalnym,
kodowane następnie przez programistę;
3. interaktywne – nieautomatyczne testy służące do wykrywania błędów wizualnych:
nieprawidłowego rozmieszczenia elementów interfejsu użytkownika, mało
estetycznego wyglądu komponentów; jeśli testy jednostkowe zostały dobrze
zaprojektowane, w testach interaktywnych nie powinny istnieć błędy modelu, jak
nieprawidłowe wyniki obliczeń, nieprawidłowa implementacja komunikacji z
pozostałymi komponentami systemu.
Standardowo, dodawanie nowej funkcjonalności do systemu jest realizacją
poniższego schematu (cyklu):
1. Zaprojektowanie i zdefiniowanie przypadków testowych dla wymagań – nie należy
od razu opisywać wszystkich cech: należy zacząć od najprostszych i najmniejszych
kroków, stopniowo zwiększając ich zakres. Na tym etapie kompilacja utworzonego
kodu zakończy się niepowodzeniem z powodu braku testowanych obiektów.
2. Opierając się na klasach i metodach opisanych w testach należy stworzyć szkielet
klas, które będą realizowały przypadki testowe. Przed dodaniem jakiejkolwiek
funkcjonalności należy uruchomić testy, aby sprawdzić, czy testy są wykonywane
(bez zdefiniowanych treści testowanych metod, testy zakończą się
niepowodzeniem).
3. Implementacja kolejnych wymagań. Po każdej zmianie należy uruchomić testy –
liczba błędów będzie maleć z każdą poprawną implementacją. Po pozytywnym
wykonaniu wszystkich testów można zdefiniować kolejne przypadki testowe,
bardziej kompleksowe.
Często podczas tworzenia czy rozwijania jakiegoś systemu klient nalega na zmianę
istniejącej już funkcjonalności. Jeśli na tym etapie pominie się stworzenie odpowiednich
testów, podczas modyfikacji kodu mogą powstać nowe, trudne do znalezienia i
poprawienia w późniejszym etapie błędy. Ab tego uniknąć, należy:
1. Poprawić testy, aby w pełni odzwierciedlały zmianę specyfikacji testwoanego
fragmentu aplikacji. Jeśli po uruchomieniu testów, nie występują błędy,
prawdopodobnie zmienione przypadki testowe nie są wykonywane.
2. Poprawiać implementację tak, by testy nie wykazywały już żadnych błędów. W
przypadku zmiany funkcjonalności wymaganej w innej części programu, przypadki
testowe uruchamiane w owym fragmencie systemu zakończą się niepowodzeniem,
co pozwoli uniknąć w dużej mierze psucia istniejącego kodu.
W razie, gdy znaleziono błąd, którego do tej pory nie uwzględniały żadne testy,
najpierw należy dodać przypadki testowe, które powinny go wykryć. Następnie trzeba
uruchomić testy, aby sprawdzić, czy nowy zestaw faktycznie wykrywa błąd. Poprawka
zostaje zakończona, gdy wszystkie testy kończą się sukcesem.
Narzędzia do automatycznego testowania.
Automatyczne testowanie, jako jeden z głównych postulatów XP, doczekał się
wielu narzędzi ułatwiających jego realizację podczas pracy z każdym językiem
(obiektowym). Oto najpopularniejsze rozwiązania dla wybranych języków.
Java – JUnit
JUnit jest najpopularniejszą biblioteką służącą do konstruowania testwów. Jej
popularność i powszechne użycie (w tym wypadku idące w parze z jakością) jest przede
wszystkim wynikiem popularności jej autorów, którymi są m. in. Kent Beck (jeden z
największych autorytetów eXtreme Programming) oraz Erich Gamma (Gang of Four) –
zresztą kostrukcja JUnit jest konsekwencją jej pochodzenia; Kent Beck napisał jej
piwerszą implementację w Smalltalk'u.
Mając wydzielony model aplikacji, można przygotować przypadki testowe.
Załóżmy, że należy napisać klasę operującą na napisach.
public class NapisTest extends TestCase
{
public void runTest()
{
Napis nDu = new Napis(
“du”
);
Napis nPa = new Napis(
“pa”
);
Napis nDupa = new Napis(
“dupa”
);
nDu.zloz(nPa);
assert(nDu.equals(nDupa));
}
}
Stworzona specjalna klasa testowa, która dziedziczy z klasy TestCase nalezącej
do JUnit. Metoda assert(), pochodząca z tej klasy, sprawdza czy wyrażenie będące
jej argumentem jest prawdą.
Napisana klasa zawiera tylko jeden test. Każdy następny wygląda podobnie:
1. stworzenie testowanych obiektów;
2. wykonanie wymaganych operacji;
3. zwolnienie zasobów (jeśli zostały jakieś pobrane).
Kod z podpunktów 1. i 2. może być współdzielony między wszystkimi testami.
Umieszcza się go w metodach odpowiednio setUp() i tearDown(). Zbiór testowanych
obiektów nazywany jest Fixture.
public class
NapisTest extends TestCase
{
Napis nDu;
Napis nPa;
protected void setUp()
{
nDu = new Napis(
“du”
);
nPa = new Napis(
“pa”
);
}
protected void tearDown()
{
/* brak zasobów do zwolnienia */
}
public void testZloz()
{
Napis nDupa = new Napis(
“dupa”
);
nDu.zloz(nPa);
assert(nDu.equals(nPa));
}
public void testPodnapis()
{
Napis wynik = new Napis(
“p”
);
nDu = nPa.podnapis(0,1);
assert(nDu.equals(wynik));
}
}
W celu uruchomienia kodu, należy skonstruować instancje testów z podanymi
nazwami metod z testem. Metoda run(), korzystając z refleksji w Javie, wywołuje
odpowiednią metodę testową.
TestResult result = (new NapisTest(
"testZloz"
)).run();
TestResult result = (new NapisTest(
"testPodnapis"
)).run();
Domyślna implementacja metody suite() tworzy zbiór testów na podstawie nazw
metod klasy testowej - wybierane są wszystkie zaczynające się od "test". Przed każdą
metodą testową wykonywany jest kod metody setUp(), a zaraz po zakończeniu metody
testowej wykonywana jest metoda tearDown(). Dzięki temu przypadki testowe są
odizolowane od siebie i wykonanie jednego nie ma wpływu na pozostałe.
Po przygotowaniu kilku przypadków testowych, można je pogrupować w zbiory
przypadków testowych.
public static Test suite()
{
TestSuite suite= new TestSuite();
suite.addTest(new NapisTest(
"testZloz"
));
suite.addTest(new NapisTest(
"testPodnapis"
));
return suite;
}
Python - PyUnit
PyUnit to bazujący na JUnit moduł do testwoania kodu napisanego w Pythonie,
będący integralną częścią standardowej biblioteki Pythona 2.1.
Podobnie jak JUnit, PyUnit używa klasy TestCase z modułu unittest
reprezentującej przypadki testowe i tak jak w JUnit, aby napisać własne testy, trzeba
rozszerzyć tę klasę. Przykładowa klasa testowa pochodzi z dokumentacji PyUnit:
import unittest
class DefaultWidgetSizeTestCase(unittest.TestCase):
def runTest(
self
):
widget = Widget(
"The widget"
)
assert widget.size() == (50,50),
'incorrect default size'
Aby utworzyć instancję tak zdefiniowanego przypadku testowego, trzeba wywołać
konstruktor stworzonej klasy testowej bez argumentów:
testCase = DefaultWidgetSizeTestCase()
Analogicznie do JUnit tworzy się kod odpowiedzialny za tworzenie testowanych
obiektów, zwalnianie zasobów i implementuje część Fixtures:
import unittest
class WidgetTestCase(unittest.TestCase):
def setUp(
self
):
self.widget = Widget(
"The widget"
)
def tearDown(
self
):
self.widget.dispose()
self.widget =
None
def testDefaultSize(
self
):
assert self.widget.size() == (50,50),
'incorrect default size'
def testResize(
self
):
self.widget.resize(100,150)
assert self.widget.size() == (100,150), \
'wrong size after resize'
Stworzenie instancji testów w PyUnit wygląda następująco:
defaultSizeTestCase = WidgetTestCase(
"testDefaultSize"
)
resizeTestCase = WidgetTestCase(
"testResize"
)
Tak zdefiniowane testy można dodać do zbioru przypadków testowych:
widgetTestSuite = unittest.TestSuite()
widgetTestSuite.addTest(WidgetTestCase(
"testDefaultSize"
))
widgetTestSuite.addTest(WidgetTestCase(
"testResize"
))
Wygodnie jest stworzyć obiekt zwracający zestaw testów z wcześniej zdefiniowanych
przypadków testowych:
def suite():
suite = unittest.TestSuite()
suite.addTest(WidgetTestCase(
"testDefaultSize"
))
suite.addTest(WidgetTestCase(
"testResize"
))
return suite
Podsumowanie
Jak widać na przykładach, automatyczne testowanie nie jest zadaniem
wymagającym ponadprzeciętnych zdolności programistycznych, zwłaszcza, gdy używa
się narzędzi testujących dla używanego języka, bazujących na JUnit – wówczas
tworzenie testów polega na stosowaniu tego samego, powszechnego schematu z
uwzględnieniem jedynie składni danego języka.
Podstawowym “mankamentem” jest zwiększona ilość kodu potrzebna na dodanie
nowej funkcjonalności (każdorazowe tworzenie przypadków testowych przed
implementacją poszczególnych założeń specyfikacji). Jednak ów “mankament” zostanie
niejednokrotnie wynagrodzony dzięki uniknięciu wielu godzin poszukiwania i naprawiania
błędów, które dzięki automatyzacji testowania są natychmiastowo wyłapywane.
Dodatkowo programowanie ukierunkowane testami (Test-Driven Development –
TDD) opierające się na technice automatycznego testowania, nie jest jedynie “kolejnym
sposobem samego programowania”, ale jest także swego rodzaju sposobem
projektowania systemu i gwarantem spełniania przez niego założonej specyfikacji, która
jest zdefiniowana w przypadkach testowych.