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

Praktyczne przykłady wykorzystania GoogleMock


2020-10-22, 00:22

W poprzednim wpisie wyjaśniłem podstawowe pojęcia, którymi operujemy podczas korzystania z GoogleMock. Dotknęliśmy tego tematu w sposób wyłącznie teoretyczny. Tematem dzisiejszego wpisu jest podjęcie kilku praktycznych przykładów wykorzystania zdobytej wcześniej wiedzy.

Zestaw Return & Throw

Słynna zasada testów jednostkowych mówi, że testować powinniśmy kontrakt, który definiuje określona funkcja/metoda. Oznacza to, że przedmiotem naszych testów powinny być przede wszystkim: przekazywane parametry, zwracane wartości oraz wyrzucane wyjątki. Takie podejście jest bardzo przydatne przy testowaniu generatorów, kalkulatorów oraz wszelkiej maści walidatorów. Do samego testowania takiej metody posłuży nam GoogleTest (omawiany szerzej tutaj).

Co jednak, kiedy zamiast testować samą metodę generującą (która wierzymy, że jest gdzieś przetestowana osobno), to tym razem chcemy przetestować kod, który uruchamia tą metodę? W takiej sytuacji idealnie przydałaby się nam kontrola wartości zwracanej przez tą metodę, jak i możliwość wyrzucania wyjątków z jej wnętrza. Rozważmy zatem następujący kod:

std::string UsernameGenerator::generate(std::string firstName, std::string lastName) const {
    // full algorithm here...
}

Zakładamy, że mamy gdzieś w systemie metodę tworzącą nazwę użytkownika, która składa się z trzech pierwszych liter imienia, następnie trzech pierwszych liter nazwiska oraz krótkiego, losowego ciągu cyfr. Zatem naszym wymaganiem będzie imię oraz nazwisko o długości conajmniej trzech znaków. Sytuację wyjątkową tego algorytmu osiągniemy więc wtedy, kiedy długość imienia albo nazwiska będzie krótsza niż trzy znaki.

Przeanalizujmy również kod fabryki, która uruchamia nasz generator:

class UsernameGeneratorInterface {

public:

    virtual std::string generate(std::string firstName, std::string lastName) = 0;
};


class CustomerFactory {

private:

    std::shared_ptr<UsernameGeneratorInterface> usernameGenerator;

public:

    explicit CustomerFactory(
        std::shared_ptr<UsernameGeneratorInterface> usernameGenerator
    ) : usernameGenerator(std::move(usernameGenerator)) {
    }

    Customer create(
        std::string firstName,
        std::string lastName,
        std::string emailAddress,
        std::string password
    ) {
        std::string username = this->usernameGenerator->generate(
            firstName, lastName
        );

        return Customer(
            username, firstName, lastName, emailAddress, password
        );
    }
};

PS. jeżeli zastanawiacie się, dlaczego korzystamy z funkcji czysto wirtualnych, to koniecznie przeczytajcie ten wpis.


Aby móc w pełni przetestować metodę CustomerFactory::create(...), musimy do jej konstruktora przekazać zamockowany obiekt rozszerzający klasę UsernameGeneratorInterface. Do przetestowania będziemy mieli conajmniej dwa scenariusze:

  • metoda UsernameGeneratorInterface::generate(...) zwróci wartość
  • metoda UsernameGeneratorInterface::generate(...) wyrzuci wyjątek

Zatem, do dzieła!

Tworzymy naszą klasę mockującą:

class UsernameGeneratorMock : public UsernameGeneratorInterface {

public:

    MOCK_METHOD2(generate, std::string(std::string, std::string));

};

Mamy do zamockowania jedną metodę pobierającą dwa parametry (stąd MOCK_METHOD2, więcej informacji tutaj). Naszą klasę dziedziczymy po klasie UsernameGeneratorInterface, która wygląda następująco:

class UsernameGeneratorInterface {

public:

    virtual std::string generate(std::string firstName, std::string lastName) = 0;
}

Mamy przygotowaną klasę mocka, zatem możemy przejść do testów:

