Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Podstawy pracy z GoogleMock


2020-01-06, 00:00

Po temacie GoogleTest bardzo naturalnym staje się kontynuacja tematu w kierunku mockowania. Czym są Mocki wie każdy, kto zna teorię testowania. Niezależnie jednak od tego, czy tą wiedzę posiadacie czy nie, serdecznie zapraszam Was do przeczytania dzisiejszego wpisu, w którym wyjaśnię podstawy pracy z obiektami mockującymi w GoogleMock.

Różnice pomiędzy środowiskiem produkcyjnym i testowym

Kiedy tworzymy kod produkcyjny, powinno zależeć nam na tym, aby wszystkie przetworzone przez ten kod informacje trafiały w odpowiednie miejsce. Mogą to być pliki, bazy danych, czy po prostu standardowe wyjście naszego programu. Patrząc na ten sam kod z perspektywy testów logiki biznesowej - mało istotne dla nas jest, gdzie tak na prawdę ostatecznie trafią te dane. Podróż informacji z do jej miejsca przeznaczenia (czyt. na zewnątrz) jest zadaniem infrastruktury, która zazwyczaj jest najwolniejszym elementem całej aplikacji.

Pisząc dobre testy jednostkowe powinniśmy w stu procentach odciąć się od wszystkich miejsc łączących nas ze światem zewnętrznym. W sytuacji, kiedy (podczas testowania) mamy do czynienia z obiektem klasy transportującej informację poza naszą aplikację, powinniśmy zastąpić go innym obiektem - takim, którego klasa będzie implementowała ten sam interfejs co klasa obiektu “prawdziwego”, jednakże jej implementacja nie wyniesie odebranej informacji na zewnątrz. Na potrzeby tego wpisu, nazwijmy je obiektami-marionetkami.

Rodzaje obiektów-marionetek

Obiektów-marionetek mamy kilka rodzajów. Poniżej omówię krótko każdy z nich.

Imitacja, czyli fake object

Imitacją nazywamy obiekt, który podobnie jak obiekt produkcyjny, implementuje ten sam interfejs. Różnicą pomiędzy nimi dwoma jest to, że w przeciwieństwie do obiektu produkcyjnego, implementacja wewnątrz fake object imituje jedynie zachowanie produkcyjne. Dobrym przykładem tutaj jest przykład repozytorium. Aplikacje backendowe wykorzystują repozytorium do utrwalania obiektów encji. Zatem produkcyjna wersja repozytorium będzie odwoływała się do bazy danych. Aby podczas testów jednostkowych nie tracić czasu na komunikację pomiędzy aplikacją oraz bazą danych (testowanie jednostkowe polega właśnie na braku komunikacji zewnętrznej), tworzymy klasę typu in memory, która zapamiętuje encje wewnątrz siebie na czas trwania testów. Taka klasa może wyglądać następująco:

#include <vector>
#include <memory>
#include "AbstractRepository.hpp"
#include "Entity.hpp"
#include "Exception/EntityDoesNotExistException"

class InMemoryRepository : public AbstractRepository {

public:

    void store(std::shared_ptr<Entity> entity) override {
        if (!isEntityStored(entity)) {
            entities.emplace_back(entity);
        }
    }

    std::shared_ptr<Entity> findById(int id) override {
        for (auto &storedEntity: entities) {
            if (storedEntity->getId() == id) return storedEntity;
        }
        throw EntityDoesNotExistException(id);
    }

private:

    std::vector_ptr<Entity> entities;

    bool isEnityStored(std::shared_ptr<Entity> entity) {
        for (auto &storedEntity: entities) {
            if (storedEntity->getId() == entity->getId()) return true;
        }
        return false;
    }
};

Jak zauważyliście, implementujemy tutaj własne algorytmy, które w rzeczywistości mają za zadanie imitować prawdziwe repozytorium. Do tworzenia fake-obiektów nie potrzebujemy dodatkowych narzędzi.

Stub

