Podstawą TDD jest szybki feedback jaki otrzymujemy z unit testów. Oznacza to, że kompilacja i uruchomienie testów powinno trwać kilka sekund. Kluczem do osiągnięcia tak krótkiego czasu jest odpowiednio skonfigurowany system buildowania. Wiadomo, że w te kilka sekund nie uda nam się zbudować od zera całego projektu. System buildowania musi więc udostępniać nam odpowiednie opcje. W tym wpisie postaram się je przybliżyć. Będę pisał z punktu widzenia systemów embedded, ale część uwag spokojnie można odnieść do zwykłych aplikacji.

Buildowanie wszystkich targetów

Od systemu buildowania oczekujemy możliwości kompilacji głównej aplikacji oraz wszystkich targetów testowych. Po pierwsze potrzebujemy więc, aby kompilację wszystkich dostępnych targetów uruchamiać za pomocą pojedynczej komendy. Jeżeli korzystamy z makefile, będzie to komenda:

Ważna jest też komenda czyszcząca wszystkie pliki utworzone podczas kompilacji, czyli:

Wyposażeni w te dwie komendy jesteśmy w stanie sprawdzić, czy po dodaniu jakiejś zmiany wszystko się dalej kompiluje. Jest to więc pierwsza rzecz, jaką powinien przeprowadzić system Continuous Integration.

Oddzielna komenda dla każdego targetu

Kolejnym wymaganiem są oddzielne komendy do zbudowania każdego pojedynczego targetu wchodzącego w skład naszego projektu. Dzięki temu mamy możliwość kompilacji samej aplikacji docelowej, albo unit testów tylko dla modułu, nad którym aktualnie pracujemy. Dzięki temu podczas developmentu możemy wielokrotnie uruchamiać pojedynczy target (np. unit testy modułu nad którym aktualnie pracujemy) nie tracąc czasu na kompilację pozostałych elementów.

Szybko może się okazać, że nasz projekt będzie zawierał całkiem sporo targetów.  Ważne jest, aby miały one opisowe nazwy tworzone według określonego schematu. Dzięki temu łatwiej będzie nam wywołać, czy znaleźć odpowiedni target. Usprawni nam to również pisanie skryptów automatycznych bazujących na nazwach.

Kompilacja inkrementalna

Podczas pracy zgodnie z TDD kompilacja i uruchamianie unit testów są wykonywane bardzo często. Dążymy więc do maksymalnego przyspieszenia tych czynności. Kolejną rzeczą, która jest w stanie oszczędzić nam trochę czasu jest kompilacja inkrementalna. Czyli jeśli od poprzedniej kompilacji wprowadziliśmy zmianę tylko w jednym pliku, ponownie należy skompilować jedynie te pliki, na które ta zmiana wpływa. Czai się tu jednak pewna pułapka. Jeżeli np. piszemy w języku C i edytujemy plik .c, proste skrypty buildowania prawdopodobnie poradzą sobie z taką sytuacją i wygenerują poprawny plik wynikowy. Jeżeli jednak zmodyfikujemy plik .h, który jest używany w kilku plikach .c, prosty skrypt może sobie nie poradzić. Z pomocą przychodzi nam tutaj flaga kompilacji powodująca utworzenie plików zależności (w gcc jest to flaga -MD). Pliki te zawierają reguły Makefile z zależnościami od wykorzystanych headerów.

Wybór kompilowanych elementów

W unit testach zwykle chcemy kompilować tylko wybrane pliki produkcyjne, resztę chcemy zastąpić kodem testowym. Aby to ułatwić, system buildowania powinien umożliwiać dodawanie zarówno całych folderów, jak i pojedynczych plików, a także wykluczanie folderów/plików z kompilacji. Zapewnia nam to większą elastyczność podczas tworzenia targetów testowych.

Załóżmy na przykład, że moduł, który chcemy przetestować składa się z 3 podfolderów z kodem produkcyjnym. W teście chcemy sprawdzić działanie plików z dwóch podfolderów z wyjątkiem jednego pliku. Możemy więc dodać główny folder modułu i wyłączyć z kompilacji trzeci folder i ten pojedynczy plik.

Oddzielne flagi kompilacji dla kodu testowego

Kod produkcyjny często chcemy kompilować z dodatkowymi flagami kompilacji takimi jak na przykład rozszerzone warningi, czy traktowanie warningów jak błędy. Kodu testowego nie musimy kompilować z tak restrykcyjnymi flagami. Framework testowy aby był w stanie wykonać swoje zadania może czasem nie być w stanie przejść kompilacji z rozszerzonymi warningami.