TEST(CustomerFactoryTest, test_returning_customer) {
    std::string dummyName = "genuse1823";

    auto generatorMock = std::make_shared<UsernameGeneratorMock>();
    EXPECT_CALL(*generatorMock, generate(_, _))
            .WillOnce(Return(dummyName));

    CustomerFactory factory(generatorMock);
    auto customer = factory.create("John", "Doe", "john@doe.com", "secret");

    ASSERT_EQ(customer.getUsername(), dummyName);
}

Pierwszy test sprawdza nam scenariusz, podczas którego wygenerowana zostaje poprawna nazwa użytkownika. Jak możemy zauważyć, kod jest całkiem prosty i składa się z kilku części:

  • Tworzymy obiekt mocka
  • Definiujemy oczekiwanie, W tym wypadku oczekujemy wywołania metody generate(...), która jednorazowo zwróci nazwę użytkownika
  • Tworzymy obiekt testowanej klasy, przekazując do jej konstruktora obiekt mocka.
  • Uruchamiamy testowaną logikę
  • Przeprowadzamy assercje na wyniku wywołanej metody

Sami musicie przyznać, że nie wygląda to na nic skomplikowanego. Następnym scenariuszem wartym przetestowania będzie sprawdzenie, co stanie się, kiedy nasza mockowana metoda wyrzuci wyjątek:

TEST(CustomerFactoryTest, test_throwing_exception) {
    std::string dummyName = "genuse1823";

    auto generatorMock = std::make_shared<UsernameGeneratorMock>();
    EXPECT_CALL(*generatorMock, generate(_, _))
            .WillOnce(Throw(NameIsTooShortException("Name 'John' is too short")));

    CustomerFactory factory(generatorMock);
    ASSERT_THROW(factory.create("John", "Doe", "john@doe.com", "secret"), NameIsTooShortException);
}

W tym wypadku nasza mockowana metoda wyrzuca wyjątek klasy NameIsTooShortException. Ponieważ nigdzie go nie przechwytujemy, zostaje on wyrzucony na zewnątrz testowanej metody fabryki. A skoro zostaje on wyrzucony na zewnątrz, to możemy zrobić assercję, która to potwierdzi.

Możemy również odrobinę zmodyfikować nasz kod tak, aby zamiast wyrzuconego wyjątku była generowana zupełnie losowa nazwa użytkownika. Na przykład tak:

class CustomerFactory {

private:

    std::shared_ptr<UsernameGeneratorInterface> usernameGenerator;

public:

    explicit CustomerFactory(
        std::shared_ptr<UsernameGeneratorInterface> usernameGenerator
    ) : usernameGenerator(std::move(usernameGenerator)) {
    }


    Customer create(
        std::string firstName,
        std::string lastName,
        std::string emailAddress,
        std::string password
    ) {
        std::string username;
        try {
            username = this->usernameGenerator->generate(firstName, lastName);
        } catch (NameIsTooShortException &e) {
            username = this->usernameGenerator->generateRandom();
        }

        return Customer(
            username, firstName, lastName, emailAddress, password
        );
    }
};

Zaktualizujemy zatem naszą klasę generującą nazwę użytkownika:

class UsernameGeneratorInterface {

public:

    virtual std::string generate(std::string firstName, std::string lastName) = 0;
    virtual std::string generateRandom() = 0;
};

Aktualizujemy również klasę-mock:

class UsernameGeneratorMock : public UsernameGeneratorInterface {

public:

    MOCK_METHOD2(generate, std::string(std::string, std::string));
    MOCK_METHOD0(generateRandom, std::string());

};

Możemy dzięki temu napisać nieco ładniejszy test:

TEST(CustomerFactoryTest, test_returning_customer) {
    std::string dummyName = "genuse1823";
    std::string generatedName = "autoUser123";

    auto generatorMock = std::make_shared<UsernameGeneratorMock>();
    EXPECT_CALL(*mockObject, generate(_, _))
            .WillOnce(Throw(NameIsTooShortException("Name 'John' is too short")));

    EXPECT_CALL(*mockObject, generateRandom())
            .WillOnce(Return(generatedName));

    CustomerFactory factory(generatorMock);
    auto customer = factory.create("John", "Doe", "john@doe.com", "secret");

    ASSERT_EQ(customer.getUsername(), generatedName);
}

