Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autor: 'andcpp'
Inne artykuły

[C++, boost] Tworzenie testów jednostkowych (unit tests)

[artykuł] W artykule opisano metodykę wytwarzania oprogramowania TDD (Test-driven development), proces instalacji niezbędnych pakietów boost, tworzenie testów jednostkowych oraz ich uruchamianie.

Wstęp

Artykuł ten ma na celu przekazanie wstępnych wiadomości z testowania oprogramowania wykorzystując bibliotekę Boost.
Na początek należy jednak powiedzieć, co oznacza unit test. W programowaniu metoda testowania tworzonego oprogramowania poprzez wykonywanie testów weryfikujących poprawność działania pojedynczych elementów (jednostek) programu – np. metod lub obiektów w programowaniu obiektowym lub procedur w programowaniu proceduralnym. Testowany fragment programu poddawany jest testowi, który wykonuje go i porównuje wynik (np. zwrócone wartości, stan obiektu, wyrzucone wyjątki) z oczekiwanymi wynikami – tak pozytywnymi, jak i negatywnymi (niepowodzenie działania kodu w określonych sytuacjach również może podlegać testowaniu).
Zaletą testów jednostkowych jest możliwość wykonywania na bieżąco w pełni zautomatyzowanych testów na modyfikowanych elementach programu, co umożliwia często wychwycenie błędu natychmiast po jego pojawieniu się i szybką jego lokalizację zanim dojdzie do wprowadzenia błędnego fragmentu do programu. Testy jednostkowe są również formą specyfikacji.
Pojedyncze testy nazywane są „test case”. Wszystkie test case-y są zamykane w oddzielnych test suite-ach. Czyli na jeden test suite składa się więcej test case. Odpalając testy możemy albo włączyć jeden konkretny test case albo puścić całą suite.
Pisanie testów jednostkowych jest bardzo ważnym elementem pisania oprogramowania ze względu na koszty. Pisząc testy cały czas mamy pod kontrolą kod. Jeżeli coś przestanie działać po jakiejś zmianie, wprowadzeniu jakiejś nowej funkcjonalności możemy szybko zlokalizować moment, w którym ten błąd się pojawia. Po puszczeniu testów wiemy, które testy nam failują. Oczywiście musimy pamiętać, by dobrze dobrać nazwy testów, tak aby w momencie pojawienia się jakiś błędów szybko znaleźć błąd w kodzie.

TDD - test driven development

Zanim przejdziemy do omawiania testowania kodu cpp za pomocą narzędzi boost chciałbym zwrócić uwagę na pojęcie TDD, czyli test driven development.
Co to oznacza ?
Jak sama nazwa wskazuje głównie chodzi, o to żeby najpierw pisać test, a następnie przejść do pisania kodu. Nie zawsze to wychodzi (albo prawie w ogóle) jednak trzeba to mieć na uwadze. Przede wszystkim należy nie zapominać o pisaniu testów. Na początku może się to wydawać niekonieczne i zbędne jednak po wyjściu na etapie końcowym pisani projektu jakiegoś błędu za pomocą testów szybko można zlokalizować miejsce błędu. Jest to wtedy mniej kosztowne.
Test-driven development jest techniką tworzenia oprogramowania zaliczaną do metodyk zwinnych (Agile). Pierwotnie była częścią programowania ekstremalnego lecz obecnie stanowi samodzielną technikę. Polega na wielokrotnym powtarzaniu kilku kroków:
    1.Najpierw programista pisze automatyczny test sprawdzający dodawaną funkcjonalność. Test w tym momencie nie    powinien się udać.
    2.Później następuje implementacja funkcjonalności. W tym momencie wcześniej napisany test powinien się udać.
    3.W ostatnim kroku, programista dokonuje refaktoryzacji napisanego kodu, żeby spełniał on oczekiwane
standardy.

Programowanie techniką test-driven development wyróżnia się tym, że najpierw programista zaczyna od pisania testów do funkcjonalności, która jeszcze nie została napisana. Na początku testy mogą nawet się nie kompilować, ponieważ może nie być jeszcze elementów kodu (metod, klas), które są w testach użyte.
Na początku zaczyna się od przypadku, który nie przechodzi testu - zapewnia to, że test na pewno działa i może wyłapać błędy.

