Projektowanie systemów informatycznych (część 7)

Sebastian Wyrwał

Testowanie oprogramowania

Prezentowane dotychczas rozważania dotyczyły ogólnych
zagadnień inżynierii oprogramowania, oraz różnych metod projektowania
oprogramowania. Omówiono metody strukturalne, obiektowe i formalne. Ważnym
etapem cyklu życia oprogramowania jest testowanie, które de facto łączy się
nierozerwalnie z projektowaniem. Trudno oczekiwać, iż w procesie projektowania
a później implementacji, nie popełni się żadnych błędów. Nawet
komercyjne produkty najbardziej renomowanych firm mają różne luki, które
skutkują różnego typu zagrożeniami. Testowanie zasadniczo ma sprawdzić, czy
produkt działa poprawnie tzn. zgodnie ze specyfikacją. Nawet w przypadku
systemów, od których działania nie zależy życie (zdrowie ludzi), bądź
nawet poprawność jakichkolwiek operacji finansowych, testowanie jest bardzo ważne.
Od niego zależy w dużej mierze wizerunek producenta w oczach klientów. Prosty
mechanizm sprawia, że jeśli produkt nie działa poprawnie to klient nie kupi
następnego. Ten kto liczy, iż programiści piszą bezbłędny kod, a
projektanci tworzą projekty, w których przewidziano wszystkie możliwe
sytuacje i nie testuje – tworzy oprogramowanie niskiej jakości.
Przenoszenie ciężaru testowania na użytkowników końcowych jest ich
karygodnym lekceważeniem. Choć w praktyce można spotkać się i z taką
sytuacją, w której do klienta trafia produkt zupełnie nie przetestowany.
Oczywiście poprawienie błędów nawet gdy system jest już zainstalowany u
klienta jest możliwe w przypadku systemów tworzonych na zamówienie. Znacznie
gorzej wygląda sprawa w przypadku oprogramowania sprzedawanego z półki.
Sytuacja nie jest beznadziejna, można bowiem wypuszczać łatki do systemu,
trzeba jednak albo wiedzieć komu je dostarczyć, lub klienci muszą wiedzieć,
że owe łatki istnieją, gdzie można je pobrać i jak zainstalować. Jako
przykład narzędzia do testowania zostanie omówione narzędzie JUnit. Autorami
tego pakietu są Erich Gamma i Kent Beck. Pierwszy z nich jest współautorem
bardzo znanej książki: Design Patterns (do której wielokrotnie się w tym
cyklu odwoływano). Program jest na licencji Common Public Licence i jest
dostarczany razem ze źródłami. Pozwala to na zapoznanie się z interesującym
kodem źródłowym i najlepszymi praktykami projektowo-programistycznymi. Warto
jeszcze dodać, iż na studiach informatycznych testowanie oprogramowania
traktuje się bardzo marginalnie. Jest dużo przedmiotów związanych z
programowaniem, niekiedy projektowaniem, zaś testowanie pozostaje na uboczu.
Wydaje się, że sytuacja taka jest niekorzystna dlatego, że umiejętność
wyprodukowania niezawodnego oprogramowania, modułu, komponentu lub klasy jest
ważniejsza niż znajomość tej czy innej technologii.

Rodzaje testów

Testowanie może być przeprowadzane na różnych poziomach:
metody, klasy, modułu, całej aplikacji itp. Etap testowania całej aplikacji
występuje zazwyczaj po jej wytworzeniu, testy na niższym poziomie są
prowadzone zazwyczaj równolegle do tworzenia kodu. Można ponadto testować
same wymagania stawiane projektowi lub jego specyfikację. Oczywiście
specyfikacje i projekty przeprowadzone z użyciem nieformalnych metod są
trudniejsze do wykonania, niż testy w przypadku użycia metod bardziej
sformalizowanych. W dalszym ciągu przedmiotem rozważań będzie testowanie
oprogramowania, czyli całej aplikacji i bytów programowych, które wchodzą w
jej skład.