Drugim rodzajem obiektów-marionetek są Stuby, czyli obiekty podobnie jak fake objects implementujące interfejs klasy produkcyjnej. Wyjątek stanowi tutaj implementacja, która sprowadza się do zwracania danych zakodowanych “na sztywno”. Przykładem tutaj mogą być na przykład obiekty typowo domenowe, takie jak encje (reprezentacja utrwalalnego przez repozytorium obiektu) czy proste obiekty wartości.

Poniżej przykład obiektu typu Stub:

#include <string>
#include "Domain/MessageInterface"

class NonHtmlMessage : public MessageInterface {

public:

    std::string getMessage() const override {
        return "this is stub message";
    }

    bool isHtml() const override {
        return false;
    }
};

Obiekty takiej klasy mogą być wykorzystywane wielokrotnie podczas testowania naszej aplikacji. Ktoś może zapytać: “Dlaczego nie utworzymy obiektu klasy produkcyjnej z testowymi danymi?”. Odpowiedź na to pytanie jest prosta: już po samej nazwie klasy wiemy, z jakim obiektem mamy do czynienia. Nasze testy nie zajmują się definiowaniem danych testowych (które niejednokrotnie potrafią być nieistotne w trakcie wykonywania testu). Dodatkową zaletą tego rozwiązania jest również to, że nasza architektura wewnątrz testów zostaje nieco bardziej ustandaryzowana.

Mock object

Mocki są to obiekty, które pozwalają na definiowanie Stubów i sprawdzanie zachowań testowanego kodu względem nich (ang. Expectations). Mocki pozwalają sprawdzić np. jak często została wywołana konkretna metoda, jakie wartości zostały przesłane jako parametry czy też zgodność zwróconych przez Stuby wartości.

W dalszej części wpisu będziemy kontynuowali temat Mocków właśnie.

Przetestujmy ten kod!

Przygotowałem dosyć ciekawy temat, który powinien pokazać nam zaletę mockowania. Wyobraźmy sobie klasę przechowującą dowolną wiadomość:

class Message {

public:

    explicit Message(std::string message) : message(std::move(message)) {

    }

    std::string getMessage() const {
        return message;
    }

    bool isHtml() const {
        auto startHtmlTagPos = message.find("<html>");
        if (startHtmlTagPos != std::string::npos) {
            auto endHtmlTagPos = message.find("</html>");
            return endHtmlTagPos != std::string::npos && endHtmlTagPos > startHtmlTagPos;
        }
        return false;
    }

private:

    std::string message;

};

Jak nam doskonale wiadomo, wiadomość możemy móc wysłać. Załóżmy, że chcemy mieć możliwość jej wysyłki na dwa różne sposoby: e-mailem oraz przez komunikator Slack. Mamy do napisania w takim razie dwie klasy transportu wiadomości. Abstrakcja do transportu będzie wyglądała mniej więcej tak:

class AbstractTransport {

public:

    virtual bool send(std::string to, std::string content) = 0;
    virtual bool isReceiverValid(std::string receiver) = 0;

    virtual ~AbstractTransport();

};

Poniżej implementacja dwóch klas dziedziczących po tym interfejsie:

class EmailTransport : public AbstractTransport {

public:

    bool send(std::string to, std::string content) override {
        return isReceiverValid(to);
    }

    bool isReceiverValid(std::string to) override {
        bool atFound = false;
        bool dotFound = false;

        for (auto c: to) {
            if (!atFound) {
                if (c == '@') {
                    atFound = true;
                }
            } else if (!dotFound) {
                if (c == '.') {
                    dotFound = true;
                }
            }
        }

        return atFound && dotFound;
    }

};

class SlackTransport : public AbstractTransport {

public:

    bool send(std::string to, std::string content) override {
        return isReceiverValid(to);
    }

    bool isReceiverValid(std::string to) override {
        for (auto c: to) {
            if (c == '@') {
                return false;
            }
        }
        return true;
    }

};

