Aby móc testować aplikacje embedded na platformie docelowej często potrzebujemy frameworka napisanego w czystym C. Najlepiej jeszcze, aby zajmował mało miejsca w pamięci i był jak najprostszy, aby dawał się skompilować na kompilatorach bez zaawansowanych opcji i funkcji bibliotecznych. Wymagania te spełnia framework Unity. Niestety nazwa jest dosyć niefortunna, pokrywa się z Unity do tworzenia gier i ciężko np. wyszukiwać teksty o frameworku w google.
Podstawowe informacje
Unity to framework testowy napisany w całości w C, głównie z myślą o systemach embedded. Został napisany przez grupę entuzjastów metodyk zwinnych wdrażających je w projektach embedded – ThrowTheSwitch.org. Stworzyli oni więcej przydatnych tooli takich jak CMock – generator mocków kompatybilny z Unity, CException – implementacja komend try, catch i throw przy pomocy longjumpów.
Biblioteka Unity składa się z 3 plików – jednego c i dwóch headerów. Polecam jednak używać ją z rozszerzeniem Unity Fixture (dodatkowy 1 plik c i 3 headery) dodającym implementację grup testowych posiadających wspólne funkcje setup i teardown, a także wykrywanie memory leaków. Ten dodatek mocno upodabnia Unity do omawianego ostatnio CppUTest. Istnieją nawet skrypty do konwersji testów CppUTest do Unity. Oba projekty zostały wykorzystane w książce Jamesa Grenninga o TDD embedded. W dalszej części artykułu opisuję tylko Unity z dodatkiem Fixture.
Makra testowe
Framework udostępnia zestaw makr umożliwiających pisanie testów. Pełna lista makr dostępna jest w dokumentacji. Tutaj wypiszę kilka najważniejszych:
- TEST_ASSERT, TEST_ASSERT_TRUE – sprawdzenie, czy argument przyjmuje wartość logiczną true.
- TEST_ASSERT_FALSE, TEST_ASSERT_UNLESS – sprawdzenie, czy argument przyjmuje wartość logiczną false.
- TEST_ASSERT_EQUAL, TEST_ASSERT_EQUAL_INT – porównanie dwóch intów o domyślnym rozmiarze.
- TEST_ASSERT_EQUAL_INTx(pod x można wstawić 8, 16, 32, 64) – porównanie dwóch intów o konkretnym rozmiarze.
- TEST_ASSERT_EQUAL_UINT, TEST_ASSERT_EQUAL_UINTx
- TEST_ASSERT_EQUAL_HEX, TEST_ASSERT_EQUAL_HEXx
- TEST_ASSERT_BITS, TEST_ASSERT_BITS_HIGH, TEST_ASSERT_BITS_LOW
- TEST_ASSERT_FLOAT_WITHIN, TEST_ASSERT_EQUAL_FLOAT
- TEST_ASSERT_EQUAL_STRING
- TEST_ASSERT_NULL, TEST_ASSERT_EQUAL_PTR
- TEST_ASSERT_EQUAL_MEMORY
- TEST_FAIL, TEST_FAIL_MESSAGE
- TEST_IGNORE
- TEST_ABORT – natychmiastowe zakończenie testu bez faila. Test zakończy się sukcesem, jeśli inne checki na końcu przejdą np. sprawdzenie wycieków pamięci.
Struktura unit testów
Unit testy powinny sprawdzać pojedynczy plik produkcyjny w oderwaniu od wszystkich zależności. Z tego powodu dla każdego testowanego pliku powinien zostać zbudowany oddzielny program – czyli każdy test powinien być oddzielnym targetem kompilacji. Każdy taki target testowy w przypadku Unity powinien zawierać:
- funkcję main,
- scenariusze testowe,
- dodatkowe definicje runnerów wywołujące wszystkie testy wchodzące w skład grupy.
Oczywiście mamy dowolność w wyborze struktury plików dopóki projekt będzie zawierać wszystkie powyższe elementy. Jeżeli się uprzemy, możemy nawet je wszystkie umieścić w jednym pliku. Dla zwiększenia czytelności polecam jednak następujący układ:
- Pojedynczy plik zawierający funkcję main.
- Pojedynczy plik zawierający runnery dla każdej grupy testowej.
- Oddzielne pliki scenariuszy testowych dla każdej grupy.
Plik main
Opiszę teraz zawartość wszystkich rodzajów plików. Zacznijmy od maina:
#include "unity/fixture/unity_fixture.h" static void run_all_tests(void); int main(int argc, const char **argv) { UnityMain(argc, argv, run_all_tests); return 0; } static void run_all_tests(void) { printf("Test group 1 tests:\n"); RUN_TEST_GROUP(test_group_1); printf("\nTest group 2 tests:\n"); RUN_TEST_GROUP(test_group_2); }
Funkcja main przyjmuje argumenty z konsoli, które przekazujemy do Unity. To rozwiązanie jest przydatne, kiedy odpalamy testy na PC. Dzięki temu z linii komend możemy podawać argumenty wywołujące pojedynczy test albo wywołujące wszystkie testy kilka razy. W przypadku uruchamiania na mikrokontrolerze main nie przyjmuje żadnych argumentów. Musimy więc zmienne argc i argv zdefiniować samemu i wypełnić odpowiednimi wartościami. O uruchamianiu testów na docelowej platformie mam zamiar jeszcze przygotować oddzielny tekst.
Mamy tu również funkcję run_all_test uruchamiająca testy dla każdej grupy wchodzącej w skład tego targeta testowego. Jeżeli zapomnimy dodać jakiejś grupy, jej testy nie zostaną wykonane. W CppUTest framework robił to za nas automatycznie. W C niestety nie mamy takich możliwości i musimy dodać te kilka linii ręcznie. Korzystając z okazji dodałem dodatkowe printfy rozdzielające w outpucie testy dla poszczególnych grup.
Plik runner
Zawartość pliku runner:
#include "unity/fixture/unity_fixture.h" TEST_GROUP_RUNNER(test_group_1) { RUN_TEST_CASE(test_group_1, test1); RUN_TEST_CASE(test_group_1, test2); } TEST_GROUP_RUNNER(test_group_2) { RUN_TEST_CASE(test_group_2, test1); RUN_TEST_CASE(test_group_2, test2); }
Dla każdej grupy testowej musimy ręcznie dodać wszystkie testy. Jeżeli pomylimy nazwy, dostaniemy błąd kompilacji. Jeżeli nie oddamy jakiegoś testu, nie zostanie on wykonany. Może to być przyczyną nieporozumień. Po uruchomieniu testów zobaczymy, że wszystkie przechodzą, a tak naprawdę któryś się w ogóle nie wykonuje. Dlatego przy dodawaniu nowego testu najpierw go failujemy i uruchamiamy spodziewając się błędu. Dzięki temu mamy pewność, że został on poprawnie dodany. Kolejnym minusem dodawania testów w kilku miejscach jest utrudniony refactoring. Przy zmianie nazwy scenariusza lub grupy musimy zaktualizować wszystkie miejsca wystąpienia. IDE najczęściej nie wykona całej roboty za nas, bo nie będzie sobie radzić z macrami.
Pliki testów dla danej grupy
Zawartość pliku test:
#include "unity/fixture/unity_fixture.h" #include "production/header1.h" #include "mocks/production_mocks.h" TEST_GROUP(test_group_1); TEST_SETUP(test_group_1) { /* Setup before every test */ } TEST_TEAR_DOWN(test_group_1) { /* Cleanup after every test */ } TEST(test_group_1, test1) { int32_t val; val = header1_fun(); TEST_ASSERT_EQUAL(0, val); } TEST(test_group_1, test2) { TEST_FAIL_MESSAGE("Implement your test!"); }
Na początku musimy zadeklarować grupę testową za pomocą macra TEST_GROUP. Następnie definiujemy funkcje TEST_GROUP_SETUP i TEST_GROUP_TEARDOWN. Możemy wtedy przejść do implementacji scenariuszy testowych za pomoca macra TEST(nazwa_grupy, nazwa_testu). W drugim teście w powyższym kodzie jest komenda failująca test pozwalająca sprawdzić poprawność dodania testu.
Konfiguracja przy pomocy define
Unity umożliwia zmianę wielu domyślnych ustawień frameworka przy pomocy preprocesora. Za pomocą define można między innymi:
- włączyć wsparcie dla liczb 64-bitowych,
- ustawić rozmiar pointerów,
- skonfigurować obsługę liczb zmiennoprzecinkowych,
- wyłączyć wykorzystanie nagłówków z biblioteki standardowej jak stdint.h, czy limits.h.
Pełna lista opcji konfiguracyjnych w dokumentacji.
Skrypty
Do unity dołączono skrypty w Ruby ułatwiające korzystanie z biblioteki. Nacisk położono przede wszystkim na generowanie test runnerów i funkcji main tak, aby nie pominąć żadnej grupy ani scenariusza testowego. Niestety skrypty nie działają z Unity Fixture. Poza tym dostępne są skrypty np. do generowania raportów z outputu testów.
Narzędzia od ThrowTheSwitch często korzystają z Ruby. Wspomniany wcześniej CMock również go używa. Jest również system buildowania Ceedling napisany w Ruby. Niestety nie mogę za wiele o nich powiedzieć. Potrzeba zainstalowania Ruby okazała się dla mnie barierą nie do przejścia i zawsze radziłem sobie bez nich.
Dokumentacja
Dokumentacja jest minimalistyczna – pojedynczy dokument pdf na 12 stron. Jednak tyle wystarczy. Mamy pełną listę makr wykorzystywanych do pisania testów, opis zawartości poszczególnych folderów w repozytorium, wytłumaczenie działania skryptów. Jeśli chcemy dowiedzieć się więcej, w poszczególnych folderach znajdują się pliki readme, możemy też przeanalizować kod. Dzięki temu możemy szybko znaleźć podstawowe informacje i zdecydować, czy chcemy wgłębiać się w detale. W CppUTest tego nie ma, co odstrasza nowego użytkownika.
Podsumowanie
Największe zalety Unity jest implementacja w czystym C, niewielki rozmiar, możliwość łatwego uruchomienia na mikrokontrolerze i dostosowania do okrojonych kompilatorów przy pomocy opcji konfiguracyjnych. Sam używałem Unity już w wielu projektach i sprawdzał się bardzo dobrze. Ciekawą alternatywą dla Unity może być też greatest – framework składający się z pojedynczego headera.
19 października 2020 at 16:32
Największe zalety Unity jest implementacja w czystym C, niewielki rozmiar, możliwość łatwego uruchomienia na mikrokontrolerze i dostosowania do okrojonych kompilatorów przy pomocy opcji konfiguracyjnych.