Pisanie testów w programowaniu to kluczowy element zapewniający jakość i stabilność aplikacji. Testowanie kodu, refaktoryzacja testów i ich organizacja mogą wydawać się skomplikowane, ale trzymając się kilku prostych zasad, można zapewnić czytelność, utrzymanie i niezawodność testów. W tym artykule przyjrzymy się, jak efektywnie organizować i refaktoryzować testy przy użyciu frameworka GTEST, oraz jakie konwencje są zalecane podczas definiowania testów.
Testowanie kodu w C++
Grupowanie i nazewnictwo testów
Grupowanie testów jest pierwszym krokiem do uporządkowanego i czytelnego zestawu testów. W frameworku GTEST grupujemy testy, nadając im nazwy grup, co działa podobnie jak tagi. Taki system pozwala na łatwiejsze zarządzanie i wyszukiwanie powiązanych testów, co jest niezwykle przydatne w dużych projektach. Warto zwrócić uwagę, aby nazwa grupy testowej była krótka, zwięzła i jasno nawiązywała do testowanego elementu. Dzięki temu, zarówno my, jak i inni programiści, którzy będą pracować z naszym kodem, łatwo zrozumieją, jakie testy są grupowane i dlaczego. Podczas nadawania nazw testom w Catch, zaleca się korzystanie z CamelCase lub Snake_Case. Ważne jest jednak, aby unikać używania spacji, ponieważ nazwy testów nie są przekazywane jako stringi. Konwencje nazewnicze są istotne, gdyż wpływają na czytelność kodu i łatwość jego utrzymania. Google zaleca unikanie Snake_Case, ponieważ podkreślenia mogą być wykorzystywane do innych celów, co może prowadzić do nieporozumień. Zastosowanie odpowiedniej konwencji nazewniczej sprawia, że kod jest bardziej jednolity i profesjonalny.
Struktura testów
Struktura testów w GTEST często opiera się na formacie Given-When-Then, mimo że framework ten nie oferuje bezpośredniego wsparcia dla takich sekcji. Aby zachować przejrzystość kodu, zaleca się używanie komentarzy lub pustych linii do wizualnego oddzielenia sekcji. Taka struktura pomaga w zrozumieniu, co jest przygotowaniem (Given), co jest wykonywane (When), oraz jakie są oczekiwania (Then). Dzięki temu, zarówno pisanie, jak i przeglądanie testów staje się bardziej intuicyjne i zrozumiałe dla programistów.
Test Fixtures
Test Fixture (TEST_F) to zestawy testów, które współdzielą wspólne dane. Pozwalają one na uniknięcie duplikacji kodu i centralizację danych, które są wspólne dla kilku testów. Tworzymy oddzielną strukturę, do której ładujemy wspólne elementy. Dzięki temu, każdy test może korzystać ze wspólnych ustawień, co zwiększa czytelność i utrzymanie testów. Test fixtures umożliwiają także definiowanie funkcji, które będą inicjalizować lub manipulować danymi, co dodatkowo ułatwia zarządzanie stanem testów.
Izolacja testów
Każdy test w GTEST jest izolowany, co oznacza, że konstruktory i destruktory obiektów są wywoływane przed każdym testem. To zapewnia, że stan testów nie będzie przenoszony między nimi, co jest kluczowe dla niezawodności i powtarzalności testów. Izolacja testów jest fundamentalnym aspektem, który pomaga uniknąć subtelnych błędów wynikających z nieoczekiwanych zmian stanu. Dzięki temu możemy być pewni, że każdy test jest niezależny i jego wynik nie jest zależny od innych testów.
Identyfikacja wspólnych danych
Pierwszym krokiem w refaktoryzacji jest zidentyfikowanie testów, które korzystają z tych samych danych. Na przykład, jeżeli mamy dwa testy, które używają tych samych wektorów, warto zastanowić się nad zastosowaniem test fixture, co pozwala na współdzielenie tych danych. Rozpoznanie wspólnych danych pozwala na bardziej efektywne zarządzanie testami i redukuje nadmiarowość kodu. Kiedy już zidentyfikujemy wspólne elementy, możemy stworzyć odpowiednie struktury, które ułatwią ich zarządzanie.
Wstrzykiwanie Zależności (Dependency Injection) i Zasada Odwrócenia Zależności (DIP)
Wstrzykiwanie zależności, czyli Dependency Injection (DI), jest kluczową techniką w programowaniu obiektowym, która pozwala na odseparowanie komponentów systemu, co zwiększa jego elastyczność i testowalność. Jednym z fundamentów dobrych praktyk programistycznych są zasady SOLID, a szczególnie ostatnia litera „D”, która oznacza zasadę odwrócenia zależności (DIP). Zasada ta sugeruje, że moduły wyższego poziomu nie powinny zależeć od modułów niższego poziomu. Implementując DI, można odseparować zależności, co pozwala na łatwiejsze zarządzanie kodem i jego testowanie.
Testowanie kodu - typowe problemy i scenariusze
Problemy z siecią
Jednym z typowych problemów, które mogą pojawić się w trakcie testowania, jest zależność od zewnętrznych usług sieciowych. Na przykład, jeśli mamy funkcję przesyłającą dane przez sieć, może ona napotkać na szereg problemów: brak połączenia, złe ustawienia flag, zerwane połączenia, zapchane bufory czy duże opóźnienia. Wszystkie te scenariusze mogą wpływać na stabilność i przewidywalność testów.
Problemy z bazą danych
Podobne wyzwania pojawiają się, gdy aplikacja korzysta z bazy danych. Problemy takie jak brak połączenia z serwerem bazy danych, duże opóźnienia w odpowiedziach, czy też obciążenie serwera mogą sprawić, że testy stają się niestabilne i trudne do przeprowadzenia. Do tego dochodzą dane, które znajdują się w takiej bazie. Jeśli testy nie czyszczą danych po każdym wykonaniu (także tym, w którym nie przeszły), to każdorazowe ich uruchomienie powoduje pracę na innym zestawie danych. Dlatego, aby uniknąć takich problemów, warto zastosować DI, aby odseparować logikę biznesową od bezpośrednich interakcji z bazą danych.
Funkcje z timeoutem
Kolejnym przykładem są funkcje z timeoutem, które wykonują operacje zdalne i oczekują na odpowiedź przez określony czas. Testy takich funkcji mogą trwać zbyt długo, co nie jest pożądane w testach jednostkowych, które powinny być szybkie i deterministyczne. Ustawienie zbyt krótkiego timeoutu na potrzeby testu również nie jest dobrą praktyką, ponieważ nie odzwierciedla rzeczywistych warunków działania aplikacji.
Mocki i wstrzykiwanie zależności
Mocki są narzędziami, które umożliwiają symulowanie zachowania obiektów w testach jednostkowych. Dzięki mockom możemy ustawiać, jakie wartości mają być zwracane przez metody, sprawdzać, jakie metody zostały wywołane i z jakimi parametrami. Mocki pozwalają na testowanie w izolacji, kontrolowanie zachowania obiektów oraz weryfikowanie interakcji.
Aby zaimplementować mocka, najpierw musimy mieć interfejs lub abstrakcyjną klasę, którą nasz mock będzie implementować. To pozwala na odseparowanie logiki biznesowej od konkretnej implementacji, co jest zgodne z zasadą odwrócenia zależności (DIP). Tworzenie mocków jest procesem, który może różnić się w zależności od używanej biblioteki, ale w Google Mock jest to dość intuicyjne. Biblioteka ta pozwala na definiowanie zachowania metod, ustawianie oczekiwań co do wywołań metod oraz weryfikowanie tych wywołań. Mocki w Google Mock są tworzone na podstawie interfejsów, co pozwala na łatwe zastępowanie rzeczywistych implementacji w testach.
Mocki mogą być używane do symulowania różnych scenariuszy w testach. Na przykład, możemy używać mocków do symulowania odpowiedzi z serwera sieciowego, odpowiedzi z bazy danych, czy zachowania funkcji z timeoutem. Dzięki temu możemy przetestować, jak nasz kod zachowuje się w różnych sytuacjach bez konieczności polegania na rzeczywistych zależnościach.
Czym jest Test-Driven Development (TDD)?
Test-Driven Development (TDD) to podejście do programowania, które kładzie duży nacisk na pisanie testów przed implementacją kodu. Jest to skuteczna metoda zapewniająca, że kod działa zgodnie z oczekiwaniami. TDD składa się z trzech głównych faz: Red, Green, Refactor.
Łatwiej jest rozpocząć pracę z TDD, gdy mamy już gotową część aplikacji lub przynajmniej podstawowy szkielet. W istniejących projektach mamy już pewne wymagania i struktury, co ułatwia dodawanie nowych funkcjonalności. Jednakże, kiedy tworzymy projekt od zera, musimy najpierw utworzyć podstawowy szkielet aplikacji, który będzie zawierał główną aplikację oraz testy. Na początek warto uruchomić proste testy, aby upewnić się, że środowisko testowe działa poprawnie.
Fazy:
🔴 RED – Pisanie testu, który nie przechodzi. Na przykładzie gry w szachy, zaczynamy od napisania testu, który sprawdzi, czy możemy umieścić pionka na szachownicy. W tej fazie test zawsze będzie nieudany, ponieważ jeszcze nie zaimplementowaliśmy funkcjonalności.
🟢 Green – Implementacja kodu, aby test przeszedł. W tej fazie piszemy minimalną ilość kodu potrzebną do przejścia testu. Dla przykładu, implementujemy metody pozwalające na umieszczanie pionków na szachownicy i sprawdzanie ich położenia.
🔄 Refactor – Optymalizacja kodu bez zmiany jego funkcjonalności. Po przejściu testu możemy skupić się na poprawie jakości kodu, usuwaniu duplikacji i refaktoryzacji.
Zasady TDD:
1. Pisanie jednego testu na raz: w TDD ważne jest, aby pisać jeden test na raz, a następnie przejść przez pełny cykl Red, Green, Refactor.
2. Kompilacja testu: test musi się skompilować przed przystąpieniem do implementacji kodu. W ten sposób upewniamy się, że nasze testy są poprawne.
3. Minimalna implementacja: w fazie Green piszemy minimalną ilość kodu potrzebną do przejścia testu. Dopiero później refaktoryzujemy kod.
Podsumowanie
Efektywne testowanie i refaktoryzacja kodu to kluczowe aspekty tworzenia niezawodnego oprogramowania. Organizacja testów w frameworku GTEST poprzez grupowanie, stosowanie odpowiednich konwencji nazewniczych oraz korzystanie z test fixtures i mocków znacząco ułatwia zarządzanie i utrzymanie kodu testowego. Wstrzykiwanie zależności oraz zasada odwrócenia zależności (DIP) zwiększają elastyczność i testowalność aplikacji. Test-Driven Development (TDD) pomaga programistom pisać kod, który spełnia oczekiwania, a fazy Red, Green i Refactor prowadzą do systematycznego i przemyślanego rozwoju oprogramowania.
Chcesz wiedzieć więcej? Sprawdź naszą playlistę o testowaniu, albo dołącz do kursu i testuj jak zawodowiec 🧪