Testy powinny:
  • być proste,
  • implementować funkcjonalność,
  • mieć uporządkowany kod (refaktoryzacja)

Instalacja niezbędnych pakietów

Po pierwsze na początku trzeba zainstalować Boosta. Część odpowiadającą za testy bądź prawdopodobnie całego Boosta, jeżeli planuje się używać czegoś więcej niż tylko samych testów.
Można ściągnąć bezpośrednio z

http://www.boost.org/users​/download/

Wtedy należy rozpakować archiwum.
Jedną z najprostszych opcji przy założeniu, że pracuje się na Linuxie jest zainstalowanie paczki
libboost-test-dev, a mianowicie:
$ sudo apt-get install libboost-test-dev

Dynamic linking – wybranie swojego modelu kompilacji

Niestety wiele boostowych bibliotek (które są całkowicie zaimplementowane jako heder-y) Boost.Test zawierają tak zwane runtime component-y, które trzeba podłączyć.
Kluczową sprawą pisania oraz budowania testów jest „includowanie” poprawnych definicji w kodzie oraz dodawanie odpowiednich flag podczas linkowania. Zdecydowanie jestem za dynamicznym linkowaniem zamiast używania „rebuildowanych” bibliotek zainstalowanych przez moje pakiety Ubuntu. Chcąc to osiągnąć należy wykonać dwie rzeczy:

    1) zdefiniować
BOOST_TEST_DYN_LINK
 przed „includowaniem” nagłówków Boost.Test w kodzie źródłowym
    2) dodatkowo jeszcze dodanie specjalnej flagi do linkera :
      
lboost_unit_test_framework


Po instalacji oraz wstępnych informacjach na temat testów możemy przejść do testowania.

Pierwszy Test Case

Na początek przygotowałem wyjątkowo prostą funkcje, która dodaje dwa int-y. Natomiast sam test sprawdza czy funkcja pracuje dobrze :
C/C++
#define BOOST_TEST_DYN_LINK
#define BOOST_TEST_MODULE Hello
#include <boost/test/unit_test.hpp>

int dodaj( int i, int j )
{
    return i + j;
}

BOOST_AUTO_TEST_CASE( testDodaj )
{
    BOOST_CHECK( dodaj( 2, 2 ) == 4 );
}
Należy zauważyć, że oprócz definicji
BOOST_TEST_DYN_LINK
 również zdefiniowałem nazwę dla swojego testu (test module) BOOST_TEST_MODULE Hello. W kolejnej linijce (3) includujelmy potrzebne nagłówki. Następnie prosta funkcja, o której było wspomniane wyżej.
W linijce 10 zaczyna się pierwszy test, w którym jako argument podajemy nazwę testu. Ostatecznie wykorzystujemy makro BOOST_CHECK do sprawdzenia wyniku funkcji. Jest to najprostsza asercja.

Kompilacja

Załóżmy, że nasz plik nazywa się test.cpp. Żeby skompilować nasz test należy to zrobić w następujący sposób:
g++ -o test test.cpp -lboost_unit_test_framework-mt
Chcąc zobaczyć wyniki naszego testu wywołamy komende:
./test --log_level=test_suite
Flaga:
--log_level=test_suite
Oznacza, że chcemy wyświetlić wszystkie logi z testu. Trzeba pamiętać o tym, iż wywołanie powyższej komendy ze spacjami nie wyświetli nam wyników logów.
--log_level = test_suite // błąd

Output z konsoli

Running 1 test case...
*** No errors detected
W naszym pliku jest tylko jeden test case, stąd komentarz w logach. Dostajemy również informacje o braku błędów, ponieważ wynik funkcji jest taki sam jak się spodziewaliśmy.

Istnieje również możliwość zapisania wszystkich wyników do pliku, który możemy nazwać roboczo plikZwynikami.txt . Komenda lekko zmodyfikowana będzie wyglądać tak:

./test --log_level=test_suite > plikZwynikami.txt

