6 najczęstszych błędów z Google Test.
Google Test to chyba najbardziej znany i używany framework testowy w projektach C++. Zawiera w sobie masę przydatnych narzędzi do tworzenia różnych rodzajów testów. Ja osobiście stosuję go w swoich projektach pisząc unit testy, ale nic nie stoi na przeszkodzie by tworzyć testy modułowe czy komponentowe (typu black box). Ceniony za swą uniwersalność oraz rozbudowany system asercji jest narzędziem bardzo potężnym. Mimo to nie jest również bez wad. Im bardziej złożone jest narzędzie tym trudniej się go nauczyć i korzystać z niego poprawnie.
Popełniamy błędy. To normalne, ważne jest co z tym zrobimy. Warto wyciągnąć z nich lekcje na przyszłość. Poniższa lista to nic innego jak moje własne obserwacje, jak programiści C++(w tym ja) wykorzystują GTest i gdzie najczęściej popełniają błędy.
Kolejność parametrów asercji
Czy kolejność w asercji typu EXPECT_EQ jest ważna? Owszem! Choć nie wynika ona z samej definicji makra w nagłówku gtest.h. Wspomniana definicja wygląda następująco.
#define EXPECT_EQ(val1, val2) \
EXPECT_PRED_FORMAT2(::testing::internal::EqHelper::Compare, val1, val2)
Sam Google Test nie informuje gdzie ma być umieszczona wartość oczekiwania, a gdzie wynikowa. Skąd więc założenie, że wartość expected powinna być pierwsza? Głównie z przykładów z repozytorium Google Test.
Przykład z folderu samples repozytorium Google Test, a dokładnie ten “sample4_unittest”.
TEST(Counter, Increment) {
Counter c;
// Test that counter 0 returns 0
EXPECT_EQ(0, c.Decrement());
// EXPECT_EQ() evaluates its arguments exactly once, so they
// can have side effects.
EXPECT_EQ(0, c.Increment());
EXPECT_EQ(1, c.Increment());
EXPECT_EQ(2, c.Increment());
EXPECT_EQ(3, c.Decrement());
}
Oczywiście jest ich znacznie więcej.
Są dwa powody dlaczego warto najpierw podawać wartość oczekiwaną, a potem wynikową.
- Czytelność - przeglądając test, czytamy linijkę od lewej do prawej. Gdy wartością oczekiwaną jest po prostu stałą liczbową lub tekstową, szybciej dowiemy się co jest oczekiwane względem testowanej metody.
- Spójność z warunkami w kodzie - znasz ten ból, gdy w if zamiast użyć operatora porównania ==, użyłeś operator przypisania =? Ja tak, dlatego warto najpierw wartość oczekiwaną podawać jako pierwszą. Jeśli jest to stała, kompilator szybko poinformuje nas o pomyłce.
if(i = 5) // Kompilator nie zgłosi błędu
if(5 = i) // a tutaj już tak
Logika jest zachowana ale zwiększa się odporność na błędy.
To która wersja będzie poprawna?
EXPECT_EQ(resultValue, expectedValue); // Ta?
EXPECT_EQ(expectedValue, resultValue); // czy ta?
Tak naprawdę - to zależy. Choć sama kolejność jest sugerowana przykładami, nie jest ona napisana wprost. Którą więc wybrać? Jeśli wchodzisz do projektu, gdzie pierwsza wartość to ta wynikowa. Dostosuj się do konwencji już panującej w projekcie. Jeśli to nowy projekt, lub brak jest spójnej konwencji, wybierz wersję, gdzie wartość oczekiwana podawana jest jako pierwsza.
Używanie EXPECT zamiast ASSERT
Ten błąd może kosztować życie (naszego wątku uruchamiającego testy). Dlaczego? Najpierw omówmy różnicę w zachowaniu EXPECT_EQ i ASSERT_EQ (lub dowolnej innej pary tych typów asercji). Obie asercje mają za zadanie sprawdzać czy dana wartość zgadza się z oczekiwaną. Różnica polega na tym co wydarzy się w przypadku niepowodzenia asercji. EXPECT spowoduje, że test nie zostanie zaliczony, wypisze komunikat i przejdzie do następnej linii testu.
TEST_F(WidgetFactoryTests, create_ButtonWidgetType_ReturnButtonWidget)
{
const Rect expectedRect { 300, 400, 120, 30 };
const std::string expectedText { "Test Button" };
WidgetFactory factory;
const auto resultWidget { factory.create(WidgetType::BUTTON, expectedRect, expectedText)};
EXPECT_EQ(WidgetType::LABEL, resultWidget.getType()); // Ta asercja się nie powiedzie
EXPECT_EQ(expectedRect, resultWidget.getRect()); // Ta i następna zostaną wywołane
EXPECT_EQ(expectedText, resultWidget.getText());
}
ASSERT również spowoduje fail testu, wypisze komunikat, ale już nie przejdzie dalej do kolejnej części testu. Test zostaje przerwany w tym miejscu i dalej nie będzie kontynuowany.
TEST_F(WidgetsFactorySfmlTests, create_LabelType_CreateLabelWidget)
{
const auto sfmlWindow { std::make_shared<MockSfmlWindow>() };
EXPECT_CALL(*sfmlWindow, getRenderTarget()).WillOnce(ReturnRef(getWindow()));
EXPECT_CALL(*sfmlWindow, add(_));
WidgetsFactorySfml factory { nullptr, sfmlWindow, nullptr };
const auto resultWidget {
factory.create(
WidgetType::LABEL,
WidgetGeometry {},
WidgetText {},
WidgetStyle {}
)
}; // Jeśli ta metoda zwróci nullptr
ASSERT_TRUE(resultWidget != nullptr); // to ta asercja się nie powiedzie i test przerywa wykonywanie.
EXPECT_EQ(WidgetType::LABEL, resultWidget->getType()); // Ta i następna asercja nie zostaną wywołane
EXPECT_TRUE(dynamic_cast<LabelWidget*>(resultWidget.get()) != nullptr);
}
Co nam to daje? Bardzo wiele, zwłaszcza gdy kolejne asercje opierają się na naszym ASSERT. Jeśli zastosujemy zwykły EXPECT to test zakończy swoje działanie poprzez brutalne przerwanie w stylu segfaulta.
TEST_F(WidgetsFactorySfmlTests, create_LabelType_CreateLabelWidget)
{
const auto sfmlWindow { std::make_shared<MockSfmlWindow>() };
EXPECT_CALL(*sfmlWindow, getRenderTarget()).WillOnce(ReturnRef(getWindow()));
EXPECT_CALL(*sfmlWindow, add(_));
WidgetsFactorySfml factory { nullptr, sfmlWindow, nullptr };
const auto resultWidget {
factory.create(
WidgetType::LABEL,
WidgetGeometry {},
WidgetText {},
WidgetStyle {}
)
};
EXPECT_TRUE(resultWidget != nullptr); // zamiast ASSERT jest EXPECT
EXPECT_EQ(WidgetType::LABEL, resultWidget->getType());
EXPECT_TRUE(dynamic_cast<LabelWidget*>(resultWidget.get()) != nullptr);
}
Test nie przejdzie, na dodatek, jeśli w naszej suicie będzie więcej testów i miały być one uruchomione po tym, to do tego nie dojdzie. Cały wątek z naszą suitą zostanie przerwany. Czyli inne testy nawet się nie wykonają.
[ RUN ] WidgetsFactorySfmlTests.create_LabelType_CreateLabelWidget
Setting vertical sync not supported
/usr/src/heisttown/src/Sfml/SfmlWidgets/Tests/WidgetsFactorySfmlTests.cpp:42: Failure
Value of: resultWidget != nullptr
Actual: false
Expected: true
W tym przypadku mamy chociaż informacje co poszło nie tak. Jeśli nie dodalibyśmy, w ogóle asercji na sprawdzanie czy resultWidget nie jest nullptr, nie wiedzielibyśmy nic. Dlatego ważne jest, aby stosować ASSERT czy EXPECT zgodnie z ich przeznaczeniem.
Dlaczego więc nie stosować ASSERT zawsze? Jak już pisałem, ASSERT zatrzymuje test, dalsza jego część nie jest weryfikowana. Jeśli następne asercje nie są zależne od poprzednich to, w takim przypadku zostaną pominięte i nie będziemy wiedzieli, czy tylko ta jedna asercja nie została spełniona, czy cały ich szereg. Obraz sytuacji będzie niekompletny.
Jeśli oczekiwanym wynikiem może być kontener to sprawdź jego rozmiar ASSERTem zanim zaczniesz weryfikować jego konkretne elementy. Albo, gdy wynikiem będzie wskaźnik, sprawdź czy nie jest on nullptr zanim zaczniesz odwoływać się do obiektu pod nim. To tylko najczęściej powtarzające się sytuacje, gdzie należy stosować ASSERT. Z pewnością jest ich więcej.
Porównywanie float’ów bez precyzji
Reprezentacja liczb zmiennoprzecinkowych przez komputer nie jest doskonała i wie to każdy doświadczony programista. Porównywanie wartości typu float czy double ze sobą nie jest takie proste jak liczb całkowitych. Weryfikacja testu odbywa się właśnie przez porównanie, a wyniki zapisane w liczbach zmiennoprzecinkowych mogą się nieznacznie różnić od tych oczekiwanych.
Dlaczego tak się dzieje?
Spora część liczb zmiennoprzecinkowych nie ma idealnej reprezentacji w systemie binarnym. Dlatego komputer musi zaokrąglać wyliczoną wartość do najbliższej zero-jedynkowej wartości. Jest nawet na to określony standard “IEEE 754”. Właśnie ze względu na te zaokrąglenia, porównywanie wartości zmiennoprzecinkowych, może dać fałszywe wyniki. Dlatego stosuje się porównywanie z tolerancją, tak zwany epsilon.
To mi przypomina moje początki nauki pisania unit testów i pracy z Google Test. Tak, mniej więcej, wyglądał jeden z moich pierwszych unit testów w zakresie weryfikacji wyniku zapisanego właśnie w double.
TEST(DemodulationTests, llr16QamIMsb_PositiveRealSampleValueAndPositiveNoise_ReturnCorrectLlr)
{
const std::complex<double> sample { 1.0, 0.0 };
const double noise { 0.394 };
const auto resultLlr { llr16QamIMsb(sample, noise) };
EXPECT_EQ(5.0761421319796952, resultLlr);
}
Teraz, gdy o tym myślę, chce mi się śmiać, ale kto nie popełnia błędów? Ten kto nic nie robi ;) Dziś sobie z przed lat powiedziałbym co trzeba zrobić w takim przypadku, poprawilibyśmy ten test i wyglądałby następująco.
TEST(DemodulationTests, llr16QamIMsb_PositiveRealSampleValueAndPositiveNoise_ReturnCorrectLlr)
{
const std::complex<double> sample { 1.0, 0.0 };
const double noise { 0.394 };
const auto resultLlr { llr16QamIMsb(sample, noise) };
EXPECT_NEAR(5.07614, resultLlr, 0.00001);
}
Zmieniła się tylko sama asercja i z pewnością test stał się niewrażliwy na zmianę platformy, która ma inną precyzję operacji na liczbach zmiennoprzecinkowych. Dodatkowo dzięki właśnie EXPECT_NEAR możemy jasno określić tolerancję, czyli już wcześniej wspomniany epsilon.
Logika w Mocku
Wraz z Google Test dostajemy również Google Mock, jest on dodawany jako osobny nagłówek gmock.h. Mock to w tłumaczeniu na polski - makieta. Czyli, w kontekście programowania obiektowego, coś co tylko udaje implementację klasy. Nieraz jednak widziałem, mocki, które przysłaniały tylko część metod, pozostawiając lub nadpisując implementację klasy, z której dziedziczyły.
Możemy podejść do mockowania na przynajmniej dwa sposoby. Pierowysz to bazowanie na interfejsie. Wprowadzamy klasę czysto abstrakcyjną, do której nasza testowana klasa się odwołuje, nie znając prawdziwej wersji implementacji.
Poniżej przykład interfejsu RenderSceneBuilder. Klasa ta ma za zadanie budowanie sceny gry złożonej z obiektów klasy RenderItem (również interfejs) w oparciu o strukturę SceneUdate.
class RenderSceneBuilder
{
public:
virtual ~RenderSceneBuilder() = default;
virtual void build(const SceneUpdate& sceneUpdate) = 0;
virtual RenderItems popRenderItems() = 0;
};
A tutaj przykład jego wykorzystania (uproszczony by nie zaciemniać obrazu).
class SfmlWindow
{
public:
SfmlWindow(
//...
std::unique_ptr<RenderSceneBuilder> inputSceneBuilder = nullptr
//...
);
//...
virtual void update(const SceneUpdate& sceneUpdate) override;
private:
//...
std::unique_ptr<RenderSceneBuilder> sceneBuilder;
//...
};
Teraz utworzymy mocka z interfejsu, MockRenderSceneBuilder.
class MockRenderSceneBuilder : public RenderSceneBuilder
{
public:
MOCK_METHOD(void, build, (const SceneUpdate&), (override));
MOCK_METHOD(RenderItems, popRenderItems, (), (override));
};
W tym przypadku klasa mocka przysłania tylko interfejs i wszystko mamy czyste. Wstrzykujemy mocka do naszej testowanej klasy poprzez konstruktor i voilà!
Drugi sposób - do destruktora oraz wszystkich publicznych metod klasy, którą chcemy zamockować dodajemy virtual. Dla przykładu mamy klasę GameSession.
class GameSession
{
public:
explicit GameSession(std::unique_ptr<SceneItemFactory> inputFactory = nullptr);
virtual ~GameSession() = default;
virtual PlayerID addPlayer();
virtual void removePlayer(const PlayerID& playerId);
virtual void queuePlayerStatus(const PlayerID& playerId, const PlayerStatus& playerStatus);
virtual void updateGameWorld();
virtual GameplayUpdate getUpdateForPlayer(const PlayerID& playerId) const;
private:
//...
};
I mockujemy.
class MockGameSession : public GameSession
{
public:
MockGameSession() = deafult;
MOCK_METHOD(PlayerID, addPlayer, (), (override));
MOCK_METHOD(void, removePlayer, (const PlayerID&), (override));
MOCK_METHOD(void, queuePlayerStatus, (const PlayerID&, const PlayerStatus&), (override));
MOCK_METHOD(void, updateGameWorld, (), (override));
MOCK_METHOD(GameplayUpdate, getUpdateForPlayer, (const PlayerID&), (const, override));
};
Jeśli pozostawimy część publicznych metod jako niewirtualne i nie przysłonimy ich w mocku to nie odcinamy w pełni zależności między naszą testowaną klasą, a klasą zmockowaną. Taki test de facto nie jest już unit testem lecz czymś w rodzaju karykatury module testu. Poniżej przykład
class MockGameSession : public GameSession
{
public:
MockGameSession() = deafult;
MOCK_METHOD(PlayerID, addPlayer, (), (override));
MOCK_METHOD(void, removePlayer, (const PlayerID&), (override));
MOCK_METHOD(void, queuePlayerStatus, (const PlayerID&, const PlayerStatus&), (override));
MOCK_METHOD(void, updateGameWorld, (), (override));
GameplayUpdate getUpdateForPlayer(const PlayerID&) const override
{
// Generuje losowe wartości i wypełnia nimi GameplayUpdate
}
};
W przypadku, gdy dopisujemy jakąś część logiki w kodzie mocka ograniczamy widoczność tych operacji w naszych testach. Mock sam z siebie nie powinien nic robić. Służy on do weryfikacji zachowania naszej testowanej klasy, tj. jego interakcji z mockowaną klasą. Jeśli potrzebujemy, by nasz mock reagował na te interakcje Google Mock udostępnia szereg narzędzi jak ON_CALL i EXPECT_CALL z odpowiednią kombinacją na przykład WillOnce i Invoke (są też inne metody na sterowanie zachowaniem mocka). Wtedy wszystko jest zapisane w naszym unit teście, w bloku “Arrange”.
Testowanie Mocka
Jak już wspomniałem mocki służą do weryfikacji interakcji z naszą testowaną klasą. Spotkałem się jednak z innym sposobem wykorzystywania mocków w unit testach. Kiedy nie znamy dobrych podstaw pisania unit testów (o których jeszcze napiszę ;) ), mogą powstać naprawdę dziwne rzeczy.
W unit testach widziałem wiele nietypowych konstrukcji, i w rzeczy samej testowanie mocka jest jedną z nich. Chcąc odciąć jakąś zależność możemy zastosować mocka, aby przysłonić część implementacji lub wręcz zastąpić ją inną. Co de facto mija się z celem testowania.
Dla przykładu, mamy mocka klasy MockGameSession z poprzedniego punktu. Tym razem jak najbardziej poprawnego.
class MockGameSession : public GameSession
{
public:
MockGameSession() = deafult;
MOCK_METHOD(PlayerID, addPlayer, ());
MOCK_METHOD(void, removePlayer, (const PlayerID&));
MOCK_METHOD(void, queuePlayerStatus, (const PlayerID&, const PlayerStatus));
MOCK_METHOD(void, updateGameWorld, ());
MOCK_METHOD(GameplayUpdate, getUpdateForPlayer, (const PlayerID&), (const));
MOCK_METHOD(Texture, getTexture, (const std::string), (const)); // metoda protected w GameSession
};
Natomiast w unit testach chcemy przetestować metodę addPlayer klasy GameSession, jednak korzysta ona z metody getTexture odczytującej teksturę gracza z pliku. W takim przypadku moglibyśmy zastosować “Wrapper” lub wprowadzić osobny obiekt klasy Factory i zacmokocwać ją stosująć Dependency Injection. Jednak nie znając jeszcze tych technik możemy pokusić się o użycie mocka w taki sposób, by podczas testowania metody addPlayer przysłonić tylko metodę getTexture, która to odpowiedzialna jest za odczyt pliku.
TEST_F(GameSessionTests, addPlayer_OneNewPlayer_ReturnNewPlayerId)
{
auto factory { std::make_unique<MockSceneItemFactory>() };
EXPECT_CALL(*factory, create(_, _, _));
MockGameSession gameSession { std::move(factory) };
EXPECT_CALL(gameSession, getTexture()).WillOnce(Return(Texture())); // Mockowanie zależności odczytu z pliku
EXPECT_CALL(gameSession, addPlayer()).WillOnce(Invoke([&](){ // Mockowana metoda wykonuje tą prawdziwą
return gameSession.GameSession::addPlayer();
}));
const auto resultPlayerId { gameSession.addPlayer() }; // Wywołanie mockowanej metody
EXPECT_EQ(0, resultPlayerId);
}
Dlaczego uważam to za fatalny pomysł? W końcu działa…
No cóż to tak jakby używać spawarki do wbijania gwoździ, no niby da się tylko po co? Mocki zostały wymyślone po to by odcinać zależności między klasami, a nie do grzebania w implementacji naszej testowanej klasy. Gdy widzę test, w którym nie ma normalnej klasy, tylko same mocki to coś tu jest nie tak.
Taki test jest bardzo nieczytelny. Gdy dodamy do tego enigmatyczną nazwę to praktycznie na pierwszy czy nawet drugi rzut oka, nie jesteśmy w stanie stwierdzić, która klasa jest testowana. W GoogleTest są oczywiście nazwa test suity, która może w tym przypadku podpowiedzieć to i owo, niemniej, czytelność i tak spada diametralnie.
Używajmy narzędzi zgodnie z ich pierwotnym przeznaczeniem, zwłaszcza podczas testowania. Dbamy tym samym o wysoką czytelność i przejrzystość w naszych unit testach.
Brak override w mockach
Słowo kluczowe (a dokładnie specyfikator) override możemy stosować również w mockowanych metodach i działa ono dokładnie tak samo jak w zwykłych klasach. W MOCK_METHOD na końcu, w sekcji gdzie dodajemy kwalifikatory i specyfikatory. Dla przykładu, wróćmy do MockGameSession.
class MockGameSession : public GameSession
{
public:
MockGameSession() = deafult;
MOCK_METHOD(PlayerID, addPlayer, ());
MOCK_METHOD(void, removePlayer, (const PlayerID&));
MOCK_METHOD(void, queuePlayerStatus, (const PlayerID&, const PlayerStatus));
MOCK_METHOD(void, updateGameWorld, ());
MOCK_METHOD(GameplayUpdate, getUpdateForPlayer, (const PlayerID&), (const));
};
Jeśli w takim przypadku metoda removePlayer w klasie GameSession nie będzie wirtualna, kompilator i tak nie zwróci błędu. Kod skompiluje się, a my będziemy zachodzić w głowę czemu nasz mock nie jest wywoływany.
Użycie override eliminuje problem. W przypadku, gdy dodamy ten specyfikator, a metoda w klasie bazowej nie będzie wirtualna, kompilator zwróci błąd. Tak jak na poniższym przykładzie.
Tutaj już poprawiony mock.
class MockGameSession : public GameSession
{
public:
MockGameSession() = deafult;
MOCK_METHOD(PlayerID, addPlayer, (), (override));
MOCK_METHOD(void, removePlayer, (const PlayerID&), (override));
MOCK_METHOD(void, queuePlayerStatus, (const PlayerID&, const PlayerStatus&), (override));
MOCK_METHOD(void, updateGameWorld, (), (override));
MOCK_METHOD(GameplayUpdate, getUpdateForPlayer, (const PlayerID&), (const, override));
};
Oraz błąd zwracany przez kompilator.
/.../MockGameSession.hpp:16:23: error: 'testing::internal::Function<void(const int&)>::Result MockGameSession::removePlayer(testing::internal::ElemFromList<0, const int&>::type)' marked 'override', but does not override
16 | MOCK_METHOD(void, removePlayer, (const PlayerID&), (override));
|
Podsumowanie
To już wszystko w tym wpisie. Mam nadzieję, że będzie dla Ciebie choć trochę pomocny w zmaganiach z Google Test’em. Czy któryś z wymienionych w tym wpisie błędów Cię zaskoczył? Może inny jest dość znajomy? Daj znać w komentarzu! Popełnianie błędów jest czymś normalnym. Ważne by się rozwijać i uczyć od siebie nawzajem. Jeśli znasz inne przypadki, chętnie się z nimi zapoznam. Możesz również napisać do mnie email lub wysłać wiadomość na LinkedIn. Będzie mi bardzo miło :)
Autor: Tadeusz Biela
Programista C++ | Entuzjasta TDD | Fan unit testów
Zostaw komentarz