Общая картина

Legacy проекты на С++ зачастую являются многокомпонентными, когда продукт использует несколько библиотек, которые имеют различную архитектуру для работы с ними.
Обычно это:

  • библиотеки, поставляемые как ООП решение (Некоторые модули boost, SOCI как пример)

  • библиотеки, реализованные в функциональном стиле (OpenGL через С API, POSIX как пример)

Из-за этого в итоговом проекте появляются сущности, которые внутри реализованы через классы, но внутри методов класса идет обращение к обычным функциям. Некоторые библиотеки имеют специфичные функции, которые для своей работы требуют первоначальную инициализацию. Как пример: поиск подключенных устройств и получение на них ссылок для дальнейшей работы или функции, которые требуют инициализации большого количества памяти.
Вследствие этого возникает вопрос - как лучше реализовать покрытие юнит-тестами специфичных объектов, которые внутри себя имеют функции, требующие специальных условий для своей работы?

Конечно, можно сразу сказать, что у GTest/GMock (берем как самый распространённый framework для юнит тестирования в C++) уже есть предоставляемое решение через реализацию враппера для работы с обычными функциями, но этот метод затратный, если библиотека в проекте не была адаптирована под работу через ООП стиль, из-за чего придется вносить существенные изменения в существующий код. В данной статье рассмотрим, как можно с минимальными затратами сделать покрытие тестами сложных объектов, которые в своей работе используют обычные функции, требующие для работы специфичных условий.

Обозреваемые инструменты

  • gmock-global - header библиотека, используемая для создания mock вызовов не ООП функций.

  • Gtest/Gmock - gtest framework для юнит тестирования, как самая распространённая библиотека для юнит тестирования С++.

Данная пара была взята, так как gmock-global базируется на gtest и часто используется как заплатка для покрытия тестами кусков кода, где используются обычные функции. GTest/GMock - как самый распространенный framework встречающийся в legacy проектах на C++.
Библиотеки типа Catch2 или Boost:Test в данной статье не будут обозреваться т.к. для использования их с моками нужно брать доп. библиотеки (как пример FakeIt).

Обозреваемая ситуация

Пусть в нашем проекте есть статичная библиотека с функцией some_func_foo, которую в будущем будем вызывать из метода тестового класса. В дальнейшем попытаемся наш класс покрыть тестами с возможность переопределения поведения функции some_func_foo.

Тестовая библиотека

Структура библиотеки используемая в примерах:

lib/
├─ CMakeLists.txt
├─ include/
│  ├─ mylib.h
├─ src/
│  ├─ mylib.cpp

mylib.h

#pragma once

int some_func_foo(int a);

mylib.cpp

#include "mylib.h"
#include <iostream>

int some_func_foo(int a){
    return 42 + a;
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(mylib)

include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include)
add_library(mylib STATIC 
    src/mylib.cpp
)

set_target_properties(mylib PROPERTIES PUBLIC_HEADER include/mylib.h)

install(TARGETS mylib
        ARCHIVE DESTINATION lib
        PUBLIC_HEADER DESTINATION include)

Таким образом, мы можем сделать static .a библиотеку, которая установится в систему для дальнейшего использования нашими проектами. Данная либа имитирует ситуацию, когда мы берем внешнюю библиотеку, компилируем ее и в дальнейшем используем в проекте связку библиотека + header файл.
Для сборки и установки в директории mylib необходимо:

mkdir build && cd build
cmake ..
make
make install

Библиотека установится в /usr/local/lib и /usr/local/include соответственно.

Тестовый объект

Пусть у нас будет класс, который внутри своего метода вызывает some_func_foo

class Foo{
public:
    int CallSomeLibMethod(int a){
        return some_func_foo(a);
    }
};

В реальном проекте some_func_foo может иметь специфическую реализацию или требовать необходимые условия для работы.
Реализуем проект на gmock-global, чтобы просто переопределить поведение функции во время тестов.

Тестовый проект на gmock-global

Установка gmock-global идет через импортирование header файла библиотеки, поэтому необходимо скопировать файл себе в проект.
Структура:

test/
├─ CMakeLists.txt
├─ include/
│  ├─ gmock-global/
│  │  ├─ gmock-global.h
├─ TestSomeFunc.cpp

CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(test_mylib)

find_package(GTest REQUIRED)

add_executable(test_runner 
    TestSomeFunc.cpp
)

target_include_directories(test_runner PRIVATE
    include/
)

target_link_libraries(test_runner PRIVATE
    GTest::gtest_main
    GTest::gmock 
    GTest::gmock_main
    "-Wl,--whole-archive"
    /usr/local/lib/libmylib.a
    "-Wl,--no-whole-archive"
)

enable_testing()
add_test(NAME SomeFuncTest COMMAND test_runner)

Флаги whole-archive используются для полного импорта статической библиотеки, т.к. такой кейс может встречаться при импортировании frameworkов в проект.

Реализация мок функции для тестов на основе gmock-global

Дальше необходимо написать тест и мок для нашей функции.
По документации сначала мы должны определить наш мок.

MOCK_GLOBAL_FUNC1(some_func_foo, int(int));

После этого в теле теста определяем, что ожидаем вызова мок функции через EXPECT_GLOBAL_CALL и значение, которое хотим получить в результате переопределения поведения функции:

TEST(SomeFuncTest, CallsMock) {
    Foo obj;
    EXPECT_GLOBAL_CALL(some_func_foo, some_func_foo(_)).Times(1).WillOnce(Return(10));
    EXPECT_EQ(obj.CallSomeLibMethod(42), 10);
}

Не забываем подключить нужные header файлы и testing namespace.
Итоговый код представлен ниже.

#include "mylib.h"
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include <gmock-global/gmock-global.h>

using namespace testing;
MOCK_GLOBAL_FUNC1(some_func_foo, int(int));

class Foo{
public:
    int CallSomeLibMethod(int a){
        return some_func_foo(a);
    }
};

TEST(SomeFuncTest, CallsMock) {
    Foo obj;
    EXPECT_GLOBAL_CALL(some_func_foo, some_func_foo(_)).Times(1).WillOnce(Return(10));
    EXPECT_EQ(obj.CallSomeLibMethod(42), 10);
}

int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

Выглядит лаконично, нам не нужно делать обертку через интерфейс, как это требует GTest/Gmock.
Соберем данный проект:

mkdir build && cd build
cmake ..
make
make install

Но при сборке возникнет ошибка линковщика:

/usr/bin/ld: /usr/local/lib/libmylib.a(mylib.cpp.o): in function `some_func_foo':
mylib.cpp:(.text+0x0): multiple definition of `some_func_foo'; CMakeFiles/test_runner.dir/TestSomeFunc.cpp.o:TestSomeFunc.cpp:(.text+0x0): first defined here

С чем это связано? Если взять другую функцию, например из библиотеки inotify функцию inotify_init, то она спокойно мокается.

#include "mylib.h"
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include <gmock-global/gmock-global.h>
#include <sys/inotify.h>

using namespace testing;
//MOCK_GLOBAL_FUNC1(some_func_foo, int(int));
MOCK_GLOBAL_FUNC0(inotify_init, int(void));

class Foo{
public:
    int CallSomeLibMethod(int a){
        //return some_func_foo(a);
        return inotify_init();
    }
};

TEST(SomeFuncTest, CallsMock) {
    Foo obj;
    //EXPECT_GLOBAL_CALL(some_func_foo, some_func_foo(_)).Times(1).WillOnce(Return(10));
    EXPECT_GLOBAL_CALL(inotify_init, inotify_init()).Times(1).WillOnce(Return(-1));
    EXPECT_EQ(obj.CallSomeLibMethod(42), -1);
}

int main(int argc, char** argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

Результат:

./test_runner 
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from SomeFuncTest
[ RUN      ] SomeFuncTest.CallsMock
[       OK ] SomeFuncTest.CallsMock (0 ms)
[----------] 1 test from SomeFuncTest (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
[  PASSED  ] 1 test.

Чтобы понять разницу, почему одна функция может спокойно мокаться в рамках gmock-global, а другая нет, необходимо разобрать, как раскрывается макрос MOCK_GLOBAL_FUNC0.
Для начала посмотрим, какой код сгенерируется во время препроцессинга из блока генерации мока:

#define MOCK_GLOBAL_FUNC0_(tn, constness, ct, Method, ...) \
class gmock_globalmock_##Method { \
public:\
  gmock_globalmock_##Method(const char* tag) : m_tag(tag) {}  \
  const char* const m_tag; \
  GMOCK_RESULT_(tn, __VA_ARGS__) ct Method () constness {  \
    static_assert((std::tuple_size<                          \
        tn ::testing::internal::Function<__VA_ARGS__>::ArgumentTuple>::value \
            == 0), \
        "this method does not take 0 arguments");  \
    GMOCK_MOCKER_(0, constness, Method).SetOwnerAndName(this, #Method); \
    return GMOCK_MOCKER_(0, constness, Method).Invoke(); \
  } \
  ::testing::MockSpec<__VA_ARGS__> \
      gmock_##Method() constness { \
    GMOCK_MOCKER_(0, constness, Method).RegisterOwner(this); \
    return GMOCK_MOCKER_(0, constness, Method).With(); \
  } \
  mutable ::testing::FunctionMocker<__VA_ARGS__> GMOCK_MOCKER_(0, constness, \
      Method); \
   }; \
   std::unique_ptr< gmock_globalmock_##Method > gmock_globalmock_##Method##_instance;\
   GMOCK_RESULT_(tn, __VA_ARGS__) ct Method() constness { \
       MOCK_GLOBAL_CHECK_INIT(Method); \
       return gmock_globalmock_##Method##_instance->Method();\
      }\

#define MOCK_GLOBAL_FUNC0(m, ...) MOCK_GLOBAL_FUNC0_(, , , m, __VA_ARGS__)

При создании global mock создается класс gmock_globalmock_##Method, в котором реализуются методы вызова мока во время тестов. При препроцессинге получим следующий код для нашего метода some_func_foo:

class gmock_globalmock_some_func_foo
{
public:
  gmock_globalmock_some_func_foo(const char *tag) : m_tag(tag) {}
  const char *const m_tag;
  ::testing::internal::Function<int(int)>::Result some_func_foo(::testing::internal::Function<int(int)>::template Arg<1 - 1>::type gmock_a1)
  {
    static_assert((std::tuple_size<::testing::internal::Function<int(int)>::ArgumentTuple>::value == 1), "this method does not take 1 arguments");
    gmock1_some_func_foo_7.SetOwnerAndName(this, "some_func_foo");
    return gmock1_some_func_foo_7.Invoke(gmock_a1);
  }
  ::testing::MockSpec<int(int)> gmock_some_func_foo(const ::testing::Matcher<::testing::internal::Function<int(int)>::template Arg<1 - 1>::type> &gmock_a1)
  {
    gmock1_some_func_foo_7.RegisterOwner(this);
    return gmock1_some_func_foo_7.With(gmock_a1);
  }
  mutable ::testing::FunctionMocker<int(int)> gmock1_some_func_foo_7;
};
std::unique_ptr<gmock_globalmock_some_func_foo> gmock_globalmock_some_func_foo_instance;
::testing::internal::Function<int(int)>::Result some_func_foo(::testing::internal::Function<int(int)>::template Arg<1 - 1>::type gmock_a1)
{
  if (!gmock_globalmock_some_func_foo_instance)
  {
    throw std::logic_error("You forgot to call EXPECT_GLOBAL_CALL for "
                           "some_func_foo");
  };
  return gmock_globalmock_some_func_foo_instance->some_func_foo(gmock_a1);
};

Здесь случается двойное переопределение, потому что в нашей библиотеке уже есть метод some_func_foo, при этом мы переопределяем его с помощью мок конструкции, создавая функцию.

::testing::internal::Function<int(int)>::Result some_func_foo(::testing::internal::Function<int(int)>::template Arg<1 - 1>::type gmock_a1)

Однако, если нашу some_func_foo функцию сделать inline, то во время линковки проблем не будет. При этом помним, что редактировать header библиотеки, которую взяли в проект - не самая лучшая практика. :)

Таким образом, у библиотеки gmock-global можно выделить существенный минус. Если для покрытия тестами надо мокать функцию, и при этом в исходных header файлах функция не объявлена как inline, то существует риск ошибки линковщика.

Из кейса выше мы можем сделать несколько выводов:

  • gmock-global мож��т помочь реализовать покрытие юнит-тестов классов, которые сложны по своей структуре и используют внешние функции библиотек

  • он имеет ограничения, так как из-за внутренней кодогенерации для тестов можно поймать ошибку линковщика, если библиотека статична и функции не имеют специальных модификаторов

В связи с этим, рассмотрим вариант, который уже упоминался в данной статье, а именно реализацию wrapper над библиотекой, но с легкой интеграцией в тестируемые классы.

Реализация wrapper над библиотекой для получения возможности покрытия тестами с помощью GTest/GMock

Опираясь на рекомендации по покрытию обычных функций, реализуем ООП враппер над нашей тестовой библиотекой.

Для начала сделаем следующие классы:

  • Интерфейс для работы с библиотекой

  • Реализацию враппера

  • Мок класс, который позволяет мокать методы враппера

class LibraryInterface{
public:
    virtual int wrp_some_func_foo(int a) = 0;
};

class MyLibWrapper : public LibraryInterface {
public:
    int wrp_some_func_foo(int a) override {
        return some_func_foo(a);
    }
};

class LibraryMock : public LibraryInterface {
public:
    MOCK_METHOD(int, wrp_some_func_foo, (int), (override));
};

При миграции с прямого вызова функции на вызов через метод класса, можно делать не полный перенос реализации библиотеки, а только тех методов, которые необходимо сделать. Это сразу дает импакт в виде повышения покрытия тестами тех участков кода, которые вызывали трудности, но не забудьте создать задачу на полный перенос. Обычно эта задача закрывается сама в процессе написания тестов. Однако, необходимо держать это в голове при миграции.
Важно, если посмотреть мок реализацию, то она будет свойственна тому, что мы видели в global-mock, но из-за того, что здесь уже используется вызов метод из нашего класса враппера, то проблем с линковкой не будет. Чтобы избежать такой ошибки, GTest настоятельно рекомендуют делать враппер, потому что здесь можно заметить прямой вызов метод wrp_some_func_foo.

class LibraryMock : public LibraryInterface
{
public:
//some static assert
  typename ::testing::internal::Function<::testing::internal::identity_t<int>(int)>::Result wrp_some_func_foo(typename ::testing::internal::Function<::testing::internal::identity_t<int>(int)>::template Arg<0>::type gmock_a0) override
  {
    gmock01_wrp_some_func_foo_21.SetOwnerAndName(this, "wrp_some_func_foo");
    return gmock01_wrp_some_func_foo_21.Invoke(::std::forward<typename ::testing::internal::Function<::testing::internal::identity_t<int>(int)>::template Arg<0>::type>(gmock_a0));
  }
  ::testing::MockSpec<::testing::internal::identity_t<int>(int)> gmock_wrp_some_func_foo(const ::testing::Matcher<typename ::testing::internal::Function<::testing::internal::identity_t<int>(int)>::template Arg<0>::type> &gmock_a0)
  {
    gmock01_wrp_some_func_foo_21.RegisterOwner(this);
    return gmock01_wrp_some_func_foo_21.With(gmock_a0);
  }
  ::testing::MockSpec<::testing::internal::identity_t<int>(int)> gmock_wrp_some_func_foo(const ::testing::internal::WithoutMatchers &, ::testing::internal::Function<::testing::internal::identity_t<int>(int)> *) const { return ::testing::internal::ThisRefAdjuster<int>::Adjust(*this).gmock_wrp_some_func_foo(::testing::A<typename ::testing::internal::Function<::testing::internal::identity_t<int>(int)>::template Arg<0>::type>()); }
  mutable ::testing::FunctionMocker<::testing::internal::identity_t<int>(int)> gmock01_wrp_some_func_foo_21;
};

Таким образом, мы получаем готовую архитектуру сразу для проекта и для тестов.
Осталось только модифицировать наш класс Foo для использования нашего враппера.
Так как мы хотим, чтобы наш итоговой класс работал с библиотекой, и во время тестов с нашим моком, то теперь наш класс должен приним��ть либо объект враппера, либо объект мока. Поэтому сделаем модификацию, чтобы по умолчанию класс получал враппер, но по необходимости принимал наш мок.

class Foo{
private:
    std::shared_ptr<LibraryInterface> lib_wrp;
public:
    Foo(std::shared_ptr<LibraryInterface> rte_wrp = std::make_shared<MyLibWrapper>());
    int CallSomeLibMethod(int a){
        return lib_wrp->wrp_some_func_foo(a);
    }
};

Foo::Foo(std::shared_ptr<LibraryInterface> rte_wrp) : lib_wrp(rte_wrp){}

В результате, мы получаем гибкий класс, который в зависимости от использования либо используется в проекте, либо в тестах. Мы можем корректировать его поведение для покрытия различных тест-кейсов.
Еще одно достоинство данного решения - внедрять такие интерфейсы в рабочий проект можно постепенно, меняя обращение к методам библиотек поочередно, а не сразу за одну таску (главное не забыть отмечать процент переноса на новый тип обращения).

Дальше остается только написать EXPECT_CALL нашего мок метода для теста.
Пример:

TEST(SomeFuncTest, CallsMock) {
    std::shared_ptr<LibraryMock> mock = std::make_shared<LibraryMock>();
    Foo obj(mock);
    EXPECT_CALL(*mock, wrp_some_func_foo(_)).WillRepeatedly(Return(10));
    EXPECT_EQ(obj.CallSomeLibMethod(20), 10);
}
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from SomeFuncTest
[ RUN      ] SomeFuncTest.CallsMock
[       OK ] SomeFuncTest.CallsMock (0 ms)
[----------] 1 test from SomeFuncTest (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test suite ran. (0 ms total)
[  PASSED  ] 1 test.

Таким образом, мы получаем возможность мокать внутренние методы класса для лучшего покрытия различных тест кейсов.

Выводы

Говоря о возможности полного покрытия сложносоставных классов, мы приходим к следующим пунктам:

  • Если проект находится на этапе архитектурного проектирования, то необходимо заранее заложить врапперы для библиотек, которые поставляются в формате функций без ООП оболочки, чтобы не плодить количество сторонних инструментов в проекте и иметь гибкую настройку нужных вам заглушек

  • Если это уже legacy проект, то здесь остается два сценария (на примере наших инструментов):

    • использовать либо gmock-global или подобные инструменты для мок реализации функций

    • делать постепенную миграцию классов на использование класса-враппера для работы с библиотекой во время написания юнит тестирования.