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

Testy parametryzowane w GoogleTest


2019-11-26, 01:23

Początkowo planowałem o GoogleTest napisać tylko jeden wpis. Podczas pisania zauważyłem jednak, że tematyka pisania testów w C++ jest bardzo rozległa. Na tyle rozległa, że gdyby próbować wszystko opisać za jednym razem, to mało komu chciałoby się dotrzeć do końca wpisu. Zapraszam w takim razie na drugi post z serii o GoogleTest, w którym zajmiemy się testami parametryzowanymi.

Najlepiej zacząć od przykładu

O testach dobrze jest mówić na przykładzie. Spróbujmy w takim razie zaimplementować klasę, której metoda:

  • zamieni wszystkie znaki nowej linii oraz tabulacji na spacje
  • podmieni wszystkie mnogie wystąpienia spacji na pojedynczą spację
  • przytnie spacje z lewej i prawej strony

Jak nam wiadomo, testy nie służą tylko do pilnowania zmian w tworzonych przez nas implementacjach. Pisząc testy, możemy modelować zachowanie tworzonej przez nas implementacji. Jest to szczególnie przydatne, kiedy nie do końca wiemy, co chcemy osiągnąć.

Tak więc, wyraźmy nasze wymagania co do klasy testami:

#include <gtest/gtest.h>

#include <WhitespaceCleaner.hpp>

TEST(WhitespaceCleaner_StandardWay, test_trim_text_on_left) {
    std::string input = "    hello";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_trim_text_on_right) {
    std::string input = "hello    ";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_remove_two_spaces_in_middle) {
    std::string input = "hello  world";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello world", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_change_tab_into_space) {
    std::string input = "hello\tworld";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello world", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_change_two_tabs_into_single_space) {
    std::string input = "hello\t\tworld";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello world", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_change_new_line_into_space) {
    std::string input = "hello\nworld";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello world", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_change_two_new_lines_into_single_space) {
    std::string input = "hello\n\nworld";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello world", cleaner.getParsed());
}

Nasza klasa nie wygląda na skomplikowaną. Z powyższych testów wynika, że klasa ta może wyglądać tak:

#pragma once

#include <string>

class WhitespaceCleaner {

public:

    explicit WhitespaceCleaner(const std::string &input);

    std::string getParsed() const;
}

Czy macie pomysł na implementację? Zastanówcie się chwilę, zanim przejdziecie do następnego akapitu, może coś wymyślicie :)

Implementujemy naszą klasę

Co prawda, motywem przewodnim dzisiejszego wpisu są testy, ale myślę, że możemy wtrącić krótko implementacją testowanej klasy. Moja implementacja tej klasy wygląda następująco:

WhitespaceCleaner::WhitespaceCleaner(const std::string &input): input(input) {

}

std::string WhitespaceCleaner::getParsed() const {
    std::string input = changeWhitespacesIntoSpaces(this->input);
    input = removeMultipleSiblingSpaces(input);
    input = ltrim(input);
    input = rtrim(input);

    return input;
}

std::string WhitespaceCleaner::changeWhitespacesIntoSpaces(std::string input) const {
    std::replace(input.begin(), input.end(), '\t', ' ');
    std::replace(input.begin(), input.end(), '\n', ' ');
    return input;
}

std::string WhitespaceCleaner::removeMultipleSiblingSpaces(std::string input) const {
    std::string::iterator new_end = std::unique(input.begin(), input.end(), bothAreSpaces);
    input.erase(new_end, input.end());

    return input;
}

bool WhitespaceCleaner::bothAreSpaces(char lhs, char rhs) {
    return lhs == rhs && lhs == ' ';
}

std::string WhitespaceCleaner::ltrim(std::string input) const {
    input.erase(input.begin(), std::find_if(input.begin(), input.end(), [](int ch) {
        return !std::isspace(ch);
    }));

    return input;
}

std::string WhitespaceCleaner::rtrim(std::string input) const {
    input.erase(std::find_if(input.rbegin(), input.rend(), [](int ch) {
        return !std::isspace(ch);
    }).base(), input.end());

    return input;
}

