praca w trakcie podrozy

Jak opracować strategię testowania oraz inne popularne problemy w C++

Jednym z problemów, który lepiej rozwiązać na wczesnym etapie projektu jest obranie właściwej strategii testowania. Na najbardziej elementarnym poziomie przyjmiemy zapewne strategię testów jednostkowych. Z założenia powinny one testować najmniejszy funkcjonalny kawałek kodu. Sytuacja ulega komplikacji, gdy mamy do czynienia z metodami zwracającymi typ void oraz nie modyfikującymi argumentów wejściowych. Jak temu zaradzić?

Piotr Janus. Senior Software Developer w Ericsson. Z wykształcenia fizyk z doświadczeniem w analizie danych z eksperymentów fizycznych. W Ericsson pracuje w projekcie zajmującym się oprogramowaniem sterującym zasilaniem i bateriami. Razem ze swoim zespołem zajmują się pisaniem nowych funkcjonalności, które pozwalają na autonomiczną pracę stacji bazowych bez potrzeby wizyty techników.

Michał Niemczyk. Senior Software Developer w Ericsson. Z wykształcenia informatyk, po ukończonych studiach na Wydziale Fizyki i Informatyki Stosowanej AGH. Pracuje w firmie Ericsson przy projektach pisanych w języku C++, głównie w obszarze zajmującym się dystrybucją zasilania do poszczególnych urządzeń stacji bazowych. Wraz z zespołem uczestniczy zarówno w planowaniu, implementacji jak i testowaniu nowych funkcjonalności.


Jakby nie było wystarczająco mało problemów, takie klasy typowo mają zależność do innych klas. Dlatego gdy zaczynamy pisać testy takich funkcjonalności to przestają być one jednostkowe. Testują one wiele tysięcy linii kodu przechodząc przez rozmaite wyrażenia if-else. Zanim przejdziemy do propozycji rozwiązania tego problemu przyjrzyjmy się przykładowi:

Jest to przykład klasy mogącej posłużyć do edycji plików XML. Klasa została wyposażona w obiekt wykonujący walidację danych, czy są one zapisane w odpowiednim formacie. Obiekt ten jest polem prywatnym klasy XMLEditor  i jest to konstrukcja w pełni poprawna. Wadą danej implementacji jest brak możliwości napisania poprawnych testów jednostkowych. Napisane testy z pewnością będą testowały także klasę XMLChecker oraz prawdopodobnie będą testowały tylko logikę klasy XMLChecker. Jednym z rozwiązań tego problemu jest propozycja wykorzystania paradygmatu odwróconego sterowania (ang. Inversion of control). W tym podejściu klasa XMLEditor wykorzystuje framework XMLCheckerIf, który następnie deleguje zadanie do wyspecjalizowanego kodu. 

Zobaczmy, jak taki kod może być napisany wedle tego paradygmatu:

W tym przypadku klasa XMLEditor posiada tylko referencję do interfejsu klasy XMLCheckerIf.  Na tym etapie może być widoczna raczej wada tego rozwiązania w postaci bardziej złożonego kodu. Natomiast zysk zaczyna być widoczny na etapie pisania testów. Dla takiej architektury można stworzyć mock klasy XMLCheckerIf.

Czym jest Mock? Mock to obiekt, który dziedziczy po interfejsie XMLCheckerIf i udaje klasę bazową poprzez implementacje metod czysto wirtualnych wedle sposobu dogodnego do testowania. Popularnym środowiskiem do testowania kodu napisanego w C++ jest biblioteka Google Test i to tam odsyłam czytelników, którzy chcą poszerzyć wiedzę dotyczącą aspektów technicznych testowania.

Powyższy przykład prezentował sytuację, w której istnieje możliwość napisania testów choć odbiegają one od idei testów jednostkowych. Natomiast nierzadko można spotkać sytuację, gdzie kod bez użycia odwróconego sterowania jest w ogóle nietestowalny. Do takiej sytuacji dochodzi np. w aplikacjach klient-serwer bądź też, gdy korzystamy z urządzeń peryferycznych.

Spójrzmy na przykład, w którym mamy klasę komunikującą się z innym procesem:

Klasa TemperatureController służy do utrzymywania stałej temperatury w pomieszczeniu. W polach klasy znajduje się obiekt typu Client, który wysyła sygnał do procesu sterującego klimatyzatorem, oraz obiekt typu Thermometer, który pozwala na odczytywanie aktualnej temperatury. W tym przypadku nie istnieje możliwość napisania testów jednostkowych, ponieważ testowany kod najprawdopodobniej będzie uruchamiany na serwerze niewyposażonym ani w termometr, ani w klimatyzator. 