Nieco się tutaj zmieniło, więc już biegnę z wyjaśnieniami:

  • Dorzuciliśmy oczekiwanie na wywołanie metody generateRandom() na obiekcie mocka, która zwróci nam wartość zmiennej generatedName
  • W dalszym ciągu metoda generate(...) wyrzuca wyjątek klasy NameIsTooShortException
  • Tym razem zamiast oczekiwać, że testowana przez nas metoda wyrzuci wyjątek, wymagamy, aby zwrócony obiekt klasy Customer metoda getUsername() zwracała wartość otrzymaną przez metodę generateRandom() (porównujemy wartość metody getUsername() z wartością zmiennej generatedName)\

Pierwszy przykład za nami, lecimy dalej! :)

Liczenie wywołań metody z Invoke

Wyobraźmy sobie, że tworzymy system kolejkowy (nie wiem jak u Was, ale u mnie ostatnio takowe są bardzo na topie :)). Każdy system kolejkowy posiada mechanizm publikujący wiadomość na kolejkę oraz konsumujący ją (niekoniecznie na tym samym serwerze, na którym została wysłana).

Skupmy się nieco na mechanizmie konsumującym. Każda wiadomość powinna zostać przez mechanizm konsumujący przetworzona. Jednak w nietrywialnych systemach, które mogą komunikować się z innymi systemami, coś może się wysypać; przykładowo, podczas konsumpcji odpytujemy się do zewnętrznego serwera o wymagane dane. Serwer, jak to serwer, może nie działać. Zatem wiadomość, która do bycia przetworzoną potrzebuje danych zewnętrznych, zostaje oznaczona jako nieprzetworzona i przekazana do ponownego przetworzenia później. I właśnie ten scenariusz będzie nas w chwili obecnej bardzo interesował :)

Załóżmy, że tworzymy wspomniany wyżej system kolejkowy, w którym wymagamy, aby wiadomość została przetworzona maksymalnie trzy razy. Po jej trzecim nieudanym przetworzeniu system powinien ją odrzucić (i gdzieś zalogować). Naszym zadaniem będzie napisać testy, które potwierdzą nam, że wszystko działa tak, jak sobie zaplanowaliśmy :)

Przeanalizujmy zatem poniższy kod:

class MessageDispatcher {

private:

    std::shared_ptr<DispatcherInterface> dispatcher;
    std::shared_ptr<LoggerInterface> logger;

public:

    MessageDispatcher(
            std::shared_ptr<DispatcherInterface> dispatcher,
            std::shared_ptr<LoggerInterface> logger
    ) : logger(std::move(logger)), dispatcher(std::move(dispatcher)) {

    }

    void dispatch(Message message) {
        bool dispatched = false;
        int dispatchCount = 0;
        do {
            dispatched = dispatcher->performDispatch(message);
            dispatchCount++;
        } while (!dispatched && dispatchCount < 3);

        if (!dispatched) {
            logger->error(std::string(
                "Message cannot be dispatched: " + message.serialize()
            ));
        }
    }
};

Kodzik na tyle prosty, że chyba nie trzeba za wiele tłumaczyć. Naszym głównym zadaniem jest sporządzić zestaw testów, które dadzą nam pewność, że:

  • metoda performDispatch(...) zostanie wykonana maksymalnie trzy razy
  • w którymkolwiek momencie, kiedy metoda performDispatch(...) zwróci wartość true, logger nie zaloguje nic
  • przy trzeciej zwrotce wartości false z metody performDispatch(...) zostanie zalogowana informacja przez obiekt loggera

Z moich wyliczeń wychodzi, że satysfakcjonować będą nas dwa scenariusze:

TEST(MessageDispatcher, test_logger_logs_on_3_negative_dispatches) {
    // ...
}

TEST(MessageDispatcher, test_logger_doesnt_log_when_only_2_negative_dispatches) {
    // ...
}

