Общая картина
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 или подобные инструменты для мок реализации функций
делать постепенную миграцию классов на использование класса-враппера для работы с библиотекой во время написания юнит тестирования.