Powyższy kod to jest tylko kilka wywołań metod pracujących na iteratorach opakowanych w klasę. Wewnątrz mamy cztery metody prywatne, każda z nich odpowiedzialna jest za inną operację. Zgodnie z dobrymi praktykami, nie powinno się kombinować z testowaniem prywatnych metod (praktyka #define private public to błędna droga). Już podczas modelowania założyliśmy, że naszą niepodzielną jednostką będzie metoda getParsed(). Jest jednak jedna rzecz, którą możemy poprawić w naszym kodzie, czym też teraz się zajmiemy :)

Poznajmy testy parametryzowane

Przyjrzyjmy się dwóm dowolnym testom, które przygotowaliśmy przed implementacją:

TEST(WhitespaceCleaner_StandardWay, test_trim_text_on_left) {
    std::string input = "    hello";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello", cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, test_trim_text_on_right) {
    std::string input = "hello    ";

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ("hello", cleaner.getParsed());
}

Czy widzicie tutaj jakiś wzór? Drobna podpowiedź poniżej:

TEST(WhitespaceCleaner_StandardWay, __NAME__) {
    std::string input = __INPUT__;

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ(__EXPECTED__, cleaner.getParsed());
}

TEST(WhitespaceCleaner_StandardWay, __NAME__) {
    std::string input = __INPUT__;

    WhitespaceCleaner cleaner(input);
    ASSERT_EQ(__EXPECTED__, cleaner.getParsed());
}

Teraz już na pewno widzicie. Obydwa scenariusze testowe są niemalże identyczne, jeżeli je odrobinę sparametryzujemy. Wróćcie na chwilę do listingu z kompletną listą testów i porównajcie, czy można by w podobny sposób zapisać i je.

Ok, jak już zauważyliście - wszystkie testy są napisane prawie “na jedno kopyto”. Okazuje się, że jest to dosyć częsty przypadek, który można wykonać w nieco inny sposób. Cały proces podzieliłem na kilka kroków:

  1. Tworzymy klasę Test Case dla naszych testów.
  2. Tworzymy test, który będzie uruchamiany dla różnego zestawu danych
  3. Inicjalizujemy nasz test przygotowanym przez nas zestawem danych

Punkt 1. Tworzymy Test Case

O tworzeniu Test Case pisałem w poprzednim wpisie. W przeciwieństwie do poprzedniej sytuacji, nasz case będzie się nieco różnił: będziemy dziedziczyli po innej klasie. Spójrzcie na przykład poniżej:

class WhitespaceCleaner : public ::testing::TestWithParam<std::string> {

};

Klasa, po której dziedziczymy przyjmuje parametr szablonowy, którym właśnie możemy sterować podczas wykonania. Ten parametr może być dowolnego typu - od nas zależy, jakiego typu informacje będziemy chcieli przekazać do testów. Jest jednak jeden myk, który potrzebujemy tutaj zrobić, aby nasz przykład zadziałał tak, jak trzeba. Mianowicie, potrzebujemy dorzucić drugi parametr (o ile __NAME__ nie jest istotnym parametrem testu, to zarówno __INPUT__ jak i __EXPECTED__ są dla nas ważne).

Niestety, wrzucenie nowego parametru nie działa tak, jak prawdopodobnie pomyśli większość z Was - nie dorzucamy kolejnego parametru szablonowego. Zamiast tego, powinniśmy utworzyć strukturę z interesującą nas ilością pól, a następnie tą strukturę przekazać jako parametr. O tak:

struct TestParameters {
    std::string input;
    std::string result;
};

class WhitespaceCleaner : public ::testing::TestWithParam<TestParameters> {

};

Mamy już Test Case, więc teraz możemy przejść do kroku numer 2.

Punkt 2. Test polegający na parametrze

Do tworzenia testów opartych o wspólny Test Case używaliśmy makra TEST_F. Jak można się spodziewać, do zdefiniowania testu parametryzowanego będziemy korzystać z makra TEST_P. Poniżej przykład użycia testu:

TEST_P(WhitespaceCleaner, test_clearing_whitespaces) {
    TestParameters const& parameters = GetParam();
    WhitespaceCleaner cleaner(parameters.input);
    ASSERT_EQ(parameters.result, cleaner.getParsed());
}

Parametry makra tak jak w TEST_F: pierwszy to nazwa Test Case, drugi to nazwa testowanego przypadku.

Ciekawą rzeczą tutaj jest wywołanie metody GetParam(). To ona zwraca nam każdorazowo zestaw danych testowych, czyli nasz tytułowy parametr (a w naszym przypadku strukturę parametryzującą). Ten test już w swojej konstrukcji przypomina naszą wcześniejszą konstrukcję z parametrami __INPUT__ oraz __EXPECTED__.

Mamy test, mamy parametry, jest też asercja. Możemy przejść do ostatniego punktu.

Punkt 3. Uruchomienie testu z zestawem parametrów

Ostatnim krokiem jest zdefiniowanie wywołań utworzonego testu wraz z parametrami. Do tego celu wykorzystujemy makro INSTANTIATE_TEST_CASE_P(). Makro to przyjmuje trzy parametry:

  • Nazwa testowanego przypadku
  • Nazwa Test Case
  • Parametry wywołania testu

Wywołanie tego makra wygląda mniej więcej tak:

INSTANTIATE_TEST_CASE_P(TestSpaces, WhitespaceCleaner, ::testing::Values(
    TestParameters{"    hello", "hello"},
    TestParameters{"hello    ", "hello"},
    TestParameters{"hello  world", "hello world"}
));

Trzeci parametr to zestaw naszych parametrów testowych. Gdybyśmy w punkcie nr 1 zdefiniowali parametr typu int, przekazalibyśmy tutaj zestaw liczb całkowitych. U nas jednak jest to struktura, dlatego tworzymy jej instancje.

Warto by teraz zadać pytanie: Do czego może nam przydać się pierwszy parametr? Odpowiedź jest prosta: służy on lepszemu kategoryzowaniu zestawów testowych. Możemy przecież wydzielić sobie scenariusze testowania wycinania spacji od wycinania znaków specjalnych. Na przykład tak:

INSTANTIATE_TEST_CASE_P(TestSpaces, WhitespaceCleaner, ::testing::Values(
    TestParameters{"    hello", "hello"},
    TestParameters{"hello    ", "hello"},
    TestParameters{"hello  world", "hello world"}
));

INSTANTIATE_TEST_CASE_P(TestTabs, WhitespaceCleaner, ::testing::Values(
    TestParameters{"hello\tworld", "hello world"},
    TestParameters{"hello\t\tworld", "hello world"}
));

INSTANTIATE_TEST_CASE_P(TestNewLines, WhitespaceCleaner, ::testing::Values(
    TestParameters{"hello\nworld", "hello world"},
    TestParameters{"hello\n\nworld", "hello world"}
));

Nie dość, że czytelniej jest dla nas, to jeszcze wygenerowany zostanie ładniejsze wyjście w lini poleceń.

Uwaga na ograniczenie!

Wszystkie wpisy na temat GoogleTest dotyczą wersji 1.8, ponieważ jest to najwyższa wersja dostępna w repozytorium Conana. Najnowsza dostępna wersja tej biblioteki natomiast to 1.10. W kontekście dzisiejszego wpisu jest jedna różnica, którą powinienem omówić: ograniczenie co do ilości zestawów danych testowych per każde użycie makra INSTANTIATE_TEST_CASE_P(). Ze względu na implementację biblioteki w wersji 1.8, zostajemy ograniczeni do liczby 50 zestawów testowych jednocześnie. Za chwilę wyjaśnimy sobie, z czego to ograniczenie wynika, jednakże najpierw chciałbym wygłosić swoje osobiste zdanie na ten temat.

Osobiście uważam, że jeżeli nie mieścimy się w tym limicie, to jest coś nie tak z naszej strony. Nasz proces kategoryzowania danych testowych może być trochę zaburzony. Dlatego zamiast rozpaczać na temat tego ograniczenia, lepiej jest wyciągnąć z niego to co najlepsze i lepiej podzielić swoje scenariusze i dane testowe.

Z czego to wynika?

Implementacja testów parametryzowanych w wersji 1.8 nie należała do najlepszej. Część biblioteki w tej wersji korzysta z preprocesora, który pomagał w wygenerowaniu niewygodnych kawałków kodu, takich jak metody z… 50 parametrami :)

Dzięki takiemu kawałkowi kodu:

$range i 1..n
$for i [[
$range j 1..i

template <$for j, [[typename T$j]]>
internal::ValueArray$i<$for j, [[T$j]]> Values($for j, [[T$j v$j]]) {
  return internal::ValueArray$i<$for j, [[T$j]]>($for j, [[v$j]]);
}

]]

mogliśmy podziwiać takie kwiatki jak:

template <typename T1, typename T2, typename T3, typename T4, typename T5,
    typename T6, typename T7, typename T8, typename T9, typename T10,
    typename T11, typename T12, typename T13, typename T14, typename T15,
    typename T16, typename T17, typename T18, typename T19, typename T20,
    typename T21, typename T22, typename T23, typename T24, typename T25,
    typename T26, typename T27, typename T28, typename T29, typename T30,
    typename T31, typename T32, typename T33, typename T34, typename T35,
    typename T36, typename T37, typename T38, typename T39, typename T40,
    typename T41, typename T42, typename T43, typename T44, typename T45,
    typename T46, typename T47, typename T48, typename T49, typename T50>
internal::ValueArray50<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13,
    T14, T15, T16, T17, T18, T19, T20, T21, T22, T23, T24, T25, T26, T27, T28,
    T29, T30, T31, T32, T33, T34, T35, T36, T37, T38, T39, T40, T41, T42, T43,
    T44, T45, T46, T47, T48, T49, T50> Values(T1 v1, T2 v2, T3 v3, T4 v4,
    T5 v5, T6 v6, T7 v7, T8 v8, T9 v9, T10 v10, T11 v11, T12 v12, T13 v13,
    T14 v14, T15 v15, T16 v16, T17 v17, T18 v18, T19 v19, T20 v20, T21 v21,
    T22 v22, T23 v23, T24 v24, T25 v25, T26 v26, T27 v27, T28 v28, T29 v29,
    T30 v30, T31 v31, T32 v32, T33 v33, T34 v34, T35 v35, T36 v36, T37 v37,
    T38 v38, T39 v39, T40 v40, T41 v41, T42 v42, T43 v43, T44 v44, T45 v45,
    T46 v46, T47 v47, T48 v48, T49 v49, T50 v50) {
  return internal::ValueArray50<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11,
      T12, T13, T14, T15, T16, T17, T18, T19, T20, T21, T22, T23, T24, T25,
      T26, T27, T28, T29, T30, T31, T32, T33, T34, T35, T36, T37, T38, T39,
      T40, T41, T42, T43, T44, T45, T46, T47, T48, T49, T50>(v1, v2, v3, v4,
      v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17, v18, v19,
      v20, v21, v22, v23, v24, v25, v26, v27, v28, v29, v30, v31, v32, v33,
      v34, v35, v36, v37, v38, v39, v40, v41, v42, v43, v44, v45, v46, v47,
      v48, v49, v50);
}

Tak tak, to metoda z 50-cioma parametrami szablonowymi :) To właśnie przez to rozwiązanie do wersji 1.8 włącznie musimy żyć z maksymalną liczbą 50 parametrów. Jeżeli jednak z jakiegokolwiek powodu to rozwiązanie nam nie odpowiada (ja osobiście mogę z tym żyć), to…

…z pomocą przychodzi variadic template!

W najnowszej wersji biblioteki GoogleTest mamy o wiele lepszą implementację testów parametryzowanych. Twórcy biblioteki porzucili stara koncepty i zasięgnęli do standardu C++11, który serwuje nam funkcjonalność variadic templates, dzięki którym możemy tworzyć funkcje o nieskończonej liczbie parametrów. Dla potomnych wrzucam link do commita zmieniającego tą implementację.

Teraz możemy się cieszyć krótkim i zwięzłym kawałkiem kodu:

template <typename... T>
internal::ValueArray<T...> Values(T... v) {
  return internal::ValueArray<T...>(std::move(v)...);
}

Dzięki temu prostemu trickowi możemy cieszyć się brakiem jakichkolwiek limitów¹.

Niestety, wszyscy korzystający z GoogleTest za pomocą menadżera Conan (w tym ja) są zmuszeni poczekać, aż ta wersja biblioteki pojawi się w jego repozytorium.

Źródła na GitHubie

Oczywiście, wszystkie źródełka do tego wpisu zamieściłem na GitHubie pod adresem https://github.com/CppPolska/testy-parametryzowane-w-googletest. Zachęcam Was do ściągnięcia i przetestowania testów w praktyce.

Podsumowanie

Dotarliśmy do końca! :) Teraz już nie tylko potrafimy tworzyć zintegrowane ze sobą testy. Właśnie nauczyliśmy się, w jaki sposób za pomocą pojedynczego testu możemy przetestować wiele scenariuszy. Wszystkich tych, którzy dopiero zapoznali się z testami parametryzowanymi zachęcam do przeglądnięcia swoich testów - może znajdą się u Was testy, które warto zrefaktoryzować do postaci testów parametryzowanych? :)


¹ - limity oczywiście istnieją, natomiast nie ograniczają już nas w takim stopniu jak wcześniej.



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