Można wyróżnić testy statyczne – polegają
one na uważnym czytaniu kodu aplikacji, oraz testy dynamiczne polegające
na wykonaniu całej aplikacji lub pewnego fragmentu jej kodu. Testy statyczne służą
do wykrywania semantycznie niewłaściwych konstrukcji w kodzie aplikacji czyli,
mówiąc potocznie, błędów; testy dynamiczne służą do wykrywania błędnych
wykonań. Testy statyczne są bardzo nie doceniane podczas gdy testy dynamiczne
są przeceniane. Oczywiście autor jest bardzo daleki od stwierdzenia, że można
z nich zrezygnować. Nie można wierzyć, iż wykryje się przy ich pomocy
wszystkie błędy. Trzeba bowiem pamiętać, iż błędne wykonanie jest
rezultatem błędu w ściśle określonych warunkach (np. przy specyficznych
wartościach danych). Wniosek z tego stwierdzenia ma dość poważne
konsekwencje – wynika z niego bowiem, iż wszystkich błędów w systemie
o dużej złożoności nie da się w ogóle wyeliminować. Całkowicie
niedopuszczalna jest sytuacja, gdy wytwarzamy oprogramowanie „byle
jak” zakładając, że później się poprawi. Wracając do rozważań z
poprzednich odcinków, można uzmysłowić sobie, dlaczego tak ważne jest
staranne projektowanie – pozwala ono zminimalizować liczbę błędów
(choć oczywiście nie można ich całkowicie wyeliminować). Widać również,
dlaczego bardzo potrzebny jest podział systemu na moduły, oraz jak silnym
rozwiązaniem są metody obiektowe i wytwarzanie systemów z gotowych elementów.
Wydzielenie dobrze zdefiniowanych bytów programowych pozwala na ich testowanie.
Powróćmy do testowania statycznego, które jest z jednej strony niedoceniane,
z drugiej jednak strony trudno wierzyć, że pozwala ono na wykrycie wszystkich
błędów. Skuteczność testów statycznych zależy w dużej mierze od stylu
kodowania. Łatwo sobie bowiem wyobrazić, iż kod funkcji (lub metody), który
„ciągnie się na kilka ekranów” jest zupełnie nieczytelny. Z
obserwacji autora wynika, iż bardzo dobry kod jest napisany w ten sposób, iż
dana funkcja (metoda) ma kilka linii. Oczywiście w programach pisanych dla
zabawy, bez projektu, zależy to wyłącznie od programisty. W komercyjnych
projektach zależy to natomiast zarówno od projektantów, jak i programistów.
Testowanie oprogramowania napisanego obiektowo jest dość specyficzne [2],
dochodzą bowiem zagadnienia związane z bezpieczeństwem rzutowania. W języku
Java każdy obiekt można rzutować do klasy Object, co jest bardzo wygodne np.
przy przechowywaniu obiektów, tworzeniu uniwersalnych klas takich jak tabele (JTable
w Java Swing); do tabeli wstawić można każdy obiekt. Przy rysowaniu jego komórki,
w tabeli wywoływana jest metoda toString(). Jest ona zdefiniowana w klasie będącej
na szczycie hierarchii, a więc posiada ją każdy obiekt. Taka elastyczność
jest bardzo wygodna, może jednak prowadzić do poważnych błędów. Do klasy
wektora można wstawić cokolwiek. Wyobraźmy sobie, iż do takiego wektora
przechowującego samoloty czekające na pozwolenie na start został omyłkowo
wstawiony samochód. Błąd powodujący bardzo poważne konsekwencje może być
bardzo trudny do wykrycia. To samo może się tyczyć bardzo rozbudowanych
pionowo hierarchii klas. Jeszcze inny rodzaj błędów dotyczy przydziału i
zwalniania pamięci (C++). Błędy mogą dać o sobie znać po wielu godzinach
pracy programu. Pomocne może być zbudowanie własnej klasy do zarządzania
pamięcią, badania przeciążenia operatorów new i delete, i monitorowania
przydziału i zwalniania pamięci. Idea tego rozwiązania polega na tym, że na
początku pracy programu, w konstruktorze klasy zarządzającej pamięcią
przydziela się duży blok pamięci, którym się następnie samodzielnie zarządza
[3].

Wracając jeszcze do rodzajów testów dynamicznych [6] można
w ich obrębie wyróżnić „black box testing”, „white box
testing”
i „gray box testing”. Pierwszy rodzaj testów
polega wyłącznie na porównaniu wyników wyprodukowanych przez testowaną
jednostkę. Drugi rodzaj abstrahuje od specyfikacji i skupia się na kodzie, który
testuje się pod kątem istnienia niepoprawnych semantycznie konstrukcji. Polega
to na takim dobraniu danych testowych, aby każda ścieżka (w grafie opisującym
algorytm) była wykonana przynajmniej raz. Stosując ostatni rodzaj testów,
bierze się pod uwagę zarówno strukturę kodu źródłowego jak i specyfikację.
Służy to głównie do znalezienia sytuacji, w których zachowanie programu
jest nieokreślone. Istnieją również formalne metody dowodzenia poprawności
programów, ale pozostają one bardziej w kręgu zainteresowania teoretyków
informatyki.

