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