TDD w systemach embedded - wszystkie wpisy

Częstą wymówką, aby nie pisać unit testów jest „Tego kodu nie da się przetestować.”, otóż zwykle jednak się da, tylko trzeba chwilę pomyśleć jak się do tego zabrać. Jednym z takich trudniejszych przypadków jest testowanie nieskończonych pętli. W tym wpisie pokażę kilka technik służących do pokrycia ich unit testami.

Po co nam w kodzie nieskończone pętle?

W systemach embedded nieskończone pętle stosuje się zdecydowanie częściej niż na PC. Najczęstsze przypadki użycia to:

  • Pętla główna – może to być zarówno pętla w pliku main, jak i dla pojedynczego tasku, jeśli używamy RTOSa. W tym przypadku fakt, że program nigdy nie wyjdzie z pętli jest zjawiskiem pożądanym i wręcz niezbędnym dla zapewnienia poprawnego działania aplikacji.
  • Obsługa błędów – kiedy nastąpi krytyczny błąd, system przechodzi w safe state, gdzie wszystkie wyjścia ustawione są w bezpieczne stany, przerwania wyłączone i program wchodzi w nieskończoną pętlę. Podobne zachowanie może występować również w assertach.

Kiedy chcemy przetestować kod z nieskończoną pętlą, głównym problemem jest fakt, że program nigdy nie wyjdzie z funkcji produkcyjnej. Powinniśmy więc na czas zastąpić nieskończoną pętlę. Najprostsze rozwiązanie może wyglądać tak:

#ifdef UNIT_TEST
#define TEST_ENDLESS_LOOP()     break
#else
#define TEST_ENDLESS_LOOP()
#endif

void fun_with_endless_loop(void)
{
    while (1)
    {
        TEST_ENDLESS_LOOP();
    }
}

Unit test wtedy po prostu pójdzie dalej. Dochodzimy wtedy jednak do kolejnego problemu. Jeżeli obsługujemy krytyczny błąd, wykonanie dalszego programu może nie być wskazane np. ze względu na dereferencję null pointerów.

Testowanie safe state

Zwykle testy safe state ograniczają się do sprawdzenia, czy kod produkcyjny wchodzi tam wtedy kiedy powinien i nie wchodzi, kiedy nie powinien. W takich testach zwykle nie sprawdzamy dodatkowych assertów. Dlatego idealnym rozwiązaniem będzie exploding fake. Jest to typ dublera testowego powodujący natychmiastowe zakończenie testu sukcesem lub błędem. Pisałem na ten temat w artykule o rodzajach mocków.

Mocki – radzenie sobie z zależnościami w testach

Mock safe state może wyglądać następujaco:

static bool should_pass = false;

void mock_safe_state_init(void)
{
    should_pass = false;
}

void mock_safe_state_should_pass(void)
{
    should_pass = true;
}

void safe_state(void)
{
    if (true == mock_params.should_pass)
    {
        TEST_PASS();
    }

    TEST_FAIL();
}

Macra TEST_PASS i TEST_FAIL powinny wykorzystywać funkcje frameworka do natychmiastowego kończenia testu sukcesem lub błędem. Co ciekawe, nie wszystkie frameworki testowe są wyposażone w takie – wydawałoby się podstawowe – funkcjonalności. A nawet jeśli je posiadają, mogą w nich czaić się jakieś kruczki (o tobie mówię googletest, wytłumaczenie w dalszej części wpisu).

Testowanie pętli głównej

Testowanie pętli głównej jest trudniejsze, niż w przypadku safe state. Tutaj nieskończona pętla dodatkowo wykonuje jakieś operacje. Co więcej w teście czasem chcemy wykonać jedną iterację pętli, a czasem więcej. Przykładowy kod tasku RTOSa, jaki chcemy przetestować, może wyglądać następująco:

void my_task(void *params)
{
    (void) params;

    while (1)
    {
        //do some stuff

        rtos_delay_ms(10);
    }
}

Potrzebujemy więc mechanizmu wychodzenia z pętli w zadanym momencie. Idealnym miejscem, gdzie możemy dodać taki mechanizm jest mock funkcji rtos_delay_ms.

Z wykorzystaniem exceptionów

Jeżeli korzystamy z frameworka w C++, możemy wykorzystać w tym celu exceptiony:

const uint32_t RTOS_DELAY_MAX_ITERS_EXCEPTION = 0xAA55CC33u;

struct mock_params
{
    int delay_expected_iters ;
    int delay_call_cnt;
};

static struct mock_params mock_params;

void mock_rtos_delay_ms_init(void)
{
    mock_params.delay_expected_iters = 0;
    mock_params.delay_call_cnt = 0;
}