Problemy z testowaniem

W literaturze [1], z czym autor zgadza się w zupełności,
można znaleźć odpowiedź na pytanie, dlaczego nie testuje się programowania.
Przyczyną jest pośpiech. Chodzi tutaj o testy dynamiczne, czyli wykonanie
aplikacji lub prostego programu napisanego do testowania klasy i obserwację
wyników pod kątem zgodności ze specyfikacją. Z testami statycznymi jest
jeszcze gorzej, autor nie spotkał się z sytuacją, kiedy kod lub jego
fragmenty (choćby te najtrudniejsze) byłyby drukowane i przeglądane przez
najbardziej doświadczonego z programistów. Kod bywa uważnie analizowany
dopiero w sytuacji awaryjnej – tzn. wtedy gdy coś działa źle (tzn. w
sposób niezgodny ze specyfikacją), ale nie wiadomo dlaczego. Wracając do testów
dynamicznych – również w [1] napisano, iż sytuacja w której nie
wykonuje się testów prowadzi do błędnego koła. Im mniej się testuje, tym
mniej wydajnie się pracuje. Dobrą praktyką jest stosowanie różnego rodzaju
asercji, które „pilnują” aby niezmienniki w naszym kodzie
(algorytmie) były spełnione. Gdy nie są, generowana jest odpowiednia
informacja. Makro assert znane jest zapewne wszystkim programistom języka C,
jego zastosowanie cechuje dobry styl programowania. Ale czy
„naszpikowanie” kodu owym makrem jest wystarczające. Okazuje się,
że nie. Autor uważa, że niezależnie od narzędzi do testowania, aplikację
można pisać tak, aby generowała pewnego rodzaju raport z samo-testowania. W
pewnych wypadkach jest to możliwe i bardzo wygodne. Dla ustalenia uwagi można
przyjąć, że przedmiotem naszego zainteresowania jest aplikacja edukacyjna. Ręczne
wykonanie całego materiału może być bardzo męczące, a ponadto zmęczony
tester zapewne nie wykryje pewnych błędów po kilku godzinach pracy. Mogą być
błędy, które nie są krytyczne z punktu widzenia aplikacji (błąd
ortograficzny, złe formatowanie wyników itp. – nie zawieszają programu) ale
niedopuszczalne np. ze względu na rodzaj aplikacji. Jeżeli aplikacja potrafi
symulować pracę ucznia i drukować raport – eliminacja błędów może
być bardzo łatwa. Raport można przenieść np. do edytora tekstu i szukać
literówek.

JUnit – jako przykład narzędzia wspomagającego testowanie

Ciekawym narzędziem do wspomagania testowania klas
napisanych w języku Java jest program JUnit. Sam program jest również
napisany w języku Java. Na samym wstępie można zapytać, komu potrzebne jest
takie narzędzie (i im podobne) i co ono robi? Narzędzie JUnit służy do
przeprowadzania testów dynamicznych. Poniżej zaprezentowano przykładowy kod służący
do wykonania bardzo prostego testu przy pomocy omawianego narzędzia i jego
bibliotek. Klasa testowa (FirstCase) dziedziczy z klasy TestCase, która jest
klasą bazową dla testów. Testy można łączyć w zestawy. W jednej klasie
testowej może być zaimplementowanych wiele testów. Klasa zbiorcza (TestSuite),
czyli ta, która definiuje zestaw testów, potrafi automatycznie wykryć i
uruchomić te metody, których nazwy rozpoczynają się do słowa test.

package MyTest;
import junit.framework.*;
public class FirstTest extends TestCase {
   
     private String
testStrNull;
   
     private String
testStrEmpty;
   
     private String testStr1;
   
     private String testStr1Rev;
   
     private String testStr2;
       
private String testStr2Rev;
       
private String testStr3;
       
private String testStr3Rev;
       
private String testStr4;
       
private String testStr4Rev;

        protected void setUp(){

    testStrNull = null;

        testStrEmpty = "";
   
         testStr1 = "a";
        testStr1Rev = "a";
        testStr2 = "ab";
        testStr2Rev = "ba";
   
          testStr3 =
"abc";
        testStr3Rev = "cba";
        testStr4 = "abcd";
        testStr4Rev = "dcba";
   
}