Już teraz powinna Wam się zapalić lampka, że tych scenariuszy powinno być nieco więcej. Moim zadaniem nie jest przygotowanie w pełni przetestowanego kodu - chciałbym skupić się na przedstawieniu konkretnych mechanik. Skoro to już mamy wyjaśnione, to możemy lecieć z tematem. Zacznijmy od mocków:

class DispatcherMock : public DispatcherInterface {

public:

    MOCK_METHOD1(dispatch, bool(Message));

};

class LoggerMock : public LoggerInterface {

public:

    MOCK_METHOD1(error, void(std::string));

};

Jak widać, do konstruktora naszej klasy trafiły dwie zależności, więc tyle klas-mocków będziemy potrzebować. Dalej mamy pierwszy scenariusz testowy:

TEST(MessageDispatcher, test_logger_logs_on_3_negative_dispatches) {
    auto dispatcherMock = std::make_shared<DispatcherMock>();
    auto loggerMock = std::make_shared<LoggerMock>();

    ON_CALL(*dispatcherMock, performDispatch(_))
        .WillByDefault(Return(false));

    EXPECT_CALL(*loggerMock, error(StartsWith("Message cannot be dispatched: ")))
        .Times(1);

    MessageDispatcher dispatcher(dispatcherMock, loggerMock);
    dispatcher.dispatch(Message("Hello world"));
}

Logika powyższego kodu jest bardzo prosta: w miejscu, w którym wykorzystujemy makro ON_CALL mówimy frameworkowi, że za każdym razem, kiedy zostanie wywołana metoda performDispath(...) (dla dowolnego parametru), zwróci ona wartość false. Następnie oczekujemy, że na mocku loggerMock zostanie wywołana jeden raz metoda error(...) z parametrem, którego wartość rozpoczyna się od zadanego przez nas ciągu znakowego.

Przejdźmy teraz do drugiego scenariusza:

TEST(MessageDispatcher, test_logger_doesnt_log_when_only_2_negative_dispatches) {
    int invokeCounter = 0;
    auto incrementFunction = [&invokeCounter](auto message) {
        invokeCounter++;
    };

    auto dispatcherMock = std::make_shared<DispatcherMock>();
    auto loggerMock = std::make_shared<LoggerMock>();

    EXPECT_CALL(*dispatcherMock, performDispatch(_))
        .WillOnce(Return(false))
        .WillOnce(Return(false))
        .WillOnce(Return(true));

    ON_CALL(*loggerMock, error(StartsWith("Message cannot be dispatched: ")))
            .WillByDefault(Invoke(incrementFunction));

    MessageDispatcher dispatcher(dispatcherMock, loggerMock);
    dispatcher.dispatch(Message("Hello world"));

    EXPECT_EQ(invokeCounter, 0);
}

Mamy nasz zapowiedziany Invoke(...)! :) I mamy tutaj kilka dodatkowych linijek, które wymagają wyjaśnienia.

Linijka z makrem EXPECT_CALL informuje GoogleMock, że oczekujemy na trzykrotne wykonanie metody performDispatch na obiekcie dispatcherMock. Przy pierwszych dwóch wywołaniach metoda ta zwróci wartość false, jednak przy trzecim wywołaniu zwrócona zostanie wartość true. Oznacza to, że po drobnych problemach nasza wiadomość zostaje przetworzona. Dodatkowo definiujemy, że wraz z każdym wywołaniem metody error(...) na obiekcie loggerMock ma zostać uruchomiona funkcja (w tym wypadku anonimowa), która inkrementuje wyzerowany wcześniej licznik. Zależy nam, aby każde wywołanie metody logującej było równoznaczne z inkrementacją tego licznika. Na końcu testu sprawdzamy, czy wartość licznika wynosi zero, ponieważ w tym wypadku logger nie powinien nic logować.

Tak jak wspomniałem, w naszym przypadku te dwa scenariusze testowe wystarczą. Gdybym jednak tworzył kod produkcyjny, to na pewno sprawdziłbym jeszcze scenariusz z natychmiastową obsługą wiadomości oraz taki, kiedy wiadomość zostaje przetworzona za drugim razem.

