Framework do mockowania: gmock i podobne vs. FSeam

1

Do pisania testów jednostkowych/modułowych w C++ potrzeba 2 rzeczy:

  • framework do testów
  • framework do mockowania zależności testowanej klasy.

Spośród wielu projektów, które widziałem, większość używa zestawu GoogleTest + GoogleMock. Z frameworków do testowania kiedyś popularny był Boost.Test (ktoś używał? czemu gtest go wyparł?), teraz na popularności zyskuje catch2. Ja jednak chcę się skupić na frameworku do mockowania. gmock opiera się na tym samym patencie co javowe mockito, z tym że w c++ wygląda to tak:

  • jest sobie interfejs IFoo z metodami wirtualnymi, na przykład virtual void aaa() = 0;
  • jest sobie klasa Foo dziedzicząca po tym interfejsie, która implementuje tę metodę: void aaa() override;
  • jest sobie mock FooMock, który również implementuje tą metodę w sobie potrzebny sposób. gmock robi to za pomocą makr: MOCK_METHOD0(aaa, void());
  • klasa Bar dostaje w konstruktorze wstrzyknięto zależność na interfejs IFoo (referencja/wskaźnik)
    • w kodzie produkcyjnym wstrzykujemy Foo
    • w kodzie testowym wstrzykujemy FooMock i to na interakcjach testowanej klasy z nim opieramy wynik testu

Jednak ma to duży minus: większość klasy projektu ma interfejsy i używa dynamicznego polimorfizmu tam gdzie w rzeczywistości nie musi tylko po to, aby kod był testowalny. A w c++ to kosztuje.

Innym podejściem jest używanie szablonów zamiast dziedziczenia. Klasa Bar wyglądałaby wtedy tak:

template <class FooType>
class Bar
{
//(...)
private:
FooType foo;
};

Kod produkcyjny:

Bar<Foo> bar;

Kod testowy:

Bar<FooMock> sut;

FooMock i Foo nie muszą dziedziczyć po wspólnej klasie.

Nie stosowałem tego podejścia, ale domyślam się, że największe minusy to: cały kod klasy w headerze (w końcu szablon), ogólna "szablonoza" projektu, potrzeba pisania getterów tylko po to, aby w teście dostać się do mocka.

Ostatnio odkryłem istnienie biblioteki FSeam (link, link). Przyznam, że nie używałem jeszcze, ale z tego co doczytałem, to jej podejście jest inne: podmienia zależności na etapie linkowania:

  • jest sobie header Foo.hpp z deklaracją klasy i deklaracjami jej metod
  • jest sobie plik z kodem: Foo.cpp z definicjami metod. Kompilowany do object file'u Foo.cpp.o
  • jest sobie wygenerowany przez fseam plik z alternatywnymi, mockowymi definicjami metod. Kompilowany do object file'u, nazwijmy go umownie, FooMock.cpp.o
  • jest sobie klasa Bar includująca normalnie Foo.hpp i mająca pole klasy Foo. Object file: Bar.cpp.o
  • kod produkcyjny linkuje się z Foo.cpp.o
  • kod testowy linkuje się z FooMock.cpp.o

Dodatkowy wysiłek jaki trzeba włożyć to:

  • odpalanie generatora testowych implementacji
  • zabawa na poziomie plików cmake'owych

Z niewątpliwych atutów, to można pisać testy legacy code'u kompletnie bez jego zmieniania.

Teraz główne pytania:

  • czy ktoś używał tej biblioteki, albo innej korzystającaej z "link seam"? jakie ogólne wrażenia?
  • czy to podejście jest wygodniejsze niż praca z bibliotekami typu gtest, gdzie wymagane jest dodawanie interfejsów do kodu?
  • czy ktoś ma wiarygodne obliczenia performancowe na ile użycie dynamicznego polimorfizmu i "javaizacja" projektu pod testy "szkodzi" w wydajności, optymalizacji kodu przez kompilator, rozmiarze generowanych binarek, itd.?
2

Ja robię to szablonami:

// header

struct ProdFoo;
class MockableFoo
{
    virtual void api() = 0;
};

template<typename Dep>
class Bar
{
public:
     explicit Bar(Dep *dep = nullptr);

     void doIt();
     ....
private:
      Dep* dep;
};

using ProdBar = Bar<ProdFoo>;
using TestableBar = Bar<MockableFoo>;

// source code
#include "Bar.h"

struct ProdFoo
{
  void api();
};

template<typename Dep>
Bar::Bar(Dep *dep) : dep{dep}
{}

template<typename Dep>
void Bar::doIt() {
     dep->api();
}
     ....


template class Bar<ProdFoo>;
template class Bar<MockableFoo>;

Przy czym, żeby nie mieć wszystkiego, w headerze wymuszam instancję szablonu w cpp w tym samym, w którym mam detale implmentacyjne szablonu.

Przy czym warto pamiętać, że czasami nie warto kombinować.
Podobno podczas statycznego linkowania (nie testowałem), kompilator potrafi stwierdzić, że jest tylko jedna implementacja interface'u. W takiej sytuacji może zastąpić dynamiczne wywołanie metody statycznym wywołaniem.

0

Jednak ma to duży minus: większość klasy projektu ma interfejsy i używa dynamicznego polimorfizmu tam gdzie w rzeczywistości nie musi tylko po to, aby kod był testowalny. A w c++ to kosztuje.

No nie do końca, nie potrzebujesz interfejsów. Wystarczy, żeby metody które chcesz mockować były wirtualne, wtedy Twój mock dziedziczy bezpośrednio po konkretnej klasie a nie interfejsie. W takim układzie Twoim kosztem jest plus osiem bajtów (zazwyczaj) rozmiaru instancji przeznaczone na wskaźnik do vtable'a no i odniesienie się do niego [1]. Mnie osobiście nadal potrafi to razić, ale faktem jest, że w takim układzie, w części produkcyjnej nie ponosisz największego kosztu związanego z dynamicznym polimorfizmem czyli niemal gwarantowanym "cache miss" gdy odnosisz się do konkretnego obiektu przez jego interfejs.

[1] Robiłem kiedyś amatorski benchmark i którego nie mogę teraz znaleźć. Dodanie virtual do metody, gdy wszystko leżało w cache kosztowało mnie kilka dodatkowych nano sekund, czyli jeśli nie robi się czegoś performance critical albo nie planuje się wołać metody kilka milionów razy na sekundę to ten virtual nie ma znaczenia.

1 użytkowników online, w tym zalogowanych: 0, gości: 1