Teraz nasze wyniki testów zostaną zapisane do podanego wyżej pliku.
Co się stanie, gdy dodamy jakiś błąd.
C/C++
BOOST_CHECK( dodaj( 2, 2 ) == 3 );
Jak widać wynik dodawania naszej funkcji jest równy 4, natomiast w teście spodziewamy się 3.

Output z konsoli

Running 1 test case...
test.cpp(12): error in "testDodaj": check dodaj(2, 2) == 3 failed

*** 1 failure detected in test suite "Hello"

Dostajemy informacje o błędzie w linijce 12. Wynik funkcji nie jest taki jakiego się spodziewaliśmy.

Suites – kategorie testów

Może zdarzyć się przypadek, gdy potrzebujemy napisać więcej niż jeden test (test case). Przeważnie bywa tak, że część testów odpowiada za pewną część kodu, a druga część za inną. Zupełnie inną funkcjonalność. W takim wypadku należy zamknąć testy odpowiadające ze pewną część w jedną całość – suite. Suite jest zbiorem testów odpowiadających za pewną część kodu. Przeważnie do każdej klasy piszemy osobną Test Suite, a w niej zamieszczamy test case-y testujące poszczególne metody.

Przykład 1

C/C++
#define BOOST_TEST_DYN_LINK
#define BOOST_TEST_MODULE Suites
#include <boost/test/unit_test.hpp>

int odejmij( int i, int j )
{
    return i - j;
}

BOOST_AUTO_TEST_SUITE( MatematykaSuite )
BOOST_AUTO_TEST_CASE( testOdejmij )
{
    BOOST_CHECK( odejmij( 3, 3 ) == 0 );
}
BOOST_AUTO_TEST_SUITE_END()

BOOST_AUTO_TEST_SUITE( FizykaSuite )
BOOST_AUTO_TEST_CASE( testPrzyspieszenie )
{
    int F = 250;
    int m = 25;
    int a = 10; // przyśpieszenie
    BOOST_CHECK( F == m * a ); // proste porównanie
}
BOOST_AUTO_TEST_SUITE_END()

Output z konsoli

Running 2 test cases...
Entering test suite "Suites"
Entering test suite "MatematykaSuite"
Entering test case "testOdejmij"
Leaving test case "testOdejmij"
Leaving test suite "MatematykaSuite"
Entering test suite "FizykaSuite"
Entering test case "testPrzyspieszenie"
Leaving test case "testPrzyspieszenie"
Leaving test suite "FizykaSuite"
Leaving test suite "Suites"

*** No errors detected

Running 2 test cases... – zaimplementowaliśmy dwa test case.
Entering test suite "Suites" – nasz test module nazywa się TEST_MODULE Suites
Następnie wchodzimy do pierwszej Suite - "MatematykaSuite", do pierwszego test case. Nie ma żadnych błędów. Wychodzimy i opuszczamy Suite "MatematykaSuite". Analogicznie z Suite "FizykaSuite”. Na samym końcu opuszczamy cały nasz moduł Suites.
Jak widać w tym prostym przykładzie pierwsza suite jest związana z matematyką, dlatego zawiera w sobie testy, które testują funkcje odejmij. W suite FizykaSuite znajduje się test, który mając podaną mase oraz przyśpieszenie oblicza sile oraz porównuje z oczekiwaną.
Oczywiście istnieje możliwość dodania więcej niż jednego test case do przykładowo pierwszej suite-y.

Przykład 2

C/C++
#define BOOST_TEST_DYN_LINK
#define BOOST_TEST_MODULE Suites
#include <boost/test/unit_test.hpp>

int odejmij( int i, int j )
{
    return i - j;
}

int dodaj( int i, int j )
{
    return i + j;
}

BOOST_AUTO_TEST_SUITE( MatematykaSuite )
BOOST_AUTO_TEST_CASE( testOdejmij )
{
    BOOST_CHECK( odejmij( 3, 3 ) == 0 );
}
BOOST_AUTO_TEST_CASE( testDodaj )
{
    BOOST_CHECK( dodaj( 4, 5 ) == 9 );
}
BOOST_AUTO_TEST_SUITE_END()