Sockety… sockety wszędzie!

Przed nami ostatni, trzeci przykład praktycznego wykorzystania GoogleMock. Tym razem dotkniemy tematu związanego z testowaniem biblioteki systemowej - dokładniej, obsługi socketów. Dla tych, którzy nie wiedzą, czym jest socket: jest to sposób na komunikację międzysieciową. Jedyne, co powinniśmy na tą chwilę wiedzieć to to, że do socketu możemy się połączyć.

Rozważmy taki zestaw klas:

class SocketResource {

};

class SocketResourceFactoryInterface {

public:

    virtual SocketResource create(std::string host, std::string port) = 0;

};

class SocketConnection {

private:

    std::shared_ptr<SocketResourceFactoryInterface> resourceFactory;

    SocketResource resource;
    std::string errorMessage;

public:

    explicit SocketConnection(
        std::shared_ptr<SocketResourceFactoryInterface> resourceFactory
    ): resourceFactory(std::move(resourceFactory)) {}

    bool connect(std::string host, std::string port) {
        resource = resourceFactory->create(host, port);

        if (errno) {
            switch (errno) {
                case ETIMEDOUT: {
                    errorMessage = "Connection timeout";
                } break;
                case ECONNREFUSED: {
                    errorMessage = "Connection refused";
                } break;
                case EAUTH: {
                    errorMessage = "Authentication error";
                } break;
                default: {
                    errorMessage = "Unexpected error";
                }
            }
            return false;
        }

        return true;
    }

    std::string getErrorMessage() const {
        return errorMessage;
    }
};

Jest to bardzo, bardzo bardzo przykładowy kodzik, który ma służyć obsłużeniu logiki podłączenia się do socketu. Dosyć wybrakowany przykład (ale właśnie tak ma być). W moim założeniu klasa ResourceFactoryInterface posiada metodę create(...), wewnątrz której fizycznie zostaje wywołany zestaw funkcji systemowych takich jak getaddrinfo(), socket() oraz bind().

Biblioteki systemowe mają to do siebie, że bardzo często w momencie niepowodzenia ustawiają kod błędu do globalnej zmiennej errno (znajdującej się w nagłówku errno.h). Zatem po wywołaniu metody create(...) sprawdzamy, co obecnie znajduje się w zmiennej errno. Jeżeli którakolwiek z funkcji systemowych zwróciła błąd, to tworzymy odpowiedni komunikat wewnątrz naszej klasy i zwracamy false. W przypadku, kiedy nie ma żadnego błędu, zwracamy wartość true.

Ufff, mam nadzieję, że nie namieszałem zbyt wiele :) Popatrzcie zatem jeszcze raz na kod i pomyślcie, co możemy tutaj przetestować :) A ja standardowo, w tak zwanym międzyczasie, wrzucam przygotowany mock dla klasy SocketResourceFactoryInterface:

class SocketResourceFactoryMock : public SocketResourceFactoryInterface {

public:

    MOCK_METHOD2(create, SocketResource(std::string, std::string));

};

Nic ciekawego tutaj się nie dzieje. Mamy metodę, która przyjmuje dwa parametry typu std::string oraz zwraca typ SocketResource.

To jak, macie już jakieś przypadki do przetestowania? Moje typy są następujące:

TEST(SocketResourceFactory, test_resource_created_successfully) {

}

TEST(SocketResourceFactory, test_connection_timeout) {

}

TEST(SocketResourceFactory, test_connection_refused) {

}

TEST(SocketResourceFactory, test_authentication_error) {

}

TEST(SocketResourceFactory, test_unexpected_error) {

}

Łącznie 5 scenariuszy. Moim zdaniem wystarczy sprawdzić przypadek, kiedy połączyliśmy się oraz wszystkie cztery przypadki z komunikatami o błędzie. Test z sukcesywnym połączeniem wygląda następująco:

TEST(SocketResourceFactory, test_resource_created_successfully) {
    auto socketFactoryMock = std::make_shared<SocketResourceFactoryMock>();

    EXPECT_CALL(*socketFactoryMock, create(_, _))
            .WillOnce(SetErrnoAndReturn(0, SocketResource()));

    SocketConnection connection(socketFactoryMock);

    ASSERT_TRUE(connection.connect("http://example.com", "80"));
    ASSERT_EQ(connection.getErrorMessage(), "");
}