Jest to złamanie zasady równego traktowania kodu produkcyjnego i testowego, o której pisałem wcześniej.  Jednak czasem nie ma innego wyjścia, szczególnie w języku C, gdzie brakuje pewnych elementów obecnych w nowszych językach. Na przykład kiedy test failuje, aby od razu z niego wyjść, framework musi wykonać instrukcję skoku. W językach wyższego poziomu można to rozwiązać przy pomocy wyjątków.

Kompilacja frameworka testowego jako biblioteki statycznej

Kod frameworka testowego jest współdzielony przez wszystkie unit testy i raczej się nie zmienia. Zamiast kompilować go oddzielnie dla każdego targetu testowego możemy skompilować go raz do postaci biblioteki statycznej, a następnie wykorzystywać tą bibliotekę we wszystkich testach. Podczas builda wszystkich targetów powinno nam to zaoszczędzić sporo czasu.

Komenda uruchamiająca testy

Poza komendą służącą do kompilacji, powinniśmy mieć również komendę służącą do uruchamiania testów. Tyczy się to zarówno pojedynczego targetu, jak i wszystkich. Możemy to osiągnąć dodając parametry do komendy builda:

Domyślnie zmienna RUN_TEST jest traktowana jako pusty string. Jednak jeżeli wywołamy komendę make z ustawieniem zmiennej na konkretną wartość, nasz skrypt może wykonać dodatkowe operacje. W tym wypadku będzie to uruchomienie testów.

Logowanie wyników testów

Uruchomienie testów powinno nam zwrócić jakiś wynik. Może on być wyświetlany na konsoli, ale warto również zapisać go do pliku. Jeśli mamy do czynienia z wieloma targetami testowymi, najlepiej się sprawdza wspólny log dla wszystkich testów. Poza łatwiejszym ręcznym sprawdzaniem wyników i ewentualnego komunikatu o failujących testach, narzędzie CI może taki pojedynczy log łatwiej sparsować.

Zarządzanie konfiguracjami

Skoro jesteśmy już przy dodawaniu zmiennych do komendy builda, możemy użyć tego mechanizmu do generowania różnych konfiguracji. Możemy więc mieć zmienną aktywującą build debugowy albo produkcyjny, buildy na różne wersje hardware, a także na PC, symulator czy eval board. Makefile umożliwia nam dodawanie wielu zmiennych do komendy:

Jeżeli mamy możliwość wygenerowania różnych konfiguracji, musimy mieć mechanizm pozwalający nam określić jaka konfiguracja została stworzona. Pomocne może być tworzenie plików wynikowych o różnych nazwach, albo umieszczanie ich w odrębnych folderach.

Generowanie raportów

Dobry build system powinien nam również umożliwić generowanie raportów dotyczących jakości kodu. Poza logiem zawierającym wyniki wykonanych testów mogą nas również interesować takie aspekty, jak:

  • Statyczna analiza kodu.
  • Code coverage.
  • Profilowanie.

Aby je wygenerować, często musimy skompilować program z dodatkowymi flagami, albo uruchomić dodatkowe narzędzia. System buildowania może to dla nas robić po dodaniu odpowiednich zmiennych konfiguracyjnych.

Dodatkowe uwagi

Każdy build testowy powinien zawierać minimalną potrzebną ilość kodu produkcyjnego. Nie testujemy kilku niezależnych modułów w jednym buildzie w celu oszczędzenia czasu. Dzięki temu możemy łatwiej zarządzać buildami w CI i unikamy ukrytych zależności.

Powinniśmy mieć na uwadzę, że system buildowania będzie uruchamiany na różnych maszynach. Może być on uruchamiany przez developera lokalnie na jego komputerze, ale może być również używany przez serwer CI. Maszyny te mogą mieć różne systemy operacyjne, systemy plików, wersje narzędzi itp. Każda z nich powinna generować takie same pliki wynikowe. Prostym sposobem na zapewnienie zgodności jest sprawdzanie w programie sumy kontrolnej z zawartości pamięci programu. Jeżeli wartość jest zgodna ze spodziewaną – wgrany kod jest zgodny z oczekiwanym.

Podsumowanie

W tym wpisie zebrałem swoje doświadczenia dotyczące tworzenia systemu buildowania, które są efektem realizacji kilku projektów. Do tej pory korzystałem tylko ze skryptów makefile, ale podobne funkcjonalności można uzyskać wykorzystując inne popularne systemy buildowania jak np. Rakefile. Wiele opisanych tu elementów można znaleźć w skryptach make do moich projektów na GitHubie.