Aby stosować Test Driven Development potrzebujemy odpowiedniego frameworka testowego implementującego obsługę scenariuszy i grup testowych, drukowanie outputu, czy asserty. Mimo iż brak takiego frameworka nie może być wymówką, aby nie testować (najmniejszy framework w C składa się z 3 linii kodu!), dobre narzędzie ułatwi nam pracę i zwiększy produktywność. Dzisiaj opiszę dość ciekawe podejście do testowania projektu napisanego w C – wykorzystanie frameworka w C++. A jest nim CppUTest.
Podstawowe informacje
CppUTest to framework do unit testów napisany w C++. Główne założenia, które przyświecały twórcom:
- Prosty w użyciu i w budowie.
- Portowalny na wiele platform, również embedded.
- Stworzony z myślą o TDD.
Pierwotnymi autorami CppUTest byli znani programiści wywodzący się z nurtu Agile i Extreme Programming: Bas Vodde (twórca LeSS, czyli Scruma dla dużych organizacji), James Grenning (jeden z sygnatariuszy manifestu Agile, autor książki o TDD w systemach Embedded), Michael Feathers (autor książki o pracy z zastanym kodem). Aktualnie projekt znajduje się na GitHubie i w dalszym ciągu jest aktywnie rozwijany.
CppUTest jest napisany bez użycia zaawansowanych funkcji C++ np. templatów, ani nowości z C++11. Dzięki temu może być użyty w projektach embedded na starszych/prostszych procesorach, gdzie kompilator wspiera tylko pewien podzbiór języka. Kolejną konsekwencją prostoty frameworka jest niewielki rozmiar umożliwiający dual targeting, czyli uruchamianie testów nie tylko na PC, ale również na docelowej platformie.
CppUTest posiada kilka bardzo przydatnych funkcjonalności takich jak:
- Memory Leak Detector – po każdym teście sprawdza, czy cała zaalokowana pamięć została poprawnie zwolniona.
- CppUMock – wbudowana obsługa mocków.
- C API – headery kompatybilne z czystym C wołające pod spodem kod C++. Przydatne na przykład, gdy jakiś produkcyjny header nie kompiluje się w C++.
Czemu C++? W embedded pisze się w C
CppUTest jest wykorzystywany w projektach embedded mimo, że kod produkcyjny powstaje w C. Głównym czynnikiem przemawiającym za frameworkiem C++ jest prostota użytkowania. W CppUTest po prostu piszemy test case i mamy pewność, że zostanie on dodany do listy wykonywanych testów. Język C jest uboższy i twórcom frameworków nie udało się zapewnić takiej prostoty użytkowania. W C każdy test trzeba dodać ręcznie do grupy, a każdą grupę wywołać w funkcji main. W ten sposób dodawanie kolejnych testów jest utrudnione i łatwiej popełnić błąd. Możemy na przykład napisać test i nie zauważyć, że nie jest on wykonywany. Kolejną zaletą C++ nad C jest prostsze pisanie mocków. Udogodnienia takie jak klasy, templaty, czy dziedziczenie zdecydowanie zwiększają reuse kodu, co jest dużym problemem, jeśli piszemy mocki w C.
Aby poprawnie zintegrować produkcyjny kod w C i kod testowy w C++ musimy pamiętać o jednej ważnej rzeczy. Kompilator C++ (w przeciwieństwie do C) uzupełnia nazwy funkcji o dodatkowe informacje np. o przyjmowanych argumentach czy zwracanych wartościach. Czyli wykonuje name mangling. Dlatego, potrzebuje informacji, że funkcja jest skompilowana w czystym C i musi ją potraktować inaczej. Służy do tego składnia extern „C”:
extern "C" void fun_in_plain_c(int arg1);
Jeżeli dodajemy całe headery z kodu produkcyjnego w C możemy napisać:
extern "C" { #include "c_header1.h" #include "c_header2.h" }
Często w plikach, które mogą być użyte zarówno w C, jak i w C++ umieszczamy dyrektywy preprocesora:
#ifdef __cplusplus /* If this is a C++ compiler, use C linkage */ extern "C" { #endif /* These functions get C linkage */ void foo(void); #ifdef __cplusplus /* If this is a C++ compiler, end C linkage */ } #endif
Struktura unit testu
Pojedynczy build unit testu powinien składać się z:
- testowanego pliku produkcyjnego,
- funkcji main,
- plików zawierających scenariusze testowe,
- mocków zewnętrznych zależności.
Zaczniemy od funkcji main. W CppUTest plik zawierający funkcję main powinien wyglądać następująco:
#include "CppUTest/CommandLineTestRunner.h" int main(int ac, char** av) { return CommandLineTestRunner::RunAllTests(ac, av); }
Nie ma tutaj żadnego kodu zależnego od konkretnych grup i scenariuszy testowych, więc ten sam plik main może być współdzielony przez wszystkie buildy testowe.
Jeżeli chodzi o plik produkcyjny – najlepiej, aby był to pojedynczy plik. Jeżeli chcemy przetestować inny plik, robimy oddzielny build testowy. Chodzi o to, aby testować funkcje w odłączeniu od zależności od innych plików produkcyjnych. Dzięki temu za pomocą mocków mamy kontrolę nad wartościami przekazywanymi przez testowaną funkcję do zależności oraz nad wartościami zwracanymi przez zależność. Tutaj wychodzi jedna z największych zalet TDD – zwiększenie modułowości kodu produkcyjnego. Skoro każda zewnętrzna zależność oznacza dodatkowe mocki oraz scenariusze testowe, staramy się tak zaprojektować architekturę, aby ograniczyć ilość zależności.
Pisanie scenariuszy testowych
Scenariusze testowe są łączone w grupy skupiające logicznie połączone ze sobą testy. Grupy tworzymy za pomocą macra TEST_GROUP(NazwaGrupy). Na przykład dla testów protokołu komunikacji możemy stworzyć oddzielne grupy dla testów części nadawczej i odbiorczej. Testy w ramach grupy dzielą wspólne funkcje setup i teardown wywoływane na początku i na końcu każdego testu. Dobrą praktyką jest tworzenie oddzielnego pliku na każdą grupę testów. Szablon takiego pliku może wyglądać następująco:
#include "CppUTest/TestHarness.h" extern "C" { #include "production/header1.h" } #include "mock_templates.hpp" #include "mocks/dependency1_mocks.hpp" TEST_GROUP(GroupName) { void setup() { /* Initialization code for every test case. */ } void teardown() { /* Cleanup code for every test case. */ } }; TEST(GroupName, TestName) { FAIL("Implement your test here!"); }
Scenariusze testowe tworzymy za pomocą makra TEST(NazwaGrupy, NazwaTestu). Przy tworzeniu scenariuszy niezbędne są asserty sprawdzające zgodność wykonanego kodu z oczekiwaniami. W CppUTest mamy następujące asserty:
- CHECK(booleanCondition), CHECK_TRUE(booleanCondition), CHECK_FALSE(booleanCondition) – sprawdzenie warunku logicznego. CHECK i CHECK_TRUE oznaczają to samo.
- CHECK_EQUAL(expected, actual) – Porównanie wartości z oczekiwaną dla dowolnego typu przy pomocy operatora ==.
- LONGS_EQUAL(expected, actual), UNSIGNED_LONGS_EQUAL(expected, actual), BYTES_EQUAL(expected, actual) – Porównanie wartości z oczekiwaną dla określonych typów.
- DOUBLES_EQUAL(expected, actual, tolerance) – Porównanie wartości zmiennoprzecinkowych z zadaną tolerancją.
- BITS_EQUAL(expected, actual, mask) – Porównanie wartości pojedynczych bitów używając maski.
- POINTERS_EQUAL(expected, actual), FUNCTIONPOINTERS_EQUAL(expected, actual) – Porównania z wartością oczekiwaną dla wskaźników i wskaźników na funkcje.
- STRCMP_EQUAL(expected, actual), STRNCMP_EQUAL(expected, actual, length), STRCMP_NOCASE_EQUAL(expected, actual), STRCMP_CONTAINS(expected, actual) – Porównania dla stringów.
- MEMCMP_EQUAL(expected, actual, size) – Porównania dla obszarów pamięci.
- CHECK_THROWS(expected_exception, expression) – Sprawdzenie zwracanego wyjątku.
- FAIL(text) – Natychmiastowe zakończenie testu failem, text to wiadomość drukowana w outpucie.
- TEST_EXIT – Natychmiastowe zakończenie testu sukcesem.
Test powinien składać się z inicjalizacji, wywołania testowanego kodu i sprawdzenia poprawności jego wykonania. Zasady pisania dobrych scenariuszy testowych opisałem już kiedyś na blogu:
Podczas developmentu czasem przydaje się opcja ignorowania testu. Ignorowany test jest kompilowany, ale nie jest uruchamiany. Aby zignorować test należy do makra scenariusza testowego dodać prefix IGNORE_ – IGNORE_TEST(NazwaGrupy, NazwaTestu). Ignorowania testów możemy używać na przykład, kiedy w trakcie pisania jednego testu stwierdzimy, że czegoś brakuje nam jeszcze w implementacji i najpierw powinniśmy dodać inny test. Albo jeśli wprowadziliśmy większą zmianę powodującą wiele failów i chcemy je naprawiać stopniowo. Pamiętajmy jednak, że ignorowanie testów służy wyłącznie do developmentu. Skończona implementacja nie zawiera ignorowanych testów!
Słaba dokumentacja
Autorzy zgodnie z duchem Agile zakładają, że najlepszą dokumentacją jest działający kod. Dlatego klasyczna dokumentacja mocno kuleje. Szczególnie brakuje opisu zaawansowanych funkcjonalności, są tylko tutoriale wprowadzające. Przez to trudno porównać CppUTest z innym frameworkiem pod względem funkcjonalności. Używając CppUTest kilka razy założyłem, że nie ma jakiejś funkcjonalności i muszę samemu napisać obsługę. Potem okazywało się, że jednak jest i odwaliłem kawał dobrej, nikomu nie potrzebnej roboty. Kolejną wadą dokumentacji CppUTest jest brak spójności np. lista assertów na stronie projektu jest różna od listy na GitHubie.
Repozytorium projektu zawiera skrypty bashowe, rozszerzenia frameworka, skrypty instalacyjne, przykłady, czy unit testy samego frameworka. Niewątpliwie można tam znaleźć wiele przydatnych informacji i narzędzi, jednak trzeba się mocno namęczyć, aby domyślić się ich działania. Doświadczenia z CppUTest przekonują mnie, że zaniedbanie tradycyjnej dokumentacji to błąd. Próg wejścia dla nowego użytkownika staje się przez to dużo wyższy.
Podsumowanie
Przez długi czas byłem sceptyczny do CppUTest w projektach embedded napisanych w C. Główną barierą był dla mnie C++ i potrzeba zarówno poznawania nowego języka, jak i zwalczania problemów z narzędziami, toolchainem, uruchamianiem na targecie i innymi problemami, których nie potrafiłem sobie wyobrazić, ale byłem pewny, że wystąpią. Dlatego w swoich projektach używałem zawsze Unity. Jestem mile zaskoczony bezproblemowością używania C++ i możliwościami jakie ze sobą niesie. Na pewno będę wykorzystywał CppUTest w kolejnych projektach. Mam zamiar również sprawdzić w praktyce pisanie całego systemu embedded w C++. Do tej pory jedyne co zrobiłem w tym kierunku to uruchomienie prostej aplikacji:
Po jednej stronie skali mamy Unity – minimalistyczny framework testowy napisany w C, natomiast po drugiej stronie kombajn w postaci GoogleTest + GoogleMock. Jest to ogromny kombajn i może być ciężko go uruchomić na systemie embedded. Szczególnie takim z ograniczoną pamięcią i kompilatorem wspierającym standard C++98. CppUTest natomiast poradzi sobie w takim środowisku bez problemu.
6 grudnia 2018 at 15:58
Zachęcony między innymi tutejszym opisem jakiś czas temu zacząłem używać Unity. Udało mi się go nawet zmusić do współpracy z Keil uVision i uruchomić na procesorze. Unity to 3 pliki + 3 kolejne ułatwiając grupowanie i uruchamianie testów. Do tego fajny skrypt do parsowania wyjścia dla Eclipce. Za to CppUtest to cała masa plików i zależności. Jak już udało mi się skompilować projekt z CppUtest to wykłada mi się przy uruchamianiu. Namierzyłem że problemem jest inicjowanie modułu Memory Leak Detector, którego raczej nie można wyłączyć. Nie wyobrażam sobie nawet próby uruchomienia tego na jakimś procesorze STM32. Ja podobnie jak autor równiez jestem mile zaskoczony bezproblemowością używania C++ w embeded a nawet mieszania C i C++. Niestety nie mogę tego powiedzieć o CppUnity. Dla mnie to potężne narzędzie zupełnie nieprzydatne do embeded.
6 grudnia 2018 at 18:11
Dzięki Marek za podzielenie się opinią. Ja CppUTest używałem zwykle do odpalania unit testów lokalnie na PCcie. Na uC zwykle działałem na unity. Możliwą przyczyną problemów z CppUTest na STM32 jest słabe wsparcie producenta dla C++. Ja na przykład miałem problemy ze skryptami linkera i inicjalizacją obiektów statycznych. Możliwe, że Leak Detector też z tego korzysta. Jest jeszcze jedna opcja – projekt CppUTest zawierał chyba skrypt konwertujący testy do Unity, żeby odpalać na sprzęcie w C. Do testów w czystym C polecam również wypróbować combo greatest(https://github.com/silentbicycle/greatest) + fff(https://github.com/meekrosoft/fff)