Testowanie kodu gry w języku C++ za pomocą CPPUnit



Podobne dokumenty
Podstawy programowania. Wykład Funkcje. Krzysztof Banaś Podstawy programowania 1

Obiekt klasy jest definiowany poprzez jej składniki. Składnikami są różne zmienne oraz funkcje. Składniki opisują rzeczywisty stan obiektu.

Czym są właściwości. Poprawne projektowanie klas

znajdowały się różne instrukcje) to tak naprawdę definicja funkcji main.

Podczas dziedziczenia obiekt klasy pochodnej może być wskazywany przez wskaźnik typu klasy bazowej.

Rozdział 4 KLASY, OBIEKTY, METODY

Konstruktory. Streszczenie Celem wykładu jest zaprezentowanie konstruktorów w Javie, syntaktyki oraz zalet ich stosowania. Czas wykładu 45 minut.

Informacje ogólne. Karol Trybulec p-programowanie.pl 1. 2 // cialo klasy. class osoba { string imie; string nazwisko; int wiek; int wzrost;

Automatyzacja testowania oprogramowania. Automatyzacja testowania oprogramowania 1/36

1 Podstawy c++ w pigułce.

Szablony funkcji i klas (templates)

1. Które składowe klasa posiada zawsze, niezależnie od tego czy je zdefiniujemy, czy nie?

Wykład 8: klasy cz. 4

Wskaźniki a tablice Wskaźniki i tablice są ze sobą w języku C++ ściśle związane. Aby się o tym przekonać wykonajmy cwiczenie.

Informatyka I. Klasy i obiekty. Podstawy programowania obiektowego. dr inż. Andrzej Czerepicki. Politechnika Warszawska Wydział Transportu 2018

Niezwykłe tablice Poznane typy danych pozwalają przechowywać pojedyncze liczby. Dzięki tablicom zgromadzimy wiele wartości w jednym miejscu.

TEMAT : KLASY DZIEDZICZENIE

JAVA. Java jest wszechstronnym językiem programowania, zorientowanym. apletów oraz samodzielnych aplikacji.

Wyszukiwanie binarne

Liczby losowe i pętla while w języku Python

Podstawy Programowania Obiektowego

Laboratorium Informatyka (I) AiR Ćwiczenia z debugowania

Programowanie strukturalne i obiektowe. Funkcje

Po uruchomieniu programu nasza litera zostanie wyświetlona na ekranie

Uniwersytet Zielonogórski Instytut Sterowania i Systemów Informatycznych. Ćwiczenie 3 stos Laboratorium Metod i Języków Programowania

Wyjątki (exceptions)

7. Pętle for. Przykłady

Programowanie obiektowe

REFERAT PRACY DYPLOMOWEJ

PROE wykład 3 klasa string, przeciążanie funkcji, operatory. dr inż. Jacek Naruniec

Zadanie nr 2: Arytmetyka liczb zespolonych

Programowanie i techniki algorytmiczne

Obiektowy PHP. Czym jest obiekt? Definicja klasy. Składowe klasy pola i metody

Jeśli chcesz łatwo i szybko opanować podstawy C++, sięgnij po tę książkę.

Języki i techniki programowania Ćwiczenia 2

1 Podstawy c++ w pigułce.

Cwiczenie nr 1 Pierwszy program w języku C na mikrokontroler AVR

Listy powiązane zorientowane obiektowo

0 + 0 = 0, = 1, = 1, = 0.

Kumulowanie się defektów jest możliwe - analiza i potwierdzenie tezy

Java pierwszy program w Eclipse «Grzegorz Góralski strona własna

Część 4 życie programu

użytkownika 1 Jak wybrać temat pracy 2 Spis treści 3 Część pierwsza problematyka 4 Część druga stosowane metody 5 Część trzecia propozycja rozwiązania

4. Funkcje. Przykłady

Obszar statyczny dane dostępne w dowolnym momencie podczas pracy programu (wprowadzone słowem kluczowym static),

Programowanie w języku C++ Grażyna Koba

Szablony funkcji i szablony klas

Wskaźnik może wskazywać na jakąś zmienną, strukturę, tablicę a nawet funkcję. Oto podstawowe operatory niezbędne do operowania wskaźnikami:

Zad. 5: Układ równań liniowych liczb zespolonych

Wyjątki. Streszczenie Celem wykładu jest omówienie tematyki wyjątków w Javie. Czas wykładu 45 minut.

Spis treści. Rozdział 1. Aplikacje konsoli w stylu ANSI C i podstawowe operacje w Visual C

Programowanie obiektowe - 1.

IMIĘ i NAZWISKO: Pytania i (przykładowe) Odpowiedzi

Jak napisać program obliczający pola powierzchni różnych figur płaskich?

Wykład 5: Klasy cz. 3

TESTOWANIE OPROGRAMOWANIA

Priorytetyzacja przypadków testowych za pomocą macierzy

LABARATORIUM 9 TESTY JEDNOSTKOWE JUNIT 3.8

Warunek wielokrotnego wyboru switch... case

Klasa jest nowym typem danych zdefiniowanym przez użytkownika. Najprostsza klasa jest po prostu strukturą, np

Widoczność zmiennych Czy wartości każdej zmiennej można zmieniać w dowolnym miejscu kodu? Czy można zadeklarować dwie zmienne o takich samych nazwach?

Wykład 3 Składnia języka C# (cz. 2)

Programowanie obiektowe

C++ Przeładowanie operatorów i wzorce w klasach

Wstęp do informatyki- wykład 7

PROE wykład 2 operacje na wskaźnikach. dr inż. Jacek Naruniec

Dariusz Brzeziński. Politechnika Poznańska, Instytut Informatyki

JUnit TESTY JEDNOSTKOWE. Waldemar Korłub. Platformy Technologiczne KASK ETI Politechnika Gdańska

PROJEKTOWANIE. kodowanie implementacja. PROJEKT most pomiędzy specyfikowaniem a kodowaniem

Zadanie 2: Arytmetyka symboli

2. Tablice. Tablice jednowymiarowe - wektory. Algorytmy i Struktury Danych