    public void testNull(){
       
Testee t = new Testee();
        String s = testStrNull;
        String s1 = t.reverse(s);
        String sRev = testStrNull;
        Assert.assertEquals(s1,sRev);
   
}

    public void test0(){
        Testee t = new Testee();
        String s = testStrEmpty;
        String s1 = t.reverse(s);
        String sRev = testStrEmpty;
        Assert.assertEquals(s1,sRev);
   
}

    public void test1(){
        Testee t = new Testee();
        String s = testStr1;
        String s1 = t.reverse(s);
        String sRev = testStr1Rev;
        Assert.assertEquals(s1,sRev);
    }


    public void test2(){
        Testee t = new Testee();
        String s = testStr2;
        String s1 = t.reverse(s);
        String sRev = testStr2Rev;
        Assert.assertEquals(s1,sRev);
    }


    public void test3(){
        Testee t = new Testee();
   
          String s = testStr3;
        String s1 = t.reverse(s);
        String sRev = testStr3Rev;
        Assert.assertEquals(s1,sRev);
    }


    public void test4(){
        Testee t = new Testee();
        String s = testStr4;
        String s1 = t.reverse(s);
        String sRev = testStr4Rev;
        Assert.assertEquals("s="+s+" "+sRev,s1,sRev);
    }

public static void main(String args[]){
        junit.textui.TestRunner.run(new TestSuite(FirstTest.class));

}

class Testee{
    String reverse(String s){
        if ((s==null)||(s.length()<=1))
   
          return s;
    else
        return reverse(s.substring(1,s.length()))+s.substring(0,1);
}

Powyższy przykład służy do testowania metody, która
odwraca rekurencyjnie łańcuch znaków. Została ona zdefiniowana w klasie
Testee. Klasa ta nie zawiera żadnego dodatkowego kodu, który jest związany z
testowaniem. W obrębie klasy – testu zdefiniowano dane testowe. Stanowi
je kilka łańcuchów znaków. Dla każdego łańcucha zdefiniowano łańcuch, będący
jego odwróceniem.

Inicjowanie łańcuchów odbywa w się metodzie setup. Zestaw
składa się z sześciu testów. Każdy z nich jest zdefiniowany w metodzie, której
nazwa rozpoczyna się od słowa test np. test3.

public void test4(){
    Testee t = new Testee();
    String s = testStr4;
    String s1 = t.reverse(s);
    String sRev = testStr4Rev;
    Assert.assertEquals(s1,sRev);
}

W obrębie przykładowej metody testującej wykorzystano
metodę assertEquals zdefiniowaną w klasie Assert. Sprawdza ona, czy dwa
obiekty są takie same, a w przypadku łańcuchów czy są równe. W klasie
Assert, zdefiniowanej w bibliotece wchodzącej w skład narzędzia, zdefiniowano
również metody służące do sprawdzania, czy dane wyrażanie logiczne ma
wartość true lub false. Tak jak ma to miejsce w przypadku funkcji testowej
test4.

Jak już powiedziano, zestaw testów tworzy się bardzo
prosto: do konstruktora klasy TestSuite wystarczy przekazać klasę (!) definiującą
testy – w omawianym przypadku jest to oczywiście FirstTest, tzn.:

TestSuite ts = new TestSuite(FirstTest.class);

W klasie TestSuite istnieją mechanizmy, które wyszukają i
wywołają publiczne metody, których nazwa rozpoczyna się od słowa test.
Warto przypomnieć, że w dowolnym obiekcie pole class odpowiada klasie tego
obiektu.

Kilka słów o testowaniu oprogramowania napisanego obiektowo

W pracy [6] napisano, iż testowanie w przypadku
oprogramowania napisanego obiektowo powinno odbywać się zawsze na poziomie
klasy. Tzn. klasa jest najmniejszą jednostką, którą można testować. Rozważmy
prosty stos:

class Stack{
private StackElement stack = null;
    private class StackElement{
        Object value;
        StackElement next;
        StackElement(Object valueIn,StackElement nextIn){
               
value = valueIn;
               
next = nextIn;
        }
    }

public Object pop() throws EmptyStackException {
    if (stack == null)
        throw new EmptyStackException();
    else{
        StackElement oldStack = stack;
        stack = stack.next;
        return oldStack.value;
    }
}

public void push(Object o){

    stack = new StackElement(o,stack);

}

public String toString(){

    if (stack == null){
        return "Stos pusty";
   
  }else{
            String stackString = "Stos zawiera:";
            for (StackElement stackIterator = stack;
            stackIterator!=null;stackIterator = stackIterator.next){
            stackString = stackString + " "+stackIterator.value.toString();
               
}
            return stackString;
    }
}

class EmptyStackException extends Exception{

}
}

Metoda put() służy do włożenia obiektu na stos, pop()
zdejmuje ostatnio włożony element, ostatnia z metod – toString() – zwraca
zawartość stosu w postaci tekstu. Nie wchodząc w szczegóły implementacyjne
tej bardzo prostej struktury danych można zauważyć, że testowanie metody
pop, zdejmującej element ze stosu nie może odbywać się samodzielnie. Aby coś
zdjąć ze stosu trzeba „to coś” najpierw tam umieścić. Składowe
implementacyjne stosu są prywatne. Nie ma więc możliwości obserwowania, co
się dzieje w środku (za wyjątkiem debagowania). Pierwsze podejście do
testowania polega więc na położeniu pewnej ilości elementów na stosie a
następnie zdejmowaniu i sprawdzaniu. W drugim można wykorzystać metodę
toString, która zwraca zawartość tekstową stosu. Można sprawdzić, czy to
co jest na stosie jest zgodne z naszymi oczekiwaniami.

public static void main(String args[]){
    Stack stack = new Stack();

    stack.push(new String("ala"));
    stack.push(new String("ela"));
    stack.push(new String("ewa"));
    System.out.println(stack);
    try{
        while(true){
                   
System.out.println(stack.pop());
        
   
}catch(Stack.EmptyStackException stackEmpty){
                   
System.out.println("Wszystko zdjęte");
   }
   }
}

Jeśli na stos położono napisy „ala”, „ela”
i „ewa” to zawartość stosu powinna być: „ewa”,
„ela”, „ala”. Zastosowanie tej (nadmiarowej) metody
toString() pozwala na pobranie zawartości stosu bez niszczenia go i sprawdzenie
jego działania. Analizując nieco głębiej metodę to String w klasie Stos
– można zauważyć, iż wywołuje ona metodę toString dla wszystkich
elementów, które są na stosie. Tak więc poprawność działania metody
toString klasy stos, zależy od poprawności metody toString obiektów, które są
na stosie Nie wiadomo jednak, jakie mogą być to obiekty, ponieważ
zaprezentowany stos jest uniwersalną strukturą danych i można na nim położyć
każdy obiekt. Można oczywiście określić, jakie elementy mogą być położone
na stosie, zastępując klasę Object w obrębie definicji stosu jakimś przez
nas zdefiniowanym interfejsem np.

interface stackCompatybile{

}

Zyskamy możliwość kontroli typu elementów, które będą
kładzione na stosie – stracimy elastyczność. Gdyby jednak ów stos miał
zawierać jakieś bardzo ważne informacje, to byłoby to sensowne rozwiązanie.
Poniżej znajduje się przykład klasy, której obiekty mogą być kładzione na
zmodyfikowany stos:

class MyString implements stackCompatybile{
    String string=null;

               
public MyString(String s){
        string = new String (s);
   
}
    public String toString(){
        return string;
    }
}

Kończąc rozważania na temat testowania trzeba podkreślić,
że testowanie nie powinno istnieć w postaci wiedzy na kartach książek, ale
powinno być stosowane w praktyce. Niestety nie zawsze tak się dzieje. Trzeba również
zauważyć, że rodzaj metodologii a także misja wytwarzanego systemu mają
niebagatelny wpływ na testowanie.

c.d.n.

Literatura

[1] JUnit Test Infected: Programmers Love Writing Tests
[2] John D. McGregor, David A. Sykes A
practical Guide to Testing Object-Oriented software
Oreilly 2002
[3] Mark DeLoura Perełki Programowania Gier Helion
2002
[4] E. Gamma Design Patterns: Elements of Reusable
Object-Oriented Software
Addisin_Wesly 1995
[5] http://junit.sourceforge.net/doc/cookbook/cookbook.htm
[6] E.V. Berard Issues in the Testing of Object-Oriented
Software
[7] J.A. Whittaker What Is Software Testing? Andy Why Is
It so Hard
IEEE Software January/February 2000
[8] http://www.junit.org/