Ostatnio w pracy miałem do wykonania dosyć proste zadanie. Chodziło o to, aby w przerwaniu od timera wykonującym się co 10 ms umieścić funkcję, która miała wykonywać się co 100 ms. W tym przerwaniu już wcześniej był wywoływany task co 10 ms i obsługa komunikacji. Napisany przeze mnie kod wyglądał mniej więcej tak:

void irq_timer_10ms(void)
{
    static int32_t cnt = 0;

    task_comm();
    task_10ms();

    cnt++;
    if (cnt < 10)
    {
        cnt = 0;
        task_100ms();
    }
}

Kryje się tutaj dosyć często spotykany błąd – warunek w ifie jest odwrócony. Funkcja zamiast wywoływać się raz na 10, wykonuje się 9 razy na 10. Po dodaniu tej zmiany uruchomiłem aplikację i na pierwszy rzut oka wyglądało, że działa. Nie miałem do dyspozycji żadnych unit testów, ani dokładniejszych testów na docelowym hardware, więc wypushowałem tę zmianę razem z kilkoma innymi.

Po jakimś czasie kolega pracujący na tym samym kodzie wykrył problemy z logowaniem eventów. Na przeanalizowanie problemu stracił około godzinę, ale problem udało mu się rozwiązać. Oczywiście nie wykrył, że problem powoduje kod przerwania w zupełnie innym miejscu niż jego objawy, tylko zrobił workarounda. Niedługo potem okazało się, że występuje również problem z komunikacją. Znalezienie przyczyny tego problemu nie było proste. Należało debugować dwa komunikujące się urządzenia jednocześnie i dodatkowo analizować ramki wysyłane pomiędzy nimi. Problem rozwiązywaliśmy we dwóch przez kolejne dwie godziny. Dopiero wtedy zauważył wprowadzony przeze mnie błąd i po zmianie warunku w ifie wszystko zaczęło działać.

Ten przykład pokazuje jak prosty błąd przy podstawowym sprawdzeniu działania może być niewidoczny, ale za to powodować błędne działanie w niby niepowiązanych z nim miejscach i jak na debugowanie można stracić wiele czasu. Gdybym do tego kodu napisał unit testy, błąd znalazł bym od razu.

Druga sytuacja miała miejsce również nie tak dawno w pracy. Kod, który piszemy musi być zgodny ze standardem MISRA C. Pewne funkcjonalności zostały zaimplementowane i działały poprawnie, jednak narzędzie do statycznej analizy kodu zgłaszało jeszcze błędy zgodności z MISRA. Jednym z najczęściej powtarzających się tego typu błędów jest mieszanie typów signed i unsigned w jednym wyrażeniu. Jest to dosyć upierdliwa reguła, ponieważ liczby pisane jako np. 12, albo hexalnie 0xA5 są traktowane jako liczby ze znakiem. Żeby otrzymać liczbę bez znaku należy napisać 12u, 0xA5u. Aby zapewnić zgodność typów konieczna była również zmiana typów niektórych zmiennych.

Niby są to zmiany nie wpływające na funkcjonalność. Jednak osoby, które je wprowadzały, wykryły potem błędy w działaniu systemu. Okazało się, że są pewne specyficzne sytuacje, w których działanie programu się zmienia. Poza tym przy wprowadzaniu poprawek zdarzają się czasem błędy ludzkie. Kiedy przyszło mi wprowadzać tego typu zmiany, miałem do dyspozycji działające unit testy. Po poprawkach mogłem więc po prostu puścić testy i po chwili mieć potwierdzenie, że program dalej działa tak, jak przedtem. Oczywiście okazało się, że w kilku miejscach pojawiły się błędy. Dzięki unit testom wiedziałem o nim praktycznie od razu po wprowadzeniu zmiany i poprawka była szybka i bezbolesna.

Zalety unit testów

Częstym argumentem przeciwko unit testom jest chęć oszczędności czasu. Jeżeli nie tracimy czasu na pisanie testów, kod powstaje szybciej. Opisane przeze mnie przykłady pokazują, że jest to złudna oszczędność. Kod napiszemy trochę szybciej, ale następnie możemy zmarnować kilka razy więcej czasu na debugowanie. Jeśli napiszemy testy, nasza specyficzna wiedza o szczegółach konkretnej funkcji zostaje gdzieś uwieczniona. Dzięki temu, gdy po pół roku próbujemy coś dopisać, mamy szybką informację, czy czegoś nie zepsuliśmy.

W systemach Embedded unit testy są stosowane bardzo rzadko. Aby je wprowadzić, należy pokonać kilka trudności. Testy często muszą być uruchamiane na innej platformie niż docelowa. Stosuje się w tym celu symulatory, czy kompilację i uruchamianie bezpośrednio na komputerze. Poza tym ciężko testować zachowanie rejestrów procesora, czy przerwań. Jednak unit testy dają szereg korzyści szczególnie pożądanych w systemach Embedded. Takie systemy często wchodzą w interakcje z urządzeniami wykonawczymi, które mogą być duże, drogie, trudno dostępne, głośne, czy niebezpieczne. Niektóre błędne stany muszą być obsłużone, a praktycznie nie zdarzają się podczas zwykłego użytkowania. W takich sytuacjach unit testy są jedynym sposobem, aby sprawdzić daną funkcjonalność w szybki, tani i powtarzalny sposób.

Istnieją jeszcze inne zalety unit testów nie związane z ich głównym przeznaczeniem. Żeby kod był możliwy do przetestowania, musi mieć jasno określone powiązania między elementami i po prostu być dobrze zaprojektowany. Dlatego proces tworzenia testów ujawnia problemy projektowe i umożliwia lepszy design. Publiczny interfejs poszczególnych modułów jest dzięki temu lepiej przemyślany.

Kolejną zaletą jest lepsze poznanie języka, kompilatora i narzędzi przez programistę. Test Driven Development mówi, żeby dopisać tyle kodu testów, żeby przestały przechodzić, a następnie tylko tyle kodu produkcyjnego, żeby zaczęło znowu działać. Stosując tę technikę możemy zadawać sobie pytanie, jaki jest spodziewany efekt mojej zmiany, i jeśli efekt jest inny od oczekiwanego, należy zbadać dlaczego tak jest. Jeśli jakiś test nie przechodzi, w logu znajdziemy informację, który assert zgłosił błąd, lub że jest błąd kompilacji. Możemy wtedy prześledzić kod produkcyjny w poszukiwaniu błędu. Często błędy są oczywiste, ale raz na kiedyś zdarzają bardziej złośliwe przypadki. Dzięki ich analizie trenujemy swoje oko w znajdowaniu błędów i oswajamy się z różnymi rodzajami błędów.