Zależności globalne - jak poradzić sobie z nimi w unit testach
Każdy dobry unit test powinien nie tylko weryfikować nasz kod ale również odcinać zewnętrzne zależności, tak, aby przeprowadzenie testu odbywało się w izolacji. Typów zależności jest kilka, jednak najbardziej problematycznym są te globalne. Zaszyte w naszym kodzie potrafią skutecznie uniemożliwić nam odizolowanie naszej testowanej jednostki. Czy można coś z tym zrobić? Oczywiście! Jest na to kilka naprawdę dobrych technik. A więc zacznijmy od podstaw.
Czym są zależności globalne
Celowo nie użyłem słowa “zmienne” bo to nie jedyny problem. Możemy natrafić na stałe lub makra, które są zależne od platformy, na której nasz kod jest uruchamiany. Innym rodzajem zależności globalnej będzie “Singleton”, z którego nasza testowana klasa korzysta. Kolejnym rodzajem zależności globalnej mogą być zmienne statyczne.
Ostatnią kategorią zależności globalnej jest wolna funkcja! Pewnie pomyślisz coś w stylu: “Ale jak to? To coś złego? Po prostu przetestuję logikę mojej klasy wraz z logiką funkcji!” Oczywiście, jak najbardziej możesz to zrobić i nawet nie będzie to takie złe. Tylko wolna funkcja powinna mieć swój zestaw testów. Po co więc powielać je jako część naszych nowych unit testów? Dobrze by było zasymulować co wolna funkcja ma zwrócić, tak, aby uzyskać pożądany przepływ sterowania w naszym kodzie, bez “wstrzeliwania się” w jej logikę. Do wolnych funkcji zaliczamy również funkcje i obiekty z biblioteki standardowej lub frameworków oraz statyczne metody klas.
Jak widzisz zależności globalne mają różne formy. W świetnej książce “Praca z zastanym kodem. Najlepsze techniki” autora Michael’a Feathers’a, polskie tłumaczenie wykonane przez Ireneusza Jakóbika, określa tego rodzaju rozwiązania jako “spoiny”. Jak dla mnie bardzo trafne i ciekawe tłumaczenie, które również w moim wpisie się pojawi. Przejdźmy zatem do metod radzenia sobie z nimi.
Linkowanie
Jednym z rozwiązań, którym możemy się posłużyć, by odciąć zależności globalne jest proces linkowania. Jeżeli implementacja wolnej funkcji czy Singleton’u jest w pliku .cpp. Możemy stworzyć ich odpowiedniki tylko dla testów, które będą zwracać cały czas te same wartości lub mieć implementację z możliwością sterowania co i jak ma być zwracane w testach, tak aby każdy test był niezależny.
Mamy klasę TemperatureSensor, która do komunikacji z czujnikiem temperatury wykorzystuje protokół “I²C”, który jest zaimplementowany jako wolne funkcje w pliku I2cBus.hpp i I2cBus.cpp.
Tutaj fragment z implementacji klasy TemperatureSensor z użyciem zależności globalnej.
float TemperatureSensor::getAvgTemperature()
{
if(sensorDrivers.empty())
return 0.0f;
float tempSum { 0.0f };
for(const auto& sensorDriver : sensorDrivers)
{
tempSum += i2c::getSensorTemp(sensorDriver.address);
}
return tempSum / sensorDrivers.size();
}
Spoinę linkowania możemy zastosować, gdy w pliku nagłówkowym mamy tylko deklarację.
namespace i2c
{
float getSensorTemp(const int sensorAddress);
}
Natomiast definicja znajduje się w pliku źródłowym (.cpp).
namespace i2c
{
float getSensorTemp(const int sensorAddress)
{
//Tutaj produkcyjna implementacja
}
}
W takiej sytuacji możemy stworzyć osobną definicję dla testów w pliku I2cBusStub.cpp.
namespace i2c
{
float getSensorTemp(const int sensorAddress)
{
return 32.1f;
}
}
Struktura projektu mogłaby wyglądać tak:
-project \
- src\
- temperatureSensor \
- I2cBus.hpp
- I2cBus.cpp
- TemperatureSensor.hpp
- TemperatureSensor.cpp
- tests \
- stubs
- I2cBusStub.cpp
- ut
- TemperatureSensorTests.cpp
W systemie budowany dla testów jako plik źródłowy do nagłówka I2cBus.hpp podajemy implementację z folderu stubs i spoina gotowa.
W ten sposób w naszych unit testach, nie będziemy korzystać z produkcyjnej implementacji tylko z stubowej/mockowej wersji. O różnicach między stubem, a mockiem pewnie jeszcze napiszę ;).
Podsumowując zastosowanie spoiny linkowania odbywa się tak:
- Jeśli trzeba przenosisz implementację zależności globalnej do pliku źródłowego.
- Tworzysz stuba lub mocka zależności globalnej i dostosowujesz system budowania.
Spoina linkowania jest w mojej ocenie jednak rozwiązaniem ostatecznym. Rozwiązuje wprawdzie problem zależności globalnych lecz ma spore wady. Po pierwsze wymaga dużo czasu. Nie tylko musimy zaimplementować stuba/mocka, ale dodatkowo zmienić także pliki budowania. Drugą wadą jest bardzo słaba czytelność. Nawet dobrze skonfigurowane IDE nie raz ma problem, by otworzyć odpowiedni plik źródłowy zależności globalnej i pracując przy testach otwiera produkcyjną implementację, co może być bardzo mylące, zwłaszcza dla mniej doświadczonych programistów. Po trzecie, nie jest rozwiązaniem dla wszystkich typów zależności globalnych. Spoiną linkowania nie odetniemy zależności do zmiennych statycznych, funkcji inline i innych zależności definiowanych w nagłówkach, których z jakiś powodów nie możemy przenieść do plików źródłowych.
Przejdźmy zatem do spoin obiektowych.
Dependency Injection
Wstrzykiwanie zależności to ogólnie dobra metoda separowania zależnych od siebie klas. W przypadku globalnych zależności ta technika również może pomóc. Możemy ją wykorzystać, gdy nasz kod zależny jest od zmiennych globalnych, Singleton’u, a nawet wolnych funkcji.
Technika ta polega na zmianie bezpośredniego wywołania zależności globalnej, w pole klasy i inicjalizowanie go poprzez dodanie parametru konstruktora. Weźmy na warsztat zmienną globalną.
int g_gameObjectsCounter { 0 };
Tutaj przykład jej użycia
void Weapon::fire(const Position& position, const Rotation& position)
{
auto bullet { std::make_unique<Bullet>(g_gameObjectsCounter++, bulletTexture, position, position) };
display->add(std::move(bullet));
}
W takiej wersji zmienna globalna będzie trzymała stan między testami co prowadzi do zależności i złamania zasady I z “F.I.R.S.T.”.
Teraz spróbujmy przekazywać wartość zmiennej globalnej jako parametr konstruktora testowanej klasy. Plik nagłówkowy będzie wyglądał następująco.
class Weapon
{
public:
Weapon(int& inputBulletId = g_gameObjectsCounter);
//...
private:
int& bulletId;
//...
};
Jak widać, możemy nadać bardziej konkretną nazwę, przekazanej zmiennej globalnej, co poprawia czytelność dodając więcej kontekstu do miejsca jej użycia.
Weapon::Weapon(int& inputBulletId)
: bulletId(inputBulletId)
{}
void Weapon::fire(const Position& position, const Rotation& position)
{
auto bullet { std::make_unique<Bullet>(bulletId++, bulletTexture, position, position) };
display->add(std::move(bullet));
}
W każdym teście możemy przekazywać dowolną zmienną, a testy stają się niezależne. W kodzie produkcyjnym niewiele się zmieni. Dodatkowo możemy zdefiniować domyślną wartość nowego parametru i ustawić ją właśnie na zmienną globalną. Zaletą takiego podejścia jest wyrzucenie użycia zmiennej globalnej poza implementację klasy. Możemy też nadać jej lepszą nazwę, bardziej związaną z kontekstem samej klasy. Zmienne globalne zazwyczaj mają bardziej ogólne nazwy. Możemy nawet dojść do miejsca, w którym ta zmienna globalna stanie się tak naprawdę lokalną zmienną tworzoną na stosie funkcji main.
Spróbujmy teraz nieco trudniejszy przypadek - Singleton. Wystarczy nam jego plik nagłówkowy. Sama implementacja do zastosowania Dependency Injection nie jest nam potrzebna.
class TextureStorage
{
public:
TextureStorage(const TextureStorage&) = delete;
TextureStorage& operator=(const TextureStorage&) = delete;
TextureStorage(TextureStorage&&) = delete;
TextureStorage& operator=(TextureStorage&&) = delete;
static const TextureStorage& instance();
const Texture& getTexture(const TextureId id);
private:
std::map<TextureId, Texture> textures;
TextureStorage();
~TextureStorage();
};
A tutaj jego użycie w naszej testowanej klasie Player.
Player::Player(const Position& inputPosition, const Rotation& inputRotation)
: position(inputPosition)
, rotation(inputRotation)
{
const auto& graphicsStorage { TextureStorage::instance() };
texture = getTexture(TextureId::PLAYER);
//Reszta implementacji konstruktora...
}
void Player::changeLook(const TextureId& newTexture)
{
const auto& graphicsStorage { TextureStorage::instance() };
texture = getTexture(newTexture);
}
Najpierw będziemy potrzebowali wydzielić potrzebny interfejs dla Singleton’u.
class ITextureStorage
{
public:
virtual const Texture& getTexture(const TextureId id) = 0;
virtual ~TextureStorage() = 0;
};
Czasem spotykam się z opinią, że dodawanie litery I do nazwy interfejsu to zły pomysł. Jak dla mnie uwidacznia on zastosowanie interfejsu co jest zaletą.
Teraz sam Singleton będzie dziedziczył po nowym interfejsie.
class TextureStorage : public ITextureStorage
{
public:
//...
};
W naszej klasie Player należy dodać referencję do ITextureStorage i przekazać ją w konstruktorze.
class Player
{
public:
Player(const Position& inputPosition, const Rotation& rotation inputRotation, const ITextureStorage& inputTextureStorage);
//...
private:
const ITextureStorage& textureStorage;
//...
};
Dzięki takiemu zabiegowi, będziemy mogli w testach przekazać mocka, który również dziedziczy po tym samym interfejsie co Singleton. Umożliwi nam to pełne i dowolne sterowanie jego zachowaniem w naszej testowanej klasie. W podobny sposób możemy poradzić sobie ze statycznymi obiektami.
Ogólna zasada jest taka:
- Dodajesz parametr do konstruktora
- Tworzysz pole i przekazujesz zależność globalną poprzez konstruktor.
Jedyną wadę jaką mogę tutaj dostrzec jest czasochłonność takiego rozwiązania. Trzeba dodać nie raz sporo kodu, aby móc skorzystać w pełni z tej techniki. Choć i tak wydaje mi się, że nakładu pracy jest mniej niż w spoinie linkowania.
Wrapper
Ostatnim i w mojej ocenie najlepszym rozwiązaniem do szybkiego, poprawnego i efektywnego odcięcia zależności globalnej jest wrapper. To nic innego jak opakowanie użycia globala w metodę. Metodę tą definiujemy jako virtual w sekcji protected. Tylko co nam to daje? Chyba najłatwiej będzie to zrozumieć na przykładzie.
Mamy klasę LoanScheduleGenerator, która wykorzystuje zmienną globalną g_interestRate w metodzie generate.
double g_interestRate = 0.035;
PaymentSchedule LoanScheduleGenerator::generate(const Loan& loan)
{
const unsigned numOfMonths { 12 };
double monthlyRate = g_interestRate / numOfMonths;
// dalsza część implementacji generowania harmonogramu spłaty kredytu
}
Dodajemy wirtualną metodę getInterestRate w sekcji protected.
class LoanScheduleGenerator
{
public:
//...
protected:
virtual double getInterestRate() const;
//...
};
Następnie umieszczamy w niej globalną zależność. I zastępujemy bezpośrednie użycie globala wrapperem.
double LoanScheduleGenerator::getInterestRate() const
{
return g_interestRate;
}
PaymentSchedule LoanScheduleGenerator::generate(const Loan& loan)
{
const unsigned numOfMonths { 12 };
double monthlyRate = getInterestRate() / numOfMonths;
// dalsza część implementacji generowania harmonogramu spłaty kredytu
}
Przejdźmy do testów. Dzięki wrapperowi możemy stworzyć klasę Testable, dziedziczącą po klasie LoanScheduleGenerator, którą chcemy przetestować i właśnie w niej przysłaniamy wrappera nadając mu potrzebne zachowanie.
class LoanScheduleGeneratorTestable : public LoanScheduleGenerator
{
public:
//...
private:
double getInterestRate() const
{
return 1.0;
}
};
W ten sposób odcinamy zależność, minimalizując przy tym ingerencję w kod produkcyjny. Zastosowanie wrappera sprowadza się do kilku kroków:
- Tworzysz wirtualną metodę w sekcji protected testowanej klasy (wrapper).
- Przenosisz wywołanie zależności globalnej do tej metody.
- Zastępujesz użycie globala wrapperem.
- Tworzysz klasę pochodną od klasy testowanej z postfixem Testable i przysłaniasz w niej wrapper.
Podsumowanie
I to już wszystko co chciałem przekazać Ci w temacie odcinania zależności globalnych w testach. Mam nadzieję, że dzięki tym technikom, dużo prościej będzie Ci pracować z Twoimi testami. Celowo nie wspomniałem o spoinach kompilacyjnych (z użyciem preprocesora), gdyż uważam je za bardzo mało intuicyjne i z powodzeniem można użyć spoin obiektowych. Niemniej dla ciekawych odsyłam do książki, o której wspomniałem na początku wpisu.
Autor: Tadeusz Biela
Programista C++ | Entuzjasta TDD | Fan unit testów
Zostaw komentarz