Nawet jeśli stworzymy taki zestaw to i tak bez ręcznego podwyższania i obniżania temperatury nie będzie możliwości zaobserwowania poprawności działania kodu. Ponownie ten problem można rozwiązać tworząc odpowiednie interfejsy:

Które następnie zostaną przekazane do klasy sterującej:

Dzięki powyższemu zabiegowi można stworzyć mock klasy ClientIf oraz mock klasy ThermometerIf. Następnie w testach jednostkowych należy je przekazać do klasy testowanej. W efekcie klasa TemperatureController jest odseparowana od klas zewnętrznych i można przetestować wyłącznie jej logikę na dowolnej maszynie nie wyposażonej w zewnętrzne komponenty.

Typy wyliczeniowe

Dobrą praktyką jest używanie typu wyliczeniowego enum class. W szczególności powinien on być preferowany nad zwykłym typem wyliczeniowym enum. Główną zaletą typu enum class jest to, że jego wartości nie są konwertowane bezpośrednio na typ int i na etapie kompilacji istnieje możliwość wychwycenia błędów. Prezentuje to następujący przykład:

Brakującą funkcjonalnością w C++ jest możliwość łatwej konwersji obiektu typu enum class na obiekt typu string. Taki problem najczęściej występuje w przypadku części kodu odpowiedzialnej za obsługę interfejsu użytkownika. 

Załóżmy, że piszemy aplikację do operacji na plikach. Gdy użytkownik wybierze plik, chcemy mu wyświetlić menu z dostępnymi akcjami: usuń, zmień nazwę, edytuj i przenieś. Od strony kodu najwydajniej i najbezpieczniej jest operować na typach wyliczeniowych natomiast od strony interfejsu użytkownika oczywistym wyborem jest typ string. Ten problem można rozwiązać przy pomocy metody konwertujące jeden typ obiektu na drugi.

ZOBACZ TEŻ:  Testowanie aplikacji frontendowych. Co warto na ten temat wiedzieć, a o czym lepiej zapomnieć

Najprostszym i najbardziej oczywistym podejściem jest wykorzystanie instrukcji switch:

W powyższej implementacji można od razu zauważyć zysk z użycia typu enum class. W sytuacji, gdy mamy różne wyliczenia kompilator poinformuje nas, jeżeli zostanie użyty zły typ do konwersji (np. zostanie przekazany obiekt ColorClass::red. Niestety podejście to ma także pewne ograniczenia. Tego typu konwersję da się zastosować tylko w jedną stronę, enum class na string. Język C++ nie pozwala na wykorzystanie obiektu typu string w instrukcji sterującej swtich. Ten problem można rozwiązać pisząc klasę konwertującą zawierającą generyczne metody. Aby podejście działało w obie strony na początek zdefiniujmy obiekt, który przechowa informację co na co ma być konwertowane. 

Można tutaj wykorzystać złożenie klas zdefiniowane w bibliotece standardowej std::pair i std::vector:

Implementacja klasy szablonowej wraz z szablonowymi metodami może wyglądać następująco:

Przeanalizujmy krok po kroku, jak działa klasa ConverterNa samym początku mamy konstruktor, który kopiuje obiekt zawierający mapowanie do pola klasy m_map. Na tym etapie widać, że do powyższej klasy można przekazać mapowanie składających się z dowolnych obiektów. Jedynym wymogiem jest to, by te obiekty miały operator porównania. Konwersja jest dokonywana w metodzie convert, która w pierwszej kolejności sprawdza czy konwersja jest zgodna z przekazaną tablicą par (enum na string). 

Jeżeli mamy do czynienia z odwrotną konwersją (string na enum) to wtedy metoda wyciąga elementy pary w odwrotnej kolejności. Po sprawdzeniu kierunku konwersji następuje przeszukiwanie wektora i zwrócenie właściwego obiektu. W przypadku gdy nie istnieje zadana konwersja, metoda zwraca std::nullopt

Przykład użycia konwersji z enum na string:

oraz w odwrotnym kierunku:

Wykorzystanie zdarzeń

W dużych projektach ważnym tematem jest kwestia podziału i pogrupowania klas w mniejsze bądź większe bloki. Rodzi się wtedy problem, jak takie bloki ze sobą skomunikować. Na wczesnych etapach projektu, gdzie nie mamy jeszcze dużej ilości klas, najczęstszą praktyką jest przekazanie do klasy obiektu innej klasy – czyli stworzenie zależności asocjacji. Jest to typowa komunikacja na poziomie klasy. Niestety, kiedy projekt się rozrasta zaczynamy mieć potrzebę komunikacji między blokami, a więc na poziomie wyższym od klas. Wtedy podejście używania asocjacji jest zdecydowanie złą praktyką, ponieważ bardzo szybko spowoduje stworzenie mnóstwa zależności i tak zwanego “kodu spaghetti”.

Dobrą praktyką w takiej sytuacji jest wykorzystanie mechanizmu zdarzeń (eventów) do skomunikowania ze sobą klas znajdujących się w różnych blokach. Najczęściej sprowadzi się do to napisania EventDispatcher – klasy sterującej całym mechanizmem obsługi zdarzeń. Taka klasa pozwoli nam wysyłać zdarzenie o określonym typie i z określonymi danymi. Do definicji danych warto pomyśleć o wykorzystaniu std::variant.  

Każda klasa posiadająca obiekt EventDispatcher, może zasubskrybować się na dane zdarzenie, przekazując funkcję callback, która ma się wykonać w przypadku wystąpienia zdarzenia. Do przekazania funkcji można wykorzystać mechanizm bindowania (std::bind). 

Poniżej znajduje się bardzo uproszczony przykład implementacji:

W powyższym przykładzie widzimy trzy klasy. Jedna z nich reprezentuje zdarzenie (Event). Każde zdarzenie musi mieć zdefiniowany typ oraz dane. Do definicji typu można wykorzystać typ wyliczeniowy. Do definicji danych możemy wykorzystać szablon, lub jak w powyższym przykładzie std::variant.

Drugą klasą jest EventDispatcher, który będzie obsługiwał zdarzenia. Posiada on metodę raiseEvent(), która przyjmuje konkretne zdarzenie i ogłasza, że ono nastąpiło. Klasa udostępnia także metody służące do subskrypcji i usunięcia subskrypcji na dany typ zdarzenia.

Klasą wykorzystującą zdarzenia jest TemperatureController. W klasie chcemy otrzymać informację, jeśli temperatura zostanie przekroczona (będzie zbyt niska lub zbyt wysoka). Subskrybujemy się zatem na interesujące nas typy zdarzeń (TEMPERATURE_TOO_LOW oraz TEMPERATURE_TOO_HIGH). W momencie, kiedy zostanie zgłoszone zdarzenie, zostanie uruchomiony odpowiedni callback z klasy TemperatureController.

Największą zaletą podejścia do komunikacji poprzez obsługę zdarzeń jest uniknięcie budowania złożonych zależności pomiędzy klasami. Kod staje się dużo czytelniejszy i co ważne, łatwy do dalszej rozbudowy. Dodanie nowego typu zdarzenia czy podpięcie nowej klasy do obsługi zdarzeń staje się bardzo łatwe.

Logowanie

Logowanie przebiegu programu jest nieodzownym elementem w każdym projekcie. Zazwyczaj w początkowych etapach projektu nie przykładamy do tego większej uwagi. Często programiści mają tendencję do nadużywania logów na wczesnym etapie projektu. Bo istotnie, na początku nie będzie stanowiło to większego problemu, jednak w miarę rozrastania się kodu, inni programiści widząc styl częstości logowania sami będą się do niego dostosowywać i już po kilku miesiącach okaże się, że plik z logami zebranymi z dwóch minut działania aplikacji zajmuje kilka megabajtów. Tym samym zebranie logów z dłuższego okresu staje się w zasadzie niemożliwe.

Warto przykładać dużą uwagę do logów oznaczających błąd w programie, sytuację, która nie powinna się wydarzyć. Tego typu logi powinny pojawiać się zawsze oraz zawierać jak najbardziej szczegółowe informacje, takie jak wartości zmiennych. Jeżeli dojdzie do takiej sytuacji w programie działającym u klienta, jest spora szansa, że uda się rozwikłać problem bez proszenia klienta o reprodukcję z dodatkowymi logami.

Złym przykładem często spotykanym w projekcie jest logowanie przebiegu tuż przed wywołaniem metody, która sama w sobie ma na wejściu log. Mamy tutaj przykład niepotrzebnego powielania logów. Warto zaimplementować swój własny logger, który pozwoli nam w łatwy sposób określać poziomy ważności logów – możemy na przykład zdefiniować grupę, która będzie wypisywana domyślnie – dobrym przykładem jest tutaj grupa logów oznaczających błąd w programie.

W logowaniu bardzo często potrzebujemy wypisać zawartość jakiejś struktury danych. Dobrą praktyką jest implementacja metody toString() w strukturach danych, która idealnie sprawdza się gdy zachodzi potrzeba wypisania zawartości struktury w logach. Można nawet pokusić się o wymuszenie implementacji takiej metody, definiując wspólny interfejs dla powiązanych struktur danych.

ZOBACZ TEŻ:  Każdego tygodnia wykonujemy 250 tys. testów. Jak wygląda praca testera w Ericsson

Modyfikacja zmiennej przez referencję

Złą praktyką, a niestety wciąż często spotykaną w projektach jest modyfikowanie zmiennej poprzez wysłanie jej przez referencję w argumencie funkcji. W argumentach funkcji powinniśmy używać wyłącznie stałych referencji, natomiast zmodyfikowana wartość powinna być zwrócona jako rezultat funkcji. Taki kod jest znacznie czytelniejszy, łatwiejszy do zrozumienia i mniej podatny na błędy.

Czasem wydaje się kuszące użyć mechanizmu modyfikacji atrybutu przez referencję, jeśli metoda ma zwrócić rezultat z kodem błędu. Popatrzmy na poniższy przykład:

W powyższym przypadku metoda getTemperature próbuje odczytać temperaturę i wpisuję ją do zmiennej wysłanej w argumencie. Jeśli odczyt się uda, metoda zwraca Result::OK. Jeśli odczyt się nie uda, metoda nie przypisze żadnej wartości do zmiennej oraz zwróci kod błędu.

Pierwsza pułapka czeka na nas już przy samym wywołaniu takiej metody, zwłaszcza jeśli użyjemy słowa kluczowego auto:

Intuicyjnie pomyślimy, że result będzie zawierał wartość temperatury, natomiast bieżąca temperatura jest potrzebna metodzie do czegoś, na przykład do logowania, ale nie do modyfikacji.

W takiej sytuacji warto jest rozważyć skorzystanie z std::optional do zwrócenia wartości temperatury. Jeśli kod błędu potrzebny jest nam tylko po to, by go wypisać w logach, w takiej sytuacji skorzystanie z std::optional byłoby tutaj dobrą praktyką. Dzięki temu sprawdzimy, czy udało się pobrać temperaturę, natomiast kod błędu wypiszemy z ciała funkcji getTemperature(). Jeśli niezbędne jest zwrócenie kodu błędu, można pomyśleć o wykorzystaniu pary <temperatura, rezultat>. 

Takie sytuacje są wyjątkowo przykre, jeśli korzystamy z zewnętrznej biblioteki i to właśnie ta biblioteka dostarcza nam taką metodę. Nie mamy możliwości modyfikacji kodu biblioteki, najczęściej nie mamy do niego nawet dostępu. Aby temu zaradzić, w takiej sytuacji dobrą praktyką jest napisanie własnego wrappera dla danej funkcjonalności biblioteki, żeby wymusić stosowanie dobrych praktyk na innych programistach w naszym projekcie. 

Zagnieżdżanie kodu

Dobrym podejściem pozwalającym na uniknięcie nadmiarowego zagnieżdżania kodu jest stosowanie się do zasady negative if-statement. Polega ono na tym, że w instrukcji warunkowej if sprawdzamy negatywny scenariusz. Jeśli warunek negatywny zostanie spełniony, jest to dobry moment na wypisanie logu z informacją o błędzie czy rzucenie wyjątku. Następnie wychodzimy z metody. Podejście prezentuje poniższy przykład:

W powyższym przykładzie przeprowadzany jest odczyt i walidacja pomiaru temperatury. Mamy tutaj jeden poziom zagnieżdżenia. Gdybyśmy nie skorzystali z zasady negatywnego scenariusza, kod wyglądałby następująco:

W tym przypadku dochodzimy do trzech poziomów zagnieżdżenia, natomiast kod wykonuje dokładnie to samo. Korzystając z negative scenario mamy więc znaczący zysk na czytelności kodu.

Dobre praktyki

Kusząca praktyką jest zwracanie „pustych” obiektów, gdy nie udało się wykonać danej akcji. Załóżmy, że piszemy metodę, która kontroluje liczbę klientów podłączonych do serwera. Przykładowa definicja mogłaby wyglądać następująco:

Problem, który się rodzi to taki, że powyższa metoda zawsze musi zwrócić jakąś wartość. Doświadczenie pokazuje, że taka metoda zwróci 0, gdy nie uda nam się połączyć z serwerem i nie zostanie odczytana liczba klientów. Na tym etapie nie jest to problem, ale z pewnością z rodzi to problemy na dalszych etapach, gdzie ta liczba jest wykorzystywana. W przypadkach, gdy istnieje szansa, że metoda może z pewnych powodów nie zwrócić żądanej wartości należy to rozróżnić. Najprościej jest wykorzystać klasę std::optional (dostępną od standardu C++17), która pozwala na rozróżnienie tych dwóch możliwości. 

Definicja poprawionej wersji powyższej metody będzie wyglądać następująco:

To samo podejście można także stosować z klasami takimi jak std::string czy std::vector. W takich sytuacjach także nie jest polecane zwracać pustego obiektu, jeżeli dana metoda nie mogła wykonać w pełni swojego zadania. 

Wysoka kompatybilność wsteczna, którą dostarcza C++ pomiędzy kolejnymi wersjami standardu, ma swoją zaletę w postaci możliwości wykorzystania starszych bibliotek. Natomiast niesie za sobą także problemy używania starszych rozwiązań, gdzie najczęstszym z nich jest operowanie na wskaźnikach. Obecnie wskaźniki nie powinny być używane bezpośrednio, ponieważ łatwo jest naruszyć wzorzec projektowy RAII (Resource Acquisition Is Initialization) i doprowadzić do wycieków pamięci. 

Zamiast standardowych wskaźników powinno się używać inteligentne wskaźniki: std::unique_ptr dla obiektów z jednym właścicielem oraz std::shared_ptr dla obiektów współdzielonych. W każdym razie, jeżeli taka sytuacja się wydarzy to polecamy rozważyć użycie wzorcu projektowego adapter lub fasada (dla bardziej złożonych bibliotek). Wzorce te pozwalają stworzyć dodatkową warstwę oddzielającą nową implementację od starszej. Niezwykle ważne, by na wczesnym etapie pisania aplikacji rozdzielać części kodu, które są w pewien sposób niekompatybilne. Poprawienie tego na późniejszym etapie może wiązać się z dużym nakładem pracy.

W zbiorze dobrych praktyk nie mogło zabraknąć tematu nazewnictwa metod. Coś z czym każdy programista na przez cały czas do czynienia, niezależnie od tego w jakim języku pisze i jak duży projekt robi. 

Najczęstszym błędem spotykanym zwłaszcza w dużych projektach jest stosowanie zbyt ogólnikowych nazw metod, przykładowo handleData. W miejscu wywołania takiej metody programista najczęściej będzie zmuszony do odnalezienia jej implementacji, aby zrozumieć intencje autora kodu. Co gorsza, taka metoda będzie miała tendencję do znacznego rozrastania się, ponieważ będzie miała zbyt wiele odpowiedzialności – będzie można „wpakować” do niej wszystko. Taka metoda dość szybko stanie się rozbudowana na kilkaset linii kodu.

Dobrą praktyką jest tworzenie metod o jak najmniejszej odpowiedzialności, najlepiej jednej. Metoda powinna być krótka i robić dokładnie to co ma w nazwie. Nie bójmy się używać dłuższych nazw w celu określenia przeznaczenia funkcji.

Przy nazewnictwie zmiennych warto zwrócić uwagę na jednostki. Doświadczenie pokazuje, że bardzo łatwo jest popełnić tutaj błąd. Jeśli mamy zmienną o nazwie m_timeout dopiszmy do niej jednostkę, na przykład m_timeoutMs. Kolejny przykład to dopisanie przyrostka _opt przy korzystaniu z std::optional. Jest to przydatne zwłaszcza przy korzystaniu ze słowa kluczowego auto:

Należy oczywiście zawsze zwracać uwagę na zbiór zasad obowiązujących w danym projekcie / danej firmie i trzymać się konwencji. Przykładem może być dodawanie przedrostków do nazw zmiennych i argumentów.

Kolejnym ważnym zagadnieniem jest dodawanie komentarzy do kodu. Istnieje teoria mówiąca, że jeśli potrzebujemy napisać komentarz w kodzie to oznacza, że napisaliśmy zły kod, często zbyt zawiły i powinniśmy go od razu zmodyfikować. Jest w tym sporo racji. Przy stosowaniu odpowiednich nazw zmiennych, metod oraz zasady pojedynczej odpowiedzialności, kod powinien być na tyle czytelny, aby nie wymagał dodatkowych komentarzy. Czasem jednak napisanie komentarza staje się niezbędne, na przykład przy korzystaniu z zewnętrznych bibliotek, na których implementację nie mamy wpływu.

Zdjęcie główne artykułu pochodzi z unsplash.com.

Zapraszamy do dyskusji

Patronujemy

 
 
More Stories
Modlishka zdradziecka jak nigdy wcześniej, czyli jak Polak zrewolucjonizował phishing