W tym scenariuszu korzystamy z akcji SetErrnoAndReturn(...), która przyjmuje dwa parametry. Jako pierwszy parametr przekazujemy wartość, na którą ma zostać ustawiona zmienna errno. W przypadku sukcesu będzie to zero. Drugi parametr to wartość, która zostaje zwrócona przez naszą metodę.

To, co sprawdzam, to zwrotka metody connect(...) oraz komunikat błędu, który powinien być w tym przypadku pusty. Scenariusze związane z komunikatami o błędach będą bardzo podobne, dlatego publikuję je wszystkie w jednym listingu:

TEST(SocketResourceFactory, test_connection_timeout) {
    auto socketFactoryMock = std::make_shared<SocketResourceFactoryMock>();

    EXPECT_CALL(*socketFactoryMock, create(_, _))
            .WillOnce(SetErrnoAndReturn(ETIMEDOUT, SocketResource()));

    SocketConnection connection(socketFactoryMock);

    ASSERT_FALSE(connection.connect("http://example.com", "80"));
    ASSERT_EQ(connection.getErrorMessage(), "Connection timeout");
}

TEST(SocketResourceFactory, test_connection_refused) {
    auto socketFactoryMock = std::make_shared<SocketResourceFactoryMock>();

    EXPECT_CALL(*socketFactoryMock, create(_, _))
            .WillOnce(SetErrnoAndReturn(ECONNREFUSED, SocketResource()));

    SocketConnection connection(socketFactoryMock);

    ASSERT_FALSE(connection.connect("http://example.com", "80"));
    ASSERT_EQ(connection.getErrorMessage(), "Connection refused");
}

TEST(SocketResourceFactory, test_authentication_error) {
    auto socketFactoryMock = std::make_shared<SocketResourceFactoryMock>();

    EXPECT_CALL(*socketFactoryMock, create(_, _))
            .WillOnce(SetErrnoAndReturn(EAUTH, SocketResource()));

    SocketConnection connection(socketFactoryMock);

    ASSERT_FALSE(connection.connect("http://example.com", "80"));
    ASSERT_EQ(connection.getErrorMessage(), "Authentication error");
}

TEST(SocketResourceFactory, test_unexpected_error) {
    auto socketFactoryMock = std::make_shared<SocketResourceFactoryMock>();

    EXPECT_CALL(*socketFactoryMock, create(_, _))
            .WillOnce(SetErrnoAndReturn(ECONNABORTED, SocketResource()));

    SocketConnection connection(socketFactoryMock);

    ASSERT_FALSE(connection.connect("http://example.com", "80"));
    ASSERT_EQ(connection.getErrorMessage(), "Unexpected error");
}

Nie ma co oszukiwać, wszystkie te scenariusze są również podobne do pierwszego. Różnica jest taka, że metoda connect(...) ma zwrócić false, a getErrorMessage() zwróci odpowiedni komunikat o błędzie. Dla wartości ECONNABORTED, która jest pomijana przez naszą klasę, komunikatem powinno być "Unexpected error". W mojej opinii nie ma sensu sprawdzać wszystkich wartości, które są zwracane przez sockety, a nie są przez nas obsługiwane.

Podsumowanie

Dobrnęliśmy do końca tego nieco długawego wpisu. Przykładów wykorzystania GoogleMock byłoby więcej, ale chyba dłuższego wpisu nikomu nie chciałoby się czytać :)

Dzisiaj omówiliśmy trzy praktyczne przykłady na wykorzystanie GoogleMock; przez dzisiejszy wpis przewinęły się akcje takie jak Return, Throw, Invoke, oraz SetErrnoAndReturn. Przykłady zostały przeze mnie dobrane w taki sposób, by móc przedstawić koncept każdej z wybranych akcji.

Całość kodu omawiana w dzisiejszym wpisie jest dostępna na GitHubie.


Bibliografia:



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