void mock_rtos_delay_ms_expected_iters_set(int val)
{
    mock_params.delay_expected_iters = val;
}

void rtos_delay_ms(ms_t ms)
{
    if (mock_params.delay_expected_iters <= ++mock_params.delay_call_cnt)
    {
        throw RTOS_DELAY_MAX_ITERS_EXCEPTION;
    }
}

Test może wtedy wyglądać tak:

TEST_CASE(MyTestGroup, MyTestCase)
{
    mock_rtos_delay_ms_init();
    mock_rtos_delay_ms_expected_iters_set(3);

    ASSERT_THROW(RTOS_DELAY_MAX_ITERS_EXCEPTION, my_task(NULL));

    //assert for stuff inside task
}

Bez exceptionów

Wszystko fajnie, ale w embedded po pierwsze C++ może być kompilowany bez wykorzystania exceptionów, a po drugie możemy w ogóle pisać w czystym C i nie mieć możliwości korzystania z exceptionów. No cóż, wtedy często trzeba się bardziej nagimnastykować, a rozwiązanie może być zależne od frameworka. W C większość takich rozwiązań bazuje na znienawidzonej przez programistów bibliotece setjmp.h. We frameworku Unity na przykład możemy wykorzystać macra TEST_PROTECT i TEST_ABORT:

TEST_CASE(MyTestGroup, MyTestCase)
{
    mock_rtos_delay_ms_init();
    mock_rtos_delay_ms_expected_iters_set(3);

    if (0 == TEST_PROTECT())
    {
        my_task(NULL);
    }

    //assert for stuff inside task
}

Mock rtos_delay_ms musi wtedy wyglądać następująco:

void rtos_delay_ms(ms_t ms)
{ 
    if (mock_params.delay_expected_iters <= ++mock_params.delay_call_cnt)
    {
        TEST_ABORT();
    }
}

W innych frameworkach trzeba sprawdzić dokumentację w poszukiwaniu możliwości implementacji mocka. Jeżeli takowej nie ma, trzeba dorobić ją samemu przy okazji wspomnianej biblioteki setjmp.h.

Inne podejście

Testując funkcje w taskach FreeRTOSa często w ogóle nie używałem exceptionów, ani jumpów. Zamiast tego zadania do wykonania podczas pojedynczej iteracji opakowywałem w dodatkową funkcję i ją testowałem. Może wyglądać to tak:

void single_iteration(void)
{
    //do stuff for single loop iteration
}

void my_task(void *params)
{
    (void) params;

    while (1)
    {
        single_iteration();

        rtos_delay_ms(10);
    }
}

Problemy z googletest

Jeżeli chcielibyśmy przetestować safe state przy pomocy googletest, możemy się rozczarować. Po przestudiowaniu dokumentacji wiemy, że googletest posiada funkcje FAIL() i SUCCEED(), które powinny robić dokładnie to co chcemy – kończyć test z zadanym wynikiem. Niestety, kiedy podstawimy je do testu z przykładu wyżej, nie będą działać. Kiedy poszukamy w dokumentacji wskazówek, dlaczego tak się dzieje, znajdziemy opis:

The assertions come in pairs that test the same thing but have different effects on the current function. ASSERT_* versions generate fatal failures when they fail, and abort the current function.

FAIL i SUCCEED również zaliczają się do grupy ASSERT_*. Zgodnie z przytoczonym wyżej zdaniem wcale nie powodują one zakończenia testu, a jedynie wyjście z aktualnej funkcji. Natomiast funkcja wyżej jest wywoływana dalej i może sobie czytać null pointery. Takie zachowanie jest bardzo dziwne i mylące. Nie ma też dobrej alternatywy, która robiłaby to co chcemy. Możemy na przykład bawić się z death testami, albo włączyć obsługę wyjątków i korzystać z ASSERT_THROW.

Podsumowanie

Nieskończone pętle da się pokryć unit testami, ale wymaga to trochę gimnastyki. Nie ma jednego sposobu działającego we wszystkich przypadkach. Inne strategie sprawdzają się przy safe state, a inne przy testowaniu tasków RTOSa. Narzędzia wykorzystywane przy testach nieskończonych pętli nie są elementem standardowego wyposażenia każdego frameworka testowego. Dlatego należy zapoznać się z dokumentacją. Najlepiej sprawdzić framework pod kątem możliwości testowania nieskończonych pętli przed zastosowaniem go w projekcie. Oszczędzi nam to sporo pracy, jeżeli okaże się, że czegoś nie da się zrobić i musimy przejść na inny.

TDD w systemach embedded - Nawigacja