Zad. 3: Układ równań liniowych

1 Wskaźniki i zmienne dynamiczne, instrukcja przed zajęciami

Zad. 3: Rotacje 2D. Demonstracja przykładu problemu skończonej reprezentacji binarnej liczb

TABLICA (ang. array) pojedyncza zmienna z wieloma komórkami, w których można zapamiętać wiele wartości tego samego typu danych.

Podstawy programowania skrót z wykładów:

Lab 9 Podstawy Programowania

Techniki programowania INP001002Wl rok akademicki 2018/19 semestr letni. Wykład 3. Karol Tarnowski A-1 p.

KUP KSIĄŻKĘ NA: PRZYKŁADOWY ROZDZIAŁ KOMUNIKATY DLA UŻYTKOWNIKA

Zad. 4: Rotacje 2D. 1 Cel ćwiczenia. 2 Program zajęć. 3 Opis zadania programowego

Podstawy Programowania 2

Weryfikacja i walidacja. Metody testowania systemów informatycznych

C++ - dziedziczenie. C++ - dziedziczenie. C++ - dziedziczenie. C++ - dziedziczenie. C++ - dziedziczenie C++ - DZIEDZICZENIE.

C++ - przeciążanie operatorów. C++ - przeciążanie operatorów. C++ - przeciążanie operatorów. C++ - przeciążanie operatorów

Dokumentacja do API Javy.

Część XVII C++ Funkcje. Funkcja bezargumentowa Najprostszym przypadkiem funkcji jest jej wersja bezargumentowa. Spójrzmy na przykład.

Testowanie I. Celem zajęć jest zapoznanie studentów z podstawami testowania ze szczególnym uwzględnieniem testowania jednostkowego.

PHP 5 język obiektowy

XQTav - reprezentacja diagramów przepływu prac w formacie SCUFL przy pomocy XQuery

1. Wartość, jaką odczytuje się z obszaru przydzielonego obiektowi to: a) I - wartość b) definicja obiektu c) typ oboektu d) p - wartość

Pierwsze kroki. Algorytmy, niektóre zasady programowania, kompilacja, pierwszy program i jego struktura

Podstawy Programowania C++

Utworzenie pliku. Dowiesz się:

SCENARIUSZ LEKCJI. Streszczenie. Czas realizacji. Podstawa programowa

Wprowadzenie do projektu QualitySpy

Testowanie i walidacja oprogramowania

3. Macierze i Układy Równań Liniowych

Makropolecenia w Excelu

Szablony klas, zastosowanie szablonów w programach

Automatyczne tworzenie operatora = Integer2& operator=(const Integer& prawy) { zdefiniuje. Integer::operator=(ri);

Transkrypt:

Testowanie kodu gry w języku C++ za pomocą CPPUnit Wojciech Toman

TESTOWANIE KODU GRY W JĘZYKU C++ ZA POMOCĄ CPPUNIT Jest duża różnica między kodem źródłowym, poprawnym kodem źródłowym i dobrym kodem źródłowym WSTĘP Błędy były, są i będą nieodłączną częścią programistycznego rzemiosła. Pojawiają się często w najmniej oczekiwanych momentach i w najmniej oczekiwanych miejscach kodu, przyczyniając się tym samym do frustracji zarówno końcowych użytkowników jak i próbujących je zlikwidować programistów i testerów. Idealnej recepty na ich eliminację nie ma i nigdy nie będzie. Istnieją jednak sposoby minimalizacji ryzyka ich wystąpienia, ograniczenia ich do takiego poziomu, że można je zaakceptować. Jedną z popularniejszych metod na przestrzeni ostatnich kilku lat są zautomatyzowane testy, a szczególnie tzw. unit-testy i testy funkcjonalne, które stanowią ważną część metodyk Agile 1. W tym artykule skupię się na tych pierwszych. Definicja 1 Test funkcjonalny (ang. functional test) test sprawdzający poprawność działania komponentów lub modułów aplikacji w rzeczywistej sytuacji oraz ich wzajemną współpracę. Przykładem testu funkcjonalnego może być sprawdzenie poprawności grafu sceny po dodaniu do niego nowego węzła za pomocą menedżera sceny. Unit-testy są testami sprawdzającymi poprawność działania kodu na poziomie najmniejszych jego autonomicznych jednostek, czyli pojedynczych funkcji 2, tu całkowicie wyrwanych z rzeczywistego kontekstu, w jakim zostaną użyte. Polegają one na przekazaniu do funkcji pewnego wejścia i przeanalizowaniu wyjścia. Wejście nie jest oczywiście przypadkowe bardzo często reprezentuje ono typową sytuację z jaką spotkamy się w grze. Jednak równie często (a nawet częściej) testowana jest sytuacja nietypowa, błędna taka jak np. zaalokowanie zbyt dużego bloku pamięci przez menedżera pamięci, czy próba otworzenia niepoprawnie zapisanego pliku poziomu. W takiej sytuacji nasz kod nadal powinien się zachowywać poprawnie: rzucając wyjątek, zgłaszając komunikat o błędzie, przyjmując wartości domyślne i ignorując nietypową sytuację lub w jeszcze inny sposób obsłużyć napotkany błąd. Definicja 2 Unit-testy są testami sprawdzającymi poprawność działania kodu na poziomie najmniejszych jego autonomicznych jednostek, czyli pojedynczych funkcji. Jeśli wyjście jest zgodne z oczekiwanym w danym przypadku wynikiem test zostaje zaliczony. W przeciwnym razie gratulacje! Właśnie znalazłeś błąd w swoim kodzie. Ważne jest to, że pojedynczy test wiele o poprawności kodu nam nie powie. Owszem, przekonamy się, że w danym przypadku kod działa zgodnie z oczekiwaniami. Ale jak zachowa się dla innych danych? Cóż recepta jest prosta. Należy napisać więcej testów. Ich liczba jest zależna od funkcji, którą poddajemy testowaniu. Co 1 Metodyki Agile są oparte na tzw. najlepszych praktykach tworzenia oprogramowania. Do ich upowszechnienia przyczyniło się w dużej mierze powstanie programowania ekstremalnego (XP) autorstwa Kenta Becka. Manifest metodyk Agile można znaleźć pod adresem: http://agilemanifesto.org. 2 Mimo że testy są prowadzone na poziomie funkcji różna jest definicja jednostki w zależności od przyjętego paradygmatu programowania. W programowaniu strukturalnym/proceduralnym jednostką jest funkcja, natomiast w programowaniu zorientowanym obiektowo klasa.

więcej czasem wcale nie trzeba pisać dziesiątek przypadków testowych wystarczy opracować kilka jak najlepszych tzn. mocno przemyślanych 3. Po pewnym czasie wyrobimy sobie swego rodzaju intuicję, dzięki czemu będziemy wiedzieli, że liczba określonych przez nas przypadków testowych w zupełności wystarczy do stwierdzenia, że kod jest poprawny. Definicja 3 Przypadek testowy (ang. test case) zbiór warunków i zmiennych pozwalający określić testującemu czy wymaganie poprawności testu zostało całkowicie lub częściowo spełnione. Przykładem przypadku testowego dla funkcji mnożenia macierzy, jest pomnożenie macierzy A przez macierz jednostkową I. Warunkiem zaliczenia testu jest otrzymanie w wyniku wywołania funkcji, macierzy A. Zaletą stosowania unit-testów jest to, że oszczędzają mnóstwo pracy zarówno programistom jak i zespołowi testującemu grę. Ten zaoszczędzony czas można wykorzystać na maksymalne dopracowanie gry pod kątem np. grywalności, efektów specjalnych, czy na podniesienie inteligencji aktorów AI. Ponadto pozwalają na przetestowanie kodu niższego poziomu, który ze względu na swój poziom komplikacji jest bardziej narażony na krytyczne dla poprawnego działania błędy (choćby wspomniane już błędy alokacji pamięci). Tego kodu testerzy w żaden sposób bezpośrednio nie sprawdzą, a zgłaszane przez nich objawy błędu wcale nie muszą doprowadzić nas do jego przyczyny. Prawdopodobnie spędzimy wiele godzin debugując kod, po czym okaże się, że błąd tkwi w zupełnie innym miejscu niż sądziliśmy. Ponadto starsze, tradycyjne metody testowania pod koniec produkcji gry sprawiały, że w przypadku znalezienia błędu na jego poprawienie było relatywnie niewiele czasu (większość zajmowała jego identyfikacja). Z wykorzystaniem unit-testów błąd jest znajdywany natychmiast. Może być też w związku z tym szybko poprawiony. Jakiś czas temu spotkałem się z zarzutem, że użycie unit-testów wymaga bardzo dużych zmian kodzie, aby wszystko co należy przetestować mogło w ogóle być przetestowane. Choć taka obawa jest uzasadniona, na szczęście nie jest to konieczne (choć i taka metoda jest jak najbardziej poprawna, a złamanie enkapsulacji faktycznie może okazać się konieczne). Możemy bowiem stworzyć framework do testów, który większość problemów pozwoli nam obejść. Stworzyć, lub jeszcze lepiej wykorzystać już istniejący. Teraz przechodzimy do bohatera tego artykułu, czyli CppUnit. Biblioteka ta, jedna z popularniejszych, powstała w oparciu o framework do testowania kodu Javy, o odkrywczej nazwie JUnit. CppUnit zaoszczędzi nam wiele pracy i ją ułatwi np. poprzez generowanie przejrzystych raportów z testów, z których szybko dowiemy się które testy nie zostały zaliczone, i w którym ich miejscu otrzymany wynik był niepoprawny. Korzystając z niego w najgorszym przypadku w niektórych klasach będziemy musieli dodać deklarację przyjaźni z klasą testu (o tym w dalszej części). Ma jednak jedną wadę - kompilacja jego niektórych części w darmowym VC++ Express może być problematyczna część projektów z solucji wymaga, bowiem do kompilacji bibliotek MFC i ATL, które są dostępne wyłącznie w wersjach od Professional włącznie. Na szczęście nie dotyczy to najważniejszych komponentów biblioteki, czyli tych odpowiedzialnych za tworzenie testów i ich uruchamianie. W dalszej części artykułu przedstawię proces tworzenia testów dla konkretnych klas gry i jej silnika. Jako zwolennik test-driven development uważam, że unit-testy powinny być tworzone przed napisaniem właściwego 3 Przykładowo zamiast dla funkcji sinus testować poprawność wyniku dla wartości kąta równej 45,567 stopni lepiej sprawdzić czy wartość będzie poprawna dla popularnych kątów lub upewnić się, że zawsze będzie należała do przedziału <-1; 1>.

kodu 4, listingi z samego kodu gry zostały pominięte, a wszelkie wątpliwości staram się wyjaśniać na bieżąco, m.in. w komentarzach do poszczególnych testów. W końcowej części artykułu postaram się przedstawić także garść porad dotyczących procesu testowania. Ponadto fakt, że przedstawiam tu konkretny framework, nie powoduje, że przedstawione techniki nie mogą być zastosowane w wypadku korzystania z innych rozwiązań. Większość z nich jest bowiem uniwersalna jedynie kod będzie wyglądał nieco inaczej. JAK ORGANIZOWAĆ TESTY Dobrą praktyką jest oddzielenie testów od kodu gry. Jest to o tyle dobre rozwiązanie, że jeśli tworzymy np. silnik graficzny, to prawdopodobnie nie mamy ochoty, udostępniać nikomu tego nadmiarowego kodu. Jeśli tworzymy grę nie mamy z kolei raczej ochoty na zbyt duże zwiększenie rozmiaru pliku wynikowego. Jednak najważniejsze jest to, że dzięki takiemu podejściu nie zmniejszamy czytelności kodu. Jeśli testujemy pewną klasę lub pewną konkretną funkcjonalność najlepiej wydzielić ze wszystkich przypadków testowych jej dotyczących osobną klasę 5. Idąc jeszcze dalej wszystkie testy można umieścić w osobnym projekcie. Taką klasę będącą zbiorem testów dotyczących pewnej funkcjonalności nazwiemy z języka angielskiego test suit. Definicja 4 Zbiór testów (ang. test suite) zbiór przypadków testowych sprawdzających poprawność konkretnego fragmentu funkcjonalności. W przypadku programowania zorientowanego obiektowo, zbiór testów będzie zwykle dotyczył jednej klasy. Ważne jest, aby testy uruchamiać dla każdej z konfiguracji. Może się bowiem okazać, że test zaliczony w konfiguracji Debug nie zostanie zaliczony w konfiguracji Release. Dlaczego? Z taką sytuacją wiąże się ściśle pojęcie heisenbug. Ogólnie mówiąc: po pierwsze w konfiguracji Debug do kodu jest dołączonych znacznie więcej informacji (które sprawiają, że niepoprawny kod może zachowywać się przez długi czas w porządku), a ponadto niektóre z optymalizacji z Release uwidaczniają nasze błędy. Definicja 5 Heisenbug (za Wikipedia) - błąd systemu, który wymyka się próbom wyizolowania warunków jego występowania, np. nie występuje lub zmienia swoje zachowanie w trakcie próby powtórzenia go w tych samych warunkach. Wspomniałem wcześniej o tym, że być może koniecznym stanie się złamanie enkapsulacji. W świetle tego, co przeczytałeś w tym podrozdziale powinno być już jasne dlaczego. Testy będą się odwoływać do metod testowanych obiektów. Zatem jedynym sposobem dostępu do metod prywatnych i chronionych jest zmiana ich zasięgu na publiczny bądź deklaracja przyjaźni z klasą testu. To drugie rozwiązanie wydaje się być bardziej eleganckie jednak wybór należy do Ciebie. PISZEMY TESTY 4 Takie podejście wywodzi się z tzw. Agile Methods [Toman01]. Powoduje ono, że programista znacznie dogłębniej zastanawia się nad kodem, który napisze, co przyczynia się do wzrostu jego jakości. 5 W CppUnit taka klasa nazywa się TestFixture niestety nie istnieje dobry polski odpowiednik dla tej nazwy.

W ramach tego rozdziału zajmiemy się następującymi klasami: klasą macierzy o wymiarach 4x4 o nazwie Matrix4 oraz klasą poziomu gry GameLevel. Dla każdej z nich określimy podstawową funkcjonalność, a następnie na tej podstawie stworzymy kilka przypadków testowych. Najpierw jednak przyjrzymy się, jak pisać testy przy pomocy CppUnit. Celem jest pokazanie praktycznego zastosowania unit-testów. JAK PISAĆ TESTY PRZY POMOCY CPPUNIT Oczywistym jest, że testy przy wykorzystaniu biblioteki CppUnit należy pisać w sposób z nią zgodny. W zasadzie są dwie metody, które różnią się zasadniczo ilością makr stosowanych w kodzie. Pierwsza z nich przedstawiona m.in. w [Madden01] prezentuje się w poniższy sposób: Listing 1 #include <cppunit/testfixture.h> #include <cppunit/testsuite.h> #include <cppunit/test.h> #include <cppunit/testcaller.h> #include <cppunit/testassert.h> class TestsMath: public CppUnit::TestFixture public: CPPUNIT_TEST_SUITE(TestsMaths); CPPUNIT_TEST(sineTest); CPPUNIT_TEST(cosineTest); CPPUNIT_TEST_SUITE_END(); float sinetest() float cosinetest() ; Zatrzymajmy się na chwilę w tym miejscu i przeanalizujmy powyższą definicję klasy TestsMaths. Jej przeznaczeniem jest przetestowanie funkcji matematycznych w naszej bibliotece. Aby klasa ta w ogóle mogła być potraktowana jako zbiór testów musi być dziedziczona z klasy TestFixture z biblioteki CppUnit 6. Makra CPPUNIT_TEST_SUITE i CPPUNIT_TEST_SUITE_END określają początek i koniec zbioru funkcji, opisujących przypadki testowe. Jedna uwaga odnośnie CPPUNIT_TEST_SUITE. Wyrażenie podane w nawiasie musi być nazwą typu dziedziczącego z TestFixture (prawdopodobnie będziesz chciał w tym miejscu umieścić nazwę klasy, którą właśnie definiujesz). 6 Możliwe jest też dziedziczenie z klasy Test będącej pojedynczym przypadkiem testowym. Aby skorzystać z tej metody należy później przeciążyć funkcję runtest() i umieścić w niej przypadek testowy.

Oprócz powyższego zapisu istnieje równoważny, w którym po prostu liczba makr została znacznie ograniczona: Listing 2 #include <cppunit/testfixture.h> #include <cppunit/testsuite.h> #include <cppunit/test.h> #include <cppunit/testcaller.h> #include <cppunit/testassert.h> class TestsMath: public CppUnit::TestFixture public: static CppUnit::Test* suite() CppUnit::TestSuite* suiteoftests = new CppUnit::TestSuite( TestsMaths ); CppUnit::TestCaller<TestsMaths>( sinetest, &TestsMaths::sineTest)); CppUnit::TestCaller<TestsMaths>( cosinetest, &TestsMaths::cosineTest)); return suiteoftests; float sinetest() ; float cosinetest() Jak widać największą różnicą w porównaniu do poprzedniego listing, jest rezygnacja z makr i dodanie statycznej metody o nazwie suite() zwracającej wskaźnik do obiektu Test. W jej ciele dodawane są wszystkie testy, poprzez utworzenie obiektu typu TestSuite i wywołanie na nim metody addtest(). Gdy teraz mamy już określone przypadki testowe, zobaczymy jak możemy określić czy dany test został zaliczony, czy też nie. Z pomocą przychodzi kilka użytecznych makr: CPPUNIT_ASSERT(warunek) tego makra będziesz prawdopodobnie używać najczęściej. Działa podobnie jak tradycyjne makro assert znane programistom C++. Jeśli warunek nie został spełniony, test zostaje nie zaliczony. CPPUNIT_ASSERT_MESSAGE(komunikat, warunek) podobnie jak powyższe, tyle że pozwala na określenie własnego komunikatu w przypadku niepowodzenia. CPPUNIT_ASSERT_THROW(wyrażenie, wyjątek) test zostaje zaliczony jeśli dla danego wyrażenia zostanie rzucony konkretny wyjątek. Makro to jest niezwykle przydatne do testowania nietypowych sytuacji. Ponadto biblioteka CppUnit definiuje takie makra jak: CPPUNIT_FAIL, CPPUNIT_ASSERT_EQUAL czy CPPUNIT_ASSERT_DOUBLES_EQUAL. Jednak ich praktyczne zastosowanie jest niewielkie. Zwykle będziesz się

prawdopodobnie ograniczać do CPPUNIT_ASSERT i CPPUNIT_ASSERT_THROW, gdyż wszystkie warunki są możliwe do określenia przy ich pomocy. Na koniec warto wspomnieć o dwóch funkcjach klasy TestFixture: setup() i teardown(). Pierwsza dokonuje inicjalizacji obiektów, z których będziemy korzystać w przypadkach testowych, a druga je zwalnia. Aby z nich skorzystać wystarczy je przeciążyć w swojej klasie. W kolejnych podrozdziałach tego artykułu zajmiemy się napisaniem prawdziwych testów. KLASA MATRIX4 Najpierw zastanówmy się, jaką funkcjonalność powinna zapewniać klasa macierzy. Ułatwi nam to jej gruntowne przetestowanie. Powinna pozwalać na: Tworzenie macierzy z tablicy 16 liczb zmiennoprzecinkowych dobrym testem czy macierz jest tworzona poprawnie jest podanie na wejściu 16 różnych liczb i sprawdzeniu, czy pojawiają się one w tych komórkach macierzy, do których mieliśmy je zamiar zapisać. Wystarczy jeden przypadek testowy, gdyż nie ma tu sytuacji nadzwyczajnych. Dodawanie macierzy w zasadzie jak wyżej, wystarczy sprawić, aby w każdej komórce macierzy wynikowej była inna wartość. Mnożenie macierzy o ile w zwykłym przypadku funkcja ta nie stanowi większego problemu, to już przy dodaniu optymalizacji pod SSE lub SSE2 jest możliwość, że błąd się pojawi. Poza mnożeniem dwóch dowolnych macierzy należy sprawdzić jak zachowa się mnożenie macierzy przez jej macierz odwrotną (powinno w wyniku dać macierz jednostkową) oraz przez macierz jednostkową (powinno dać macierz wejściową). Transponowanie macierzy Odwracanie macierzy należy sprawdzić jak zachowa się macierz, której wyznacznik jest równy 0 (macierz odwrotna wówczas nie istnieje). Liczenie wyznacznika Najpierw napiszmy definicję klasy TestMatrix4 dziedziczącej z CppUnit::TestFixture.

Listing 3 class TestMatrix4: public CppUnit::TestFixture public: TestMatrix4() ~TestMatrix4() void testcreation(); void testinvert(); void testdeterminant(); void testtranspose(); void testaddition(); void testmultiplication(); ; static CppUnit::Test* suite(); Poniżej natomiast znajduje się implementacja poszczególnych metod tej klasy. Wszystkie kroki staram się wytłumaczyć w komentarzach w kodzie: Listing 4 CppUnit::Test* TestMatrix4::suite() CppUnit::TestSuite* suiteoftests = new CppUnit::TestSuite( TestMatrix4 ); // Kolejno dodajemy wszystkie testy do zbioru testów. // W tym celu tworzymy nowy obiekt szablonowy TestCaller, // którego konstruktor przyjmuje za argumenty nazwę testu // oraz wskaźnik na funkcję będąca przypadkiem testowym CppUnit::TestCaller<TestMatrix4>( testcreation, &TestMatrix4::testCreation)); CppUnit::TestCaller<TestMatrix4>( testinvert, &TestMatrix4::testInvert)); CppUnit::TestCaller<TestMatrix4>( testdeterminant, &TestMatrix4::testDeterminant)); CppUnit::TestCaller<TestMatrix4>( testtranspose, &TestMatrix4::testTranspose)); CppUnit::TestCaller<TestMatrix4>( testaddition, &TestMatrix4::testAddition)); CppUnit::TestCaller<TestMatrix4>( testmultiplication, &TestMatrix4::testMultiplication)); return suiteoftests; void TestMatrix4::testCreation() // Niezwykle prosty przypadek testowy. Tworzymy macierz 4x4 typu // Matrix4, która w konstruktorze przyjmuje 16 liczb typu zmienno- // przecinkowego. Wystarczy zatem sprawdzić, czy wszystkie liczby // zostały wpisane we właściwych miejscach.

// W ten sam sposób można sprawdzić np. tworzenie macierzy // jednostkowej (wywołanie metody o nazwie identity() i sprawdzenie // zawartości poszczególnych pól macierzy). Matrix4 mat(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); CPPUNIT_ASSERT(mat._m11 == 1 && mat._m12 == 2 && mat._m13 == 3 && mat._m14 == 4 && mat._m21 == 5 && mat._m22 == 6 && mat._m23 == 7 && mat._m24 == 8 && mat._m31 == 9 && mat._m32 ==10 && mat._m33 ==11 && mat._m34 ==12 && mat._m41 ==13 && mat._m42 ==14 && mat._m43 ==15 && mat._m44 ==16); void TestMatrix4::testAddition() // Kolejny prosty test. Wystarczy dodać dwie macierze w taki sposób, // aby żadna wartość nie występowała w komórkach macierzy wynikowej // więcej niż raz. Matrix4 mat1(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); Matrix4 mat2(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); mat0 = add(mat1, mat2); // Zwróćmy uwagę na to, że tym razem nie podajemy tu konkretnej // macierzy, a wynik jej pomnożenia przez liczbę 2. Oczywiście // użycie takiej formy wymaga najpierw sprawdzenia poprawności // mnożenia macierzy przez liczbę rzeczywistą, jednak w tym artykule // test ten pomijamy. Sam fakt zastosowania tego typu sprawdzenia // wynika z faktu, że macierze wejściowe są sobie równe. CPPUNIT_ASSERT(mat0 == 2 * mat1); // Poniżej sprawdzamy inne sposoby wykonywania dodawania macierzy, // tj. Z wykorzystaniem przeciążonych operatorów. mat0 = mat1; mat0 += mat1; CPPUNIT_ASSERT(mat0 == 2 * mat1); mat0 = mat1 + mat2; CPPUNIT_ASSERT(mat0 == 2 * mat1); void TestMatrix4::testMultiplication() Matrix4 mat1(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); Matrix4 mat2; Matrix4 mat0; // Pomnożenie dowolnej macierzy przez macierz jednostkową powinno // zwrócić macierz wejściową. mat2.identity(); mat0 = mat1; mat0.multiply(mat2); CPPUNIT_ASSERT(mat0 == mat1); // Pomnożenie macierzy jednostkowej przez dowolną macierz powinno // zwrócić tę macierz. mat0 = mat2; mat0.multiply(mat1); CPPUNIT_ASSERT(mat0 == mat1); // Pomnożenie dowolnej macierzy przez jej macierz odwrotną powinno // zwrócić macierz jednostkową (przy założeniu, że macierz odwrotna // istnieje).

mat0 = mat1; mat2 = invert(mat1); mat0.multiply(mat2); CPPUNIT_ASSERT(mat0.isIdentity()); void TestMatrix4::testInvert() // Odwracamy macierze, które są odwracalne. Matrix4 mat(1, 1, 1, 1, 1, 1, -1, -1, 1, -1, 1, -1, 1, -1, -1, 1); mat.invert(); CPPUNIT_ASSERT(mat == Matrix4(0.25, 0.25, 0.25, 0.25, 0.25, 0.25,-0.25,-0.25, 0.25,-0.25, 0.25,-0.25, 0.25,-0.25,-0.25, 0.25)); mat.set(1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1); mat.invert(); CPPUNIT_ASSERT(mat == Matrix4(1, 0, 0, 0, 1, 1, 1, -1, -1, -1, 0, 1, 0, 1, 0, 0)); // Sprawdzamy co się dzieje gdy macierz ma wyznacznik równy 0. // Przyjmijmy, że w wyniku tej operacji chcemy otrzymać macierz // wejściową. Matrix4 mat2(0, 0, 0, 0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); mat = mat2; mat.invert(); CPPUNIT_ASSERT(mat == mat2); void TestMatrix4::testDeterminant() // Sprawdzamy poprawność liczenia wyznacznika. Matrix4 mat(2, -2, 0, 1, 0, -1, 0, 2, 0, 0, 3, 0, 0, 0, 0, 1); CPPUNIT_ASSERT(mat.determinant() == -6); mat.set(1, -2, 0, 1, 0, 0, 0, 2, -1, 0, 3, 0, 0, -1, 2, 0); CPPUNIT_ASSERT(mat.determinant() == -2); mat.set(0, 0, 0, 0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); CPPUNIT_ASSERT(mat.determinant() == 0); void TestMatrix4::testTranspose() Matrix4 mat(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); mat.transpose(); CPPUNIT_ASSERT(mat == Matrix4(1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16)); To tyle na temat klasy macierzy. Oczywiście napisanie większej liczby testów może być uzasadnione, jednak na potrzeby tego artykułu te w zupełności wystarczą.

KLASA GAMELEVEL Klasa GameLevel będzie pozwalała na wczytywanie pliku poziomu zapisanego w formacie XML. W związku z tym większość testów powinna oscylować wokół sprawdzenia poprawności danych w nim zawartych. Sprawdzenia, czy tagi mają dopuszczalne wartości. Należy też przetestować co się stanie, gdy w pliku zabraknie krytycznych danych, takich jak np. dane o wersji pliku bądź pojawią się dane, z których nie korzystamy. Oczywiście można by powiedzieć, że takie testy nie mają sensu, gdyż zwykle do tworzenia poziomów wykorzystuje się i tak edytor, który zapisuje dane w odpowiednim formacie. Pojawia się jednak pewne ale: 1. Na początku produkcji prawdopodobnie edytor będzie w tak mało zaawansowanej wersji, że wiele osób będzie edytować plik ręcznie, w edytorze tekstu byle tylko móc stworzyć prototypy. O pomyłkę wówczas nietrudno, a szukanie buga, którego w rzeczywistości nie ma (wina leży tu po stronie nieuważnego użytkownika) jest na pewno niewdzięcznym i czasochłonnym zadaniem. 2. Edytor sam w sobie może zawierać błędy. Oczywiście jeśli to my za niego odpowiadamy powinniśmy go, w myśl tego artykułu, gruntownie przetestować. Zdarza się jednak, że wykorzystuje się gotowe narzędzia, których kodu przetestować nie możemy. Jeśli zawierają one jakiś błąd możemy się tylko przed nim zabezpieczyć. Zresztą nawet jeśli edytor jest autorstwa członka naszego zespołu to o ile nie jesteśmy w jego kierownictwie nie zmusimy nikogo do pisania unit-testów. Poniżej znajduje się definicja przypadków testowych dla klasy GameLevel. W tym przypadku nie umieszczam już definicji samej klasy, ani definicji metody suite(). Ponadto przedstawiam tylko testy dla funkcji wczytujących poziom. W innym przypadku ten artykuł musiałby być dużo dłuższy. Listing 5 void TestGameLevel::testCreation() // Na początek sprawdzamy, czy poziom jest wczytywany poprawnie, gdy // wczytujemy plik o poprawnym nagłówku: // autor, nazwa, wersja, plik do wczytania. // Oczekujemy, że sprawdzenie poprawności (metoda isvalid()) da // wartość pozytywną (true). LevelHeader header( author, level, 1.1, level.lvl ); GameLevel level; level.load(header); CPPUNIT_ASSERT(level.isValid()); CPPUNIT_ASSERT(level.getAuthor() == author ); CPPUNIT_ASSERT(level.getName() == level ); CPPUNIT_ASSERT(level.getVersion() == 1.1); CPPUNIT_ASSERT(level.getFileName() == level.lvl ); // Teraz sprawdzamy co się stanie, gdy w nagłówku zabraknie danych // opcjonalnych: autor i wersja. Oczekujemy, że zostaną zastosowane // wartości domyślne. LevelHeader header2; header2.name = level ; header2.file = level.lvl ; level.load(header2); CPPUNIT_ASSERT(level.isValid()); CPPUNIT_ASSERT(level.getAuthor() == DEFAULT_AUTHOR); CPPUNIT_ASSERT(level.getVersion() == DEFAULT_VERSION); CPPUNIT_ASSERT(level.getVersion() == 1.1);

CPPUNIT_ASSERT(level.getFileName() == level.lvl ); // Gdy nie zostaną podane wartości wymagane, poziom powinien mieć // status: nieprawidłowy funkcja isvalid() zwróci wartość false. LevelHeader header3; header3.author = author ; header3.name = level ; header3.version = 1.1; level.load(header3); CPPUNIT_ASSERT(level.isValid() == false); // Alteratywą dla powyższego rozwiązania będzie sprawdzenie rzucenia // wyjątku, typu Invalid_Header: // CPPUNIT_ASSERT_THROW(level.load(header3), Invalid_Header); URUCHAMIANIE TESTÓW Zatem mamy już napisane kilka testów, ale kompilacja źródeł nie powoduje wcale, że otrzymujemy ich wyniki. Nadal nie wiemy, które z nich zaliczyły, a które nie. Cała nasza praca poszła na marne? Na szczęście nie, ale faktycznie aby je zobaczyć należy najpierw testy uruchomić. Za uruchamianie testów są odpowiedzialne obiekty klasy TestRunner. Przed uruchomieniem testów należy je dodać wywołując metodę TestRunner::addTest(). Metoda ta przyjmuje za argument wskaźnik na obiekt typu Test (przypominam, że metoda suite() zwraca taki wskaźnik). Po dodaniu wszystkich testów wystarczy wywołać metodę TestRunner::run() i wszystko się wyjaśni. Poniżej znajduje się kod dla naszej sytuacji: Listing 6 int main() CppUnit::TextUi::TestRunner runner; runner.addtest(testgamelevel::suite()); runner.addtest(testmatrix4::suite()); runner.run(); RAPORTY Niezwykle przydatną cechą biblioteki CppUnit jest to, że pozwala ona na generowanie czytelnych raportów z przeprowadzonych testów. Wyczytamy z nich ile testów zostało przeprowadzonych, które nie zaliczyły, w którym miejscu testu wystąpił błąd. Najczytelniejszym wg mnie jest arkusz xmla. Za to w jaki sposób zostanie przedstawiony raport z testów odpowiada obiekt klasy Outputter (dla xml a będzie to XmlOutputter), któremu można przekazać do konstruktora strumień, do którego chcemy zapisać dane. Ustawienie danego wyjścia odbywa się poprzez wywołanie metody TextUi::TestRunner::setOutputter(). Kod w naszej sytuacji będzie wyglądał następująco: Listing 7 CppUnit::TextUi::TestRunner runner;

std::ofstream ofs( tests.xml ); CppUnit::XmlOutputter* xml = new CppUnit::XmlOutputter(&runner.result(), ofs); xml->setstylesheet( report.xsl ); runner.setoutputter(xml); runner.addtest(testgamelevel::suite()); runner.addtest(testmatrix4::suite()); runner.run(); W kolejnych wierszach tworzymy kolejno strumień, obiekt outputtera i przypisujemy mu wybrany arkusz styli. AUTOMATYZACJA Bardzo ważną kwestią w nowoczesnych metodach testowania kodu jest zagadnienie tzw. automatyzacji. Jakiś czas temu zauważono, że najlepiej byłoby gdyby testy uruchamiane były automatycznie co jakiś czas (np. przed umieszczeniem kodu w repozytorium SVN czy CVS, czy też raz na tydzień), dzięki czemu byłaby gwarancja, że wprowadzane zmiany nie zaburzają poprawności działania kodu. CppUnit udostępnia do tego celu odpowiednie narzędzie. Niestety należy ono do tych, których kompilacja w popularnym VC++ Express jest niemożliwa. Napisanie prostego narzędzia do tego celu choć specjalnie trudne nie jest, na pewno jest czasochłonne. Na szczęście VC++ udostępnia mechanizm Build-Events, który choć trochę może nam pomóc. W Post-Build Event dopisz: $(ConfigurationName)\Test.exe $(ConfigurationName)\tests.xml Te dwa proste wiersze spowodują, że testy zostaną uruchomione ilekroć zbudowany zostanie projekt z testami (a powinien być budowany zawsze), a do tego zostanie otwarty raport z ich działania. Proste i pożyteczne. PORADY Poniżej znajduje się kilka użytecznych rad dotyczących tworzenie unit-testów: Czego nie należy testować? o o Przede wszystkim nie należy testować kodu pochodzącego całkowicie z zewnątrz. Rozumiem przez to oczywiście wszelkie biblioteki, silniki, których nie napisaliśmy sami, a które wykorzystujemy w swojej grze. Przeważnie i tak nie mamy takiej możliwości, bo nie mamy dostępu do kodu źródłowego, ale ważniejsze jest to, że nigdy nie będziemy znali danej biblioteki tak dobrze jak jej twórcy. Zresztą z jakiego powodu mielibyśmy wyręczać w pracy innych programistów? Zwykle nie ma też potrzeby testowania akcesorów, podobnie jak nie ma ich sensu uwzględniać w diagramach UMLa. Niemniej nie jest to regułą i czasem należy postąpić inaczej. Analogiczna sytuacja występuje dla przeciążonych operatorów. Testując, należy mieć po prostu na uwadze, że błąd może się pojawić nawet w pozornie łatwej funkcji. Dobrym przykładem są tu klasy należące do bibliotek matematycznych. Prędzej czy później będziemy chcieli je pewnie zoptymalizować pod kątem SIMD. Wówczas ich kod znacznie się skomplikuje. Dzięki napisaniu testów będziemy mieli gwarancję, że ta zmiana nie zaburzy prawidłowego funkcjonowania kodu. Ponadto wykonywanie działań matematycznych np. na

kwaternionach jest samo w sobie na tyle skomplikowane, że nawet w nie-simdowej formie jest narażone na trudne do uchwycenia błędy. o Test nigdy nie powinien wychodzić poza obręb testowanej jednostki (funkcji/klasy). Jeśli w teście dochodzi do jakiejś interakcji z innymi jednostkami można tu już bowiem mówić o teście funkcjonalnym. Innymi słowy w podejściu obiektowym nie powinniśmy wywoływać metod należących do klas nie będących częścią zbioru testów. W podejściu strukturalnym funkcji. Unit-testy wskazują jedynie czy kod jest poprawny. Nie są jednak w żadnym stopniu zdolne udzielić nam odpowiedzi, czy ten kod jest rzeczywiście funkcjonalny czy grywalny - powiedzą nam, czy w wyniku trafienia przeciwnika odebrane zostało mu życie, ale nie powiedzą nam, czy liczba odebranych punktów życia ma jakikolwiek sens praktyczny. Unit-testy nie do tego zostały wymyślone! Wniosek stąd jest prosty nie możemy całkowicie zrezygnować z QA. Testy nie są nam też w stanie powiedzieć nic o wydajności aplikacji, ani wyłapać błędów prowadzących do jej zmniejszenia (mam tu na myśli np. wybór złego algorytmu czy struktury danych). Gdzie leży granica między unit-testami, a testami funkcjonalnymi? Zgodnie z tym co zostało powiedziane testy funkcjonalne sprawdzają poprawność oprogramowania na wyższym poziomie, na poziomie współpracy między jego poszczególnymi częściami. Teoria zatem brzmi niezwykle prosto i typowym przykładem testu funkcjonalnego może być następująca sytuacja. Tworzymy obiekt graficzny za pomocą menedżera siatek, po czym dodajemy go do grafu sceny poprzez menedżer sceny. Następnie sprawdzamy zawartość tego grafu. Jednak nie zawsze ta granica musi być tak oczywista, np. mnożenie macierzy 4x4 przez wektor 4- wymiarowy. Z jednej strony są to dwie oddzielne klasy opakowujące pewne określone operacje. Z drugiej, z logicznego punktu widzenia stanowią jeden rodzaj funkcjonalności (zwłaszcza, że wektor w algebrze liniowej jest niczym innym jak macierzą o jednym z wymiarów wynoszącym 1). Zatem tu w moim przekonaniu lepiej użyć unit-testów. Przy okazji chciałbym podkreślić, że tandem unit-testy + testy funkcjonalne jest znacznie silniejszą bronią w walce z błędami niż tylko jeden rodzaj testów! Teoretycznie często zaleca się, aby jeden przypadek testowy był jedną funkcją. Jednak od razu widać, że jeśli dla jednej tylko funkcji napiszemy kilka przypadków testowych to dla całej klasy takich funkcji może być kilkadziesiąt i więcej, co sprawia, że kod testów staje się mało przejrzysty. Warto zatem wszystkie powiązane ze sobą przypadki testowe (np. pogrupowane pod kątem funkcji, których dotyczą) umieścić razem tj. w jednej funkcji testującej. Często nie jesteśmy w stanie jednoznacznie podać rezultatu jakiego oczekujemy od funkcji, lub dopuszczamy pewien margines błędu. Taka sytuacja może mieć miejsce np. w przypadku obliczeń na liczbach zmiennoprzecinkowych, bądź wykorzystywania stablicowanych wartości dla funkcji trygonometrycznych. Opłaca się wówczas wykorzystać pewne własności funkcji i to je testować. Przykładem może być badanie wyjścia funkcji sinus. Na wejściu możemy podać losowe kąty 7 i 7 Ze stosowaniem losowych testów należy bardzo uważać, bo nie mamy nad nimi pełnej kontroli. Z jednej strony mamy znacznie więcej przypadków testowych niż określilibyśmy ręcznie. Z drugiej może się zdarzyć tak, że będą one należały do określonego przedziału, tym samym pozostawiając nieprzetestowanym inne przedziały. Jednak istnieje możliwość określenia pewnych reguł generowania przypadków testowych. Takie

sprawdzić czy zawsze wynik jest większy równy od -1 i mniejszy równy od 1. Dla małych kątów możemy natomiast sprawdzić czy wartość sinusa jest w przybliżeniu równa samej wartości kąta. PODSUMOWANIE Podsumowując należy jeszcze raz podkreślić, że nawet unit-testy (czy jakiekolwiek inne testy) nie uchronią cię całkowicie przed błędami. Nadal będą się one pojawiać, choć z dużym prawdopodobieństwem w znacznie mniejszych ilościach. Unit-testy mogą nam oszczędzić wielu problemów, jest to jak napisał Blake Madden automatyczny debugger, który większość pracy wykonuje za nas. Niestety, mimo ich niewątpliwych zalet mają pewne problemy, aby zagościć na poważnie w przemyśle gier wideo. Dlaczego? Nie wiem sam szukam na to pytanie odpowiedzi i jak na razie jej nie znajduję. Niemniej zachęcam wszystkich do ich testowania. Niech nasze gry mają jak najmniej błędów! I na koniec jeszcze jedno zdanie: BIBLIOGRAFIA Błędny test jest gorszy niż jego brak [CppUnit01] Dokumentacja biblioteki CppUnit, dostępna online na http://cppunit.sourceforge.net/doc/1.8.0/index.html [Madden01] Madden Blake, Using CppUnit to Implement Unit Testing, Games Programming Gems 6, Charles River Media [Toman01] Toman Wojciech, Sport dla programistów, czyli programowanie ekstremalne, dostępny online na http://wtoman.awardspace.com/articles/xp.pdf podejście prowadzi do jeszcze większej automatyzacji procesu testowania, przy zachowaniu przynajmniej częściowej kontroli nad postacią przypadków testowych.