BOOST_AUTO_TEST_SUITE( FizykaSuite )
BOOST_AUTO_TEST_CASE( testPrzyspieszenie )
{
    int F = 250;
    int m = 25;
    int a = 10; // przyśpieszenie
    BOOST_CHECK( F == m * a ); // proste porównanie
}
BOOST_AUTO_TEST_SUITE_END()

Myślę, że jest to na tyle prosty przykład, że nie wymaga dalszego wyjaśnienia.

Więcej asercji

W tej części artykułu chciałbym zaznaczyć, iż istnieje wiele różnych „asercji” dostępnych w bibliotece Boost. Przykładowo makro BOOST_CHECK w momencie, gdy failuje nie zatrzyma test case w sposób natychmiastowy. Gdy chcemy, by nasz test sfailował natychmiastowo należy użyć makra BOOST_REQUIRE.
C/C++
BOOST_AUTO_TEST_CASE( testDodaj )
{
    BOOST_REQUIRE( Dodaj( 2, 2 ) == 4 );
}
Gdy chcemy wyrzucić na ekran sam warning, gdy test failuje można użyć do tego makra BOOST_WARN. W rzeczywistości dużo testów napisanych w booscie wykorzystuje trzy podstawowe warianty:
  • CHECK
  • REQUIRE
  • WARN
Następnie chciałbym pokazać więcej przykładów:
  • BOOST_CHECK_MESSAGE
     - Pozwala określić niestandardowy komunikat o awarii jako drugi argument. Można przekazać ciąg znaków, albo wykorzystać inny typ, który jest wspierany przez operator <<.
  • BOOST_CHECK_EQUAL
     - Sprawdza dwa argumenty na rzecz równości za pomocą operator ==. Jest to lepsze makro od BOOST_CHECK w powyższych przykładach, ponieważ pokazuje aktualną wartość kiedy test failuje.
  • BOOST_CHECK_THROW
     - Sprawdza, czy wyrażenie powoduje określony typ wyjątku do rzucania.
Pełna lista dostępnych asercji jest dostępna pod adresem
http://www.boost.org/doc/libs/1_34_1/libs/test/doc/components/test_tools/reference/index.html

BOOST_FIXTURE_TEST_CASE - inne makro

Zacznę od przedstawienia przykładu:
C/C++
#define BOOST_TEST_DYN_LINK
#define BOOST_TEST_MODULE Fixtures
#include <boost/test/unit_test.hpp>

struct MyFixture
{
    MyFixture()
    {
        i = new int;
        * i = 0;
    }
   
    ~MyFixture()
    {
        delete i;
    }
   
    int * i;
};

//BOOST_FIXTURE_TEST_SUITE(Physics, MyFixture)
BOOST_FIXTURE_TEST_CASE( test_case1, MyFixture )
{
    //MyFixture f;
   
    BOOST_CHECK( * i == 0 );
    // do something involving f.i
}

BOOST_AUTO_TEST_CASE( test_case2 )
{
    MyFixture f;
   
    // do something involving f.i
}
//BOOST_AUTO_TEST_SUITE_END()
Pierwsze trzy linijki kodu są bardzo podobne do wcześniejszych przykładów i nie zasługują na komentarz.

Na początku mamy strukturę o nazwie MyFixture, która zawiera pole typu wskaźnik na int. Jest on inicjalizowany wewnątrz konstruktora struktury.
Jak można zauważyć pierwszy test case
C/C++
BOOST_FIXTURE_TEST_CASE( test_case1, MyFixture )
oprócz nazwy samego testu zawiera również nazwę struktury. Dzięki tej operacji we wnętrzu testu możemy dokonać sprawdzenia
C/C++
BOOST_CHECK( * i == 0 );
nie tworząc instancji obiektu naszej struktury. Nie wyklucza to jednak możliwości stworzenia wewnątrz tego testu takiego obiektu i działania na nim. Ten fragment kodu jest zakomentowany jednak zachęcam do eksperymentowania.
Drugi test case jest już dosyć standardowy.
C/C++
BOOST_AUTO_TEST_CASE( test_case2 )

Output z konsoli

Running 2 test cases...
Entering test suite "Fixtures"
Entering test case "test_case1"
Leaving test case "test_case1"
Entering test case "test_case2"
Test case test_case2 did not check any assertions
Leaving test case "test_case2"
Leaving test suite "Fixtures"

*** No errors detected
Co więcej możemy również zamknąć nasze test case w bloku:
C/C++
BOOST_FIXTURE_TEST_SUITE( Physics, MyFixture )
BOOST_AUTO_TEST_SUITE_END()
Są to makra, które w powyższym przykładzie zostały zakomentowane. Powodują one jednak działanie podobne do sytuacji opisanej powyżej. Z tą różnicą, że teraz obejmując nasze test case-y w tych makrach nie musimy tworzyć instancji struktury MyFixture chcą działać na polu wskaźnik do int-a „i” zadeklarowanej w naszej strukturze.

Ręcznie zarejestrowane zestawy testów

W tym rozdziale również zacznę od podania przykładu, a następnie przejdę do jego omówienia.
C/C++
#include <boost/test/included/unit_test.hpp>
using namespace boost::unit_test;

void test_case1() { /* : */ }
void test_case2() { /* : */ }
void test_case3() { /* : */ }
void test_case4() { /* : */ }

test_suite *
init_unit_test_suite( int argc, char * argv[] )
{
    test_suite * ts1 = BOOST_TEST_SUITE( "test_suite1" );
    ts1->add( BOOST_TEST_CASE( & test_case1 ) );
    ts1->add( BOOST_TEST_CASE( & test_case2 ) );
   
    test_suite * ts2 = BOOST_TEST_SUITE( "test_suite2" );
    ts2->add( BOOST_TEST_CASE( & test_case3 ) );
    ts2->add( BOOST_TEST_CASE( & test_case4 ) );
   
    framework::master_test_suite().add( ts1 );
    framework::master_test_suite().add( ts2 );
   
    return 0;
}
Wydaje mi się, że sporo wyjaśni poniższy schemat:

Jak widać w kodzie oraz w schemacie na samej górze mamy jedną główną suite. W niej zawierają się kolejne dwie suite. Suite te zawierają test case-y.
Wracając do kodu na początku tworzymy cztery funkcje, które nie zawierają ciała.
Następnie tworzymy wskaźnik typu test_suite. To będzie nasz test suite o nazwie „test_suite1”:
C/C++
test_suite * ts1 = BOOST_TEST_SUITE( "test_suite1" );

Następnie do naszej suite dodajemy pierwszy test case, a za nim kolejny:
C/C++
ts1->add( BOOST_TEST_CASE( & test_case1 ) );

Analogicznie postępujemy dla drugiej test suite o nazwie „test_suite2”:
C/C++
test_suite * ts2 = BOOST_TEST_SUITE( "test_suite2" );

Na sam koniec do naszej głównej suite „Master test suite” dodajemy nasze dwie test suite:
C/C++
framework::master_test_suite().add( ts1 );
framework::master_test_suite().add( ts2 );
Analizując teraz cały kod można zauważyć, że wszystko, co stworzyliśmy idealnie pokrywa nam się z naszym schematem.

Output z konsoli

Running 4 test cases...                                                                                                                                                   
Entering test suite "Master Test Suite"                                                                                                                                   
Entering test suite "test_suite1"
Entering test case "test_case1"
Test case test_case1 did not check any assertions
Leaving test case "test_case1"
Entering test case "test_case2"
Test case test_case2 did not check any assertions
Leaving test case "test_case2"
Leaving test suite "test_suite1"
Entering test suite "test_suite2"
Entering test case "test_case3"
Test case test_case3 did not check any assertions
Leaving test case "test_case3"
Entering test case "test_case4"
Test case test_case4 did not check any assertions
Leaving test case "test_case4"
Leaving test suite "test_suite2"
Leaving test suite "Master Test Suite"
W outpucie jedynie, co może być trochę niepokojące to linijka:
Test case test_case1 did not check any assertions
Jednak patrząc do naszych testów jest to spodziewana informacja z tego względu, iż nasze test case-y, które zostały zdefiniowane przez funkcje test_caseN nie posiadają ciała. Stąd warning w outpucie mówiący o tym, że nasze testy nic nie testują.

Uruchamianie jednostek testowych za pomocą nazw

Na samym początku kolejnego rozdziału chciałbym przedstawić kolejny przykład:
C/C++
#define BOOST_TEST_MODULE example
#include <boost/test/included/unit_test.hpp>

BOOST_AUTO_TEST_CASE( testA )
{
}

BOOST_AUTO_TEST_CASE( testB )
{
}

BOOST_AUTO_TEST_SUITE( s1 )

BOOST_AUTO_TEST_CASE( test1 )
{
}

BOOST_AUTO_TEST_CASE( test2 )
{
}

BOOST_AUTO_TEST_SUITE_END()

BOOST_AUTO_TEST_SUITE( s2 )

BOOST_AUTO_TEST_CASE( test1 )
{
}

BOOST_AUTO_TEST_CASE( test11 )
{
}

BOOST_AUTO_TEST_SUITE( in )

BOOST_AUTO_TEST_CASE( test )
{
}

BOOST_AUTO_TEST_SUITE_END()

BOOST_AUTO_TEST_SUITE_END()
Jak widać powyższy kod zawiera kilka test case-ów, oraz test suite, które zawierają z kolei inne test case-y. Uruchamiając testy istnieje możliwość uruchomienia konkretnego testu. Wystarczy, ze w momencie uruchamiania naszego kodu dodamy flagę
--run_test=X
gdzie X oznacza np. nazwę testu. Należy jednak pamiętać, że dodanie spacji nie spowoduje oczekiwanego efektu.
Możliwości uruchomienia testów na podstawie podanej nazwy:
./test --log_level=test_suite --run_test=testA
./test --log_level=test_suite --run_test=testA,testB
./test --log_level=test_suite --run_test=testC
./test --log_level=test_suite --run_test=s1
./test --log_level=test_suite --run_test=s2/in/test
./test --log_level=test_suite --run_test=*/test1
./test --log_level=test_suite --run_test=s2/test*
./test --log_level=test_suite --run_test=*/*1
./test --log_level=test_suite --run_test=s1/*est*
Przykładowo wywołując tą komendę:
./test --log_level=test_suite --run_test=s2/in/test
dostaniemy się do test suite o nazwie „s2”, następnie do test suite „in” i uruchomimy test case o nazwie „test”.

Output z konsoli

Running 1 test case...
Entering test suite "example"
Entering test suite "s2"
Entering test suite "in"
Entering test case "test"
Test case test did not check any assertions
Leaving test case "test"
Leaving test suite "in"
Leaving test suite "s2"
Leaving test suite "example"

*** No errors detected
Gdy jednak uruchomimy nasz kod za pomocą standardowej komendy (takiej jakiej używaliśmy wcześniej)
./test --log_level=test_suite
otrzymamy następujcy output.

Output z konsoli

Running 7 test cases...                                                                                                                                                   
Entering test suite "example"                                                                                                                                             
Entering test case "testA"                                                                                                                                                
Test case testA did not check any assertions                                                                                                                              
Leaving test case "testA"                                                                                                                                                 
Entering test case "testB"                                                                                                                                                
Test case testB did not check any assertions
Leaving test case "testB"
Entering test suite "s1"
Entering test case "test1"
Test case test1 did not check any assertions
Leaving test case "test1"
Entering test case "test2"
Test case test2 did not check any assertions
Leaving test case "test2"
Leaving test suite "s1"
Entering test suite "s2"
Entering test case "test1"
Test case test1 did not check any assertions
Leaving test case "test1"
Entering test case "test11"
Test case test11 did not check any assertions
Leaving test case "test11"
Entering test suite "in"
Entering test case "test"
Test case test did not check any assertions
Leaving test case "test"; testing time: 10ms
Leaving test suite "in"
Leaving test suite "s2"
Leaving test suite "example"

*** No errors detected

Bibliografia