W idealnym świecie nie testujemy szczegółów implementacyjnych jakimi są funkcje statyczne. To samo tyczy się prywatnych pól i metod klasy w językach obiektowych. Zamiast tego piszemy testy dla publicznego API i z pomocą odpowiednich mocków jesteśmy w stanie zaobserwować całe zachowanie testowanego modułu z zewnątrz. Rzeczywistość często nie jest taka różowa i musimy często jakoś dostać się do tego ukrytego kodu.
W dalszej części artykułu zastanowimy się dlaczego tak się dzieje i jak radzić sobie z takimi przypadkami. Jednak kiedy jesteśmy postawieni przed problemem testowania prywatnych funkcji, najpierw zadajmy sobie jedno ważne pytanie. Czy to nie jest to oznaka jakiś problemów architektonicznych?
Być może testowany moduł ma zbyt dużo odpowiedzialności i powinien zostać rozbity na kilka mniejszych. Warto również zastanowić się, czy nie próbujemy za mocno uzależnić testów od konkretnej implementacji. Takie testy nabijają coverage, ale czynią wprowadzanie późniejszych zmian prawdziwą udręką. I tutaj wracamy do teorii – działanie każdej funkcji prywatnej powinno być widoczne na zewnątrz. Nie obchodzi nas jak to jest wewnątrz zrealizowane, ale czy zachowanie jest poprawne.
Kiedy testujemy funkcje prywatne?
Jak już wcześniej wspomniałem, istnieją przypadki, kiedy nie uciekniemy od testowania funkcji statycznych. Najczęściej są to:
- Code coverage – pisanie testów dla coverage nie jest dobrą praktyką, o czym już kiedyś pisałem. o ile piszemy w TDD, wysokie pokrycie będzie efektem ubocznym dobrze napisanych testów. Jednak czasem zdarza się, iż w projekcie wymagane jest utrzymanie coverage na odpowiednim poziomie. Może to być spowodowane np. potrzebą certyfikacji w systemach safety-critical. W takim wypadku, aby uzyskać pokrycie rzędu 95%, zwykle nie uciekniemy od testowania funkcji statycznych.
- Legacy code – o ile mamy do czynienia z istniejącą bazą kodu bez testów, często pojedynczy plik zawiera dużo różnych funkcji, często statycznych. Nie ma również możliwości podejrzenia wszystkich zmian z zewnątrz przy pomocy mocków. Najczęściej docelowo chcemy zmodyfikować taki kod, aby był łatwiejszy w utrzymaniu. W tym celu potrzebujemy testów sprawdzających, czy niczego nie zepsujemy wprowadzając zmiany.
- Wewnętrzny stan trudny do ustawienia z zewnątrz – dobrym przykładem może być tutaj maszyna stanów. Po inicjalizacji znajduje się ona w jakimś początkowym stanie i zanim będziemy mogli wykonać testy dla innego stanu, musimy go ustawić. o ile korzystamy tylko z prywatnego API może to wymagać wielu wywołań i podawania specyficznych danych. Zamiast tego dużo łatwiej jest po prostu wpisać stan do zmiennej statycznej.
Pora przejść do sposobów na uzyskanie dostępu do zmiennych i funkcji statycznych.
Modyfikator PRIVATE
Najprostszym sposobem na uzyskanie dostępu do prywatnych symboli jest zdefiniowanie własnego modyfikatora PRIVATE:
#ifdef UNIT_TEST #define PRIVATE #else #define PRIVATE static #endifDzięki temu prostemu zabiegowi symbole produkcyjne z modyfikatorem PRIVATE będą lokalne w docelowej aplikacji, ale dostępne w unit testach, jeżeli użyjemy deklaracji potrzebnego symbolu jako extern:
//plik produkcyjny PRIVATE uint32_t production_variable; PRIVATE void production_function(void) { production_variable++; } //plik z testem extern uint32_t production_variable; extern void production_function(void);Tego sposobu używam już od bardzo długiego czasu ze względu na jego prostotę. Posiada on jednak kilka wad:
- ingeruje w kod produkcyjny – każdą zmienną i funkcję, którą chcemy mieć dostępną w unit testach musimy zadeklarować jako PRIVATE zarówno w headerze, jak i w pliku źródłowym. Wymaga to zmian w istniejącym kodzie produkcyjnym. Szczególnie w systemach legacy może zdarzyć się, iż kod produkcyjny jest zamrożony i nie możemy zrobić choćby takiej prostej zmiany.
- możliwe konflikty nazw – w unit testach lokalne symbole z różnych plików mogą mieć takie same nazwy powodując błędy kompilacji. Jest to bardzo rzadki przypadek, ale jednak możliwy.
- metoda może być nadużywana przez lenistwo – dzięki swojej prostocie, możemy stosować tą metodę choćby tam, gdzie nie jest potrzebna.
Funkcje testowe w pliku produkcyjnym
Kolejnym sposobem wykorzystującym preprocesor i ingerującym w plik produkcyjny jest dodanie na końcu pliku produkcyjnego funkcji kompilowanych tylko w unit teście dających dostęp do prywatnych symboli. Może to wyglądać następująco:
static uint32_t private_variable; static void private_function(int32_t arg) { return private_variable + arg; } #ifdef UNIT_TEST uint32_t test_private_variable_get(void) { return private_variable; } void test_private_variable_set(uint32_t val) { private_variable = val; } void test_private_function_call(int32_t arg) { return private_function(arg); } #endifW unit testach możemy dodać też header z definicjami testowych funkcji i używać je w unit testach. To rozwiązanie również ingeruje w kod produkcyjny. Funkcji testowych może być całkiem sporo i zaśmiecają one plik źródłowy.
Include pliku źródłowego
Kolejny sposób umożliwia testowanie nie ruszając w ogóle pliku produkcyjnego. Niestety jest też strasznie brzydki i niektórym dopisanie czegoś takiego do swojego kodu może nie przejść przez palce Ten sposób to includowanie produkcyjnego pliku .c w pliku testowym. Może to wyglądać tak:
//plik production_file.c static uint32_t production_variable; static int32_t production_function(int32_t arg) { return production_variable + arg; } //plik production_file_test_adapter.c #include "production_file.c" void test_production_variable_get(void) { return production_variable; } int32_t test_production_function_call(int32_t arg) { return production_function(arg); }Może i wygląda strasznie, ale działa i nie trzeba nic dopisać do pliku produkcyjnego. Musimy tylko uważać na parę smaczków podczas kompilacji:
- Plik produkcyjny może być zaincludowany tylko raz. Każde kolejne dodanie spowoduje błąd wielokrotnej definicji tej samej funkcji.
- Plik produkcyjny nie może być bezpośrednio kompilowany. Kompilacja pliku i jego dołączenie gdzie indziej spowoduje ten sam błąd, co powyżej.
- Plik includujący kod produkcyjny musi być skompilowany z flagami produkcyjnymi. Chcemy mieć maksymalną pewność, iż testujemy ten sam kod, co na produkcji. A często w plikach testowych stosujemy inne flagi np. mniej restrykcyjne warningi.
Testowanie prywatnych pól klasy w C++
To tyle o ile chodzi o tricki dotyczące testowania prywatnych symboli w C. Na koniec pokażę jeszcze mechanizm dostępny w C++ do testowania prywatnych pól w klasie. Ale przedtem przypomnę po raz kolejny – dobre unit testy to takie, które nie muszą znać szczegółów implementacyjnych. Zanim użyjesz tej metody, najpierw spróbuj poradzić sobie bez takich sztuczek.
C++ posiada mechanizm klasy zaprzyjaźnionej. Takiej klasy nie obowiązują zasady enkapsulacji – ma dostęp do wszystkich pól i metod klasy bazowej. o ile zdefiniujemy klasę zaprzyjaźnioną na czas unit testów, będziemy mogli za jej pomocą dostać się do wszystkich interesujących nas funkcji i wymusić wszystkie potrzebne stany. Poniżej przykład jak to zrobić:
#ifdef UNIT_TEST #define PRODUCTION_CLASS_TEST_FRIEND friend class Production_class_test_friend #else #define PRODUCTION_CLASS_TEST_FRIEND #endif class Production_class { PRODUCTION_CLASS_TEST_FRIEND; private: int private_field; int private_function(int arg) { return arg; } }; class Production_class_test_friend { Production_class &production_class; public: Production_class_test_friend(Production_class &production) : production_class(production) {} int test_private_field_get() { return production_class.private_field; } int test_private_function_call(int arg) { return production_class.private_function(arg); } };Oczywiście stosując tą metodę w większym projekcie trzeba odpowiednio podzielić powyższy kod na pliki. Define friend class muszą być includowane do pliku produkcyjnego i klasa testowa musi być widoczna. Natomiast sama definicja klasy testowej powinna być w oddzielnym pliku wykorzystywanym tylko w testach.
Podsumowanie
W artykule przedstawiłem kilka technik, które możemy wykorzystać do testowana funkcji statycznych. Różnią się one między sobą stopniem skomplikowania i ingerencją w kod produkcyjny. Możemy więc wybrać odpowiedni sposób do naszych potrzeb. Zanim to zrobimy, pamiętajmy jednak, iż najlepszym rozwiązaniem jest po prostu nie wnikanie w szczegóły implementacyjne i wywołanie każdego spodziewanego zachowania z zewnątrz.