Zadaniem tego wpisu nie jest implementacja wysyłki, dlatego uprościłem to do absolutnego minimum. Ważna tutaj jest abstrakcja.

Poniżej znajdują się testy jednostkowe do wszystkich powyższych klas. Jeżeli coś tutaj jest nie jasne, to znaczy że potrzebujesz zapoznać się z poprzednimi wpisami: Testy jednostkowe z GoogleTest oraz Testy parametryzowane w GoogleTest. Jak już wszystko jasne, to lecimy z tematem:

TEST(Message, test_get_message_content) {
    std::string message = "message content";
    EXPECT_EQ(message, Message(message).getMessage());
}

TEST(Message, test_message_is_html) {
    std::string message = "<html>message</html>";
    EXPECT_TRUE(Message(message).isHtml());
}

TEST(Message, test_message_is_not_html_when_no_start_html_tag_found) {
    std::string message = "message</html>";
    EXPECT_FALSE(Message(message).isHtml());
}

TEST(Message, test_message_is_not_html_when_no_end_html_tag_found) {
    std::string message = "<html>message";
    EXPECT_FALSE(Message(message).isHtml());
}

TEST(Message, test_message_is_not_html_end_tag_is_before_start) {
    std::string message = "</html>message<html>";
    EXPECT_FALSE(Message(message).isHtml());
}

TEST(EmailTransport, test_send_message_to_valid_email) {
    std::string email = "my@example.com";

    EmailTransport transport;
    EXPECT_TRUE(transport.send(email, "message"));
}

TEST(EmailTransport, test_send_message_to_receiver_without_at) {
    std::string email = "example.com";

    EmailTransport transport;
    EXPECT_FALSE(transport.send(email, "message"));
}

TEST(EmailTransport, test_send_message_to_receiver_without_dot) {
    std::string email = "example@com";

    EmailTransport transport;
    EXPECT_FALSE(transport.send(email, "message"));
}

TEST(SlackTransport, test_send_message_to_valid_identifier) {
    std::string email = "my-receiver";

    SlackTransport transport;
    EXPECT_TRUE(transport.send(email, "message"));
}

TEST(SlackTransport, test_send_message_to_an_email) {
    std::string email = "my@example.com";

    SlackTransport transport;
    EXPECT_FALSE(transport.send(email, "message"));
}

Wszystkie te testy są bardzo proste. Polegają na przekazaniu odpowiednich parametrów i sprawdzeniu zwracanych wartości. Kiedy mamy już przetestowane nasze puzelki, to możemy spiąć je w całość klasą SendMessage:

class SendMessage {

public:

    explicit SendMessage(std::shared_ptr<AbstractTransport> transport);

    void addTo(std::string to) {
        toList.push_back(to);
    }

    void send(Message message) {
        if (toList.empty()) {
            return;
        }

        for (auto &to: toList) {
            transport->send(message.getMessage(), to);
        }
    }

private:

    std::shared_ptr<AbstractTransport> transport;
    std::vector<std::string> toList;

};

Podobnie, jak poprzednio - mamy bardzo prostą klasę, której zrozumienie nie powinno dla nikogo stanowić problemu.

Co z testami klasy SendMessage?

Jak się pewnie domyślacie, tej klasy nie przetestujemy w taki sam sposób co pozostałych. Niestety, ale funkcja send(...) nie zwraca żadnych informacji, po których moglibyśmy się dowiedzieć, do kogo została wysłana wiadomość. Co, jeżeli mielibyśmy w naszym kodzie błąd, przez który jedna wiadomość poszłaby do użytkownika 100 razy? Ja po swojej stronie mam już kilka ciekawych historii związanych z błędami przy wysyłce wiadomości do użytkowników, dlatego teraz podchodzę do tego tematu bardzo bezpiecznie. Jednakże, co możemy w tej chwili zrobić, aby nabrać na pewności, że wszystko poszło tak, jak powinno?

GoogleMock - do dzieła!

Tak tak - skorzystamy z Mocków :) Naszym zadaniem będzie utworzenie obiektu “udającego” klasę transportu, a następnie sprawdzenie, czy metoda send(...) została uruchomiona odpowiednią liczbę razy, z odpowiednimi parametrami. Dodatkowo możemy sprawdzić też, jakie wartości zostały zwrócone przez tą metodę. Gotowi? Do dzieła! :)

Uwaga. Biblioteka GoogleMock jest włączona w paczkę GoogleTest. Jeżeli chcemy skorzystać z dobrodziejstw GoogleMock, powinniśmy zaincludować nagłówek gmock/gmock.h.

Nasza klasa mockująca wygląda następująco:

class MockTransport : public AbstractTransport {

public:

    MOCK_METHOD2(send, bool(std::string to, std::string content));

};

Może wyglądać to nieco dziwnie, ale już biegnę z wyjaśnieniami.

Pozwalamy GoogleMock na utworzenie mocka dla methody send, która zwraca wartości typu bool i przyjmuje dwa parametry typu std::string. Nieco dziwnym może wydawać się makro MOCK_METHOD2. Stojąca przy nim dwójeczka oznacza liczbę parametrów, które przyjmuje mockowana metoda. Do wersji 1.8 makra korzystające ze zmiennej ilości parametrów są generowane statycznie, stąd manualnia potrzeba zarządzania makrami. Od wersji 1.10 (która jeszcze nie jest dostępna dla menadżera pakietów Conan) sprawa staje się prosta - mamy jedno makro, bez numerka. Jest to temat podobny do opisywanego przeze mnie wcześniej problemu z ograniczeniem liczby parametrów w testach parametryzowanych.

Skoro mamy już klasę mockującą, to najwyższa pora na napisanie naszego testu. Na samym początku upewnijmy się, że jeżeli nie podamy żadnego odbiorcy, to wiadomość nie zostanie wysłana do nikogo:

TEST(SendMessage, test_sender_doesnt_send_message) {
    auto mockTransport = std::make_shared<MockTransport>();

    SendMessage sendMessage(mockTransport);
    Message message("message content");

    EXPECT_CALL(*mockTransport, send("asd", "asd"))
            .Times(0);

    sendMessage.send(message);
}

Na początku testu tworzymy obiekt Mocka. Ja do tego celu używam std::shared_ptr, jednakże nic nie stoi na przeszkodzie, aby operować na prostych wskaźnikach. W dalszej kolejności przekazujemy go do testowanej przez nas klasy jako zależność. Po utworzeniu prostego Stuba na klasie Message nastepuje makro EXPECT_CALL, które daje nam możliwość zdefiniowania zachowań, których oczekujemy od naszego Mockowego obiektu. My oczekujemy, że metoda nie wykona się ani razu (stąd wywołanie metody Times(0)). Zwróćcie uwagę na parametry "asd" mockowanej metody. Kiedy nie oczekujemy wywołania metody - podane przez nas parametry nie mają znaczenia.

Na końcu testu wywołujemy metodę SendMessage::send(...), która wewnątrz siebie korzysta z Mocka.

Kiedy mamy pewność, że powyższy scenariusz działa, możemy zająć się scenariuszem wysyłki wiadomości do tylko jednego użytkownika:

TEST(SendMessage, test_sender_really_sends_message) {
    auto mockTransport = std::make_shared<MockTransport>();

    SendMessage sendMessage(mockTransport);
    sendMessage.addTo("my@email.to");
    Message message("message content");

    EXPECT_CALL(*mockTransport, send("message content", "my@email.to"))
            .Times(1)
            .WillOnce(Return(true));

    sendMessage.send(message);
}

Ponownie jak wcześniej, korzystamy z tej samej klasy MockTransport. Różnicą, jaką tutaj mamy jest dodanie użytkownika, do kórego powinna zostać wysłana wiadomość. Tutaj oczekujemy, że metoda send(...) zostanie wywołana jeden raz z określonymi przez nas parametrami. Dodatkowo zaznaczamy, że zmockowana metoda send(...) zwróci wartość true. Jest to funkcja, która pozwala na sterowanie wykonaniem testowanej funkcji (SendMessage::send(...)) z zewnątrz. W tym jednak przypadku jest to nadmiarowe, gdyż metoda SendMessage::send(...) nie wykorzystuje zwracanej przez nas wartości nigdzie dalej.

Sterowanie procesem wykonania metody

Skoro mamy możliwość sterowania dalszym wykonaniem metody, to dlaczego by z tego nie skorzystać? Spróbujmy przetestować metodę:

void SendMessage::sendToValidReceivers(Message message) {
    if (toList.empty()) {
        return;
    }

    for (auto &to: toList) {
        if (transport->isReceiverValid(to)) {
            transport->send(message.getMessage(), to);
        }
    }
}

Do tego celu musimy nieco zmodyfikować naszą klasę MockTransport:

class MockTransport : public AbstractTransport {

public:

    MOCK_METHOD2(send, bool(std::string to, std::string content));
    MOCK_METHOD1(isReceiverValid, bool(std::string to));

};

Tak, dorzuciliśmy metodę isReceiverValid zwracającą wartość typu bool, która przyjmuje jeden parametr. Załóżmy sobie scenariusz:

Jeżeli identyfikator osoby, do której ma zostać wysłana wiadomość jest niepoprawny - nie podejmujemy wysyłki.

Nasz test wygląda następująco:

TEST(SendMessage, test_sender_sends_only_to_valid_receivers) {
    auto mockTransport = std::make_shared<MockTransport>();

    std::string invalidIdentifier = "im-not-valid-identifier";
    std::string validIdentifier = "im-valid-identifier";
    std::string messageContent = "message content";

    SendMessage sendMessage(mockTransport);
    sendMessage.addTo(invalidIdentifier);
    sendMessage.addTo(validIdentifier);

    Message message(messageContent);

    EXPECT_CALL(*mockTransport, isReceiverValid(invalidIdentifier))
            .Times(1)
            .WillOnce(Return(false));

    EXPECT_CALL(*mockTransport, send(messageContent, invalidIdentifier))
            .Times(0);

    EXPECT_CALL(*mockTransport, isReceiverValid(validIdentifier))
            .Times(1)
            .WillOnce(Return(true));

    EXPECT_CALL(*mockTransport, send(messageContent, validIdentifier))
            .Times(1)
            .WillOnce(Return(true));

    sendMessage.sendToValidReceivers(message);
}

Pierwsze wywołanie EXPECT_CALL mówi, że oczekujemy jednokrotnego wywołania metody isReceiverValid(...) z nieprawidłowym identifikatorem. Zaznaczamy również, że kiedy taki identyfikator przesyłamy, to ta metoda zwróci wartość false. Na tej wartości opiera się dalsze wykonanie metody. Na podstawie tej wartości zostaje podjęta decyzja, że metoda send(...) dla tego parametru nie zostanie wywołana (drugie wywołanie EXPECT_CALL).

Kolejne dwa wywołania makra EXPECT_CALL działają na podobnej zasadzie, co poprzednie. Jedyna różnica tutaj jest, że kiedy przesyłamy prawidłowy identyfikator, to zostaje podjęta decyzja o jednokrotnym wywołaniu metody send(...) z klasy transportującej.

To by było dzisiaj na tyle. Kodzik, którym posługiwałem się dzisiaj możecie znaleźć w repozytorium na GitHubie.

Podsumowanie

Dzisiejszy wpis był jedynie krótkim wprowadzeniem do idei mockowania na przykładzie biblioteki GoogleMock. Nauczyliśmy się dzisiaj w jaki sposób testować metody, które nic nie zwracają. Takie testy dają nam poczucie bezpieczeństwa w sytuacjach, kiedy go potrzebujemy.



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.



Podobne wpisy


Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności