Добавить в корзинуПозвонить
Найти в Дзене

Учимся писать тестируемый код и проверять его с Google Mock

К числу основных требований, предъявляемых к модульным тестам, можно отнести следующие: Однако выполнить их далеко не всегда бывает просто. Разработчики зачастую не задумываются о вопросах тестирования, и пишут код в котором объекты одного класса будут создавать внутри себя объекты другого класса, а те, в свою очередь, объекты третьего класса и т.д. В результате проверяемый код будет требовать большое количество зависимостей, что крайне затрудняет процесс тестирования. Хуже того, код может быть связан с внешней системой и зависеть от её состояния. Например, он может тесно зависеть от конкретных записей в базе данных, пакетов информации, передающихся по сети, или конкретных файлов, хранящихся на диске. Для того чтобы решить указанные проблемы в процессе написания кода нужно опираться на признанные практики разработки, в частности принципы SOLID. В данной статье основное внимание уделим букве D за которой скрывается принцип инверсии зависимостей (dependency inversion principle, DIP).

К числу основных требований, предъявляемых к модульным тестам, можно отнести следующие:

  1. Изолированность. Тесты не должны зависеть от внешних факторов, таких как файловая система, базы данных или сеть.
  2. Скорость. Модульные тесты должны выполняться очень быстро (обычно миллисекунды), чтобы их можно было запускать постоянно.
  3. Детерминизм (воспроизводимость). Результат теста должен быть всегда одинаковым при одинаковых входных данных, независимо от окружения.

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

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

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

В данной статье основное внимание уделим букве D за которой скрывается принцип инверсии зависимостей (dependency inversion principle, DIP). Кратко его можно сформулировать в виде двух тезисов:

  1. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Таким образом, классы должны зависеть от абстракций, а не от конкретных деталей.

DIP является одной из реализаций инверсии управления(IoC) применительно к управлению зависимостями. Он упрощает расширение возможностей системы и облегчает тестирование за счет внедрения фреймворка, который контролирует поток управления программы.

В обычной программе разработчик сам решает, в какой последовательности делать вызовы функций. Но если используется фреймворк, программист может разместить свой код в определенных точках выполнения, используя callback или другие механизмы, затем запустить «главную функцию» фреймворка, которая обеспечит выполнение и вызовет его код когда это будет необходимо. Как следствие, происходит переход контроля над выполнением кода. Это и называется инверсией управления. Фреймворк управляет вызовом кода программиста, а не наоборот.

Также можно передать в метод класса объект или указатель на функцию, которая будет вызвана внутри этого метода. Это еще один способ реализовать IoC.

Фреймворк gtest задает порядок выполнения кода тестов при помощи последовательности макросов типа TEST или TEST_F и вызывает в них тестируемые функции.

Перейдем от теории к практике и рассмотрим пример кода, создатель которого не думал о DIP и IoC. Решалась задача создания класса платежного сервиса, основой которого является метод processPayment.

-2

Он получает указатель на экземпляр банковского API и проверяет достаточно ли средств на счету accountId. Если средств достаточно API посылает нотификацию и возвращает Success. В противном случае возвращается Failure.

Проблема этого кода в том, что он обращается к глобальному экземпляру BankApi и нам трудно изменить его поведение в тесте. К тому же нарушается принцип DIP так как класс PaymentService зависит от конкретного класса BankApi, а не от абстракции взаимодействия с API.

Кроме того, методы типа getBalance и sendNotification в реальном коде требуют сетевого взаимодействия, что делает тесты медленными и нестабильными так как любой сетевой запрос может закончиться неудачно из-за потери соединения или недоступности сервера.

Для решения описанных проблем введем абстрактный класс(интерфейс) IBankApi в котором объявим виртуальные методы getBalance и sendNotification, используемые в processPayement.

Теперь в коде класса PaymentService можно избавиться от вызова getBankApiInstance. Вместо этого будем передавать класс, наследующий от IBankApi, в конструктор PayementService. Тем самым переходим от жестко закодированной зависимости к ее внедрению через конструктор. В результате получим следующий код:

-3

В отличие от первой версии метод processPayemnt теперь стал чище так как не создает внутри себя зависимость от функции getBankApiInstance. Также метод теперь можно легко тестировать, передавая в конструктор PaymentService умный указатель на класс, производный от IBankApi. При тестировании мы можем заменить методы setBalance и sendNotification их дублерами, логика которых соответствует целям тестирования. Таким образом, добиваемся изоляции тестируемого кода от внешних зависимостей.

Главная цель такой изоляции - заменить «тяжелые» или непредсказуемые зависимости (вроде реальной базы данных или сетевого API) на легкие и управляемые.

В gtest создание тестовых дублеров реализуется с помощью расширения Google Mock (gMock), которое позволяет тестировать взаимодействие вашего класса с другими. С помощью этого расширения можно убедиться в том, что класс вызывает методы других объектов с нужными значениями входных параметров, нужное количество раз и в заданном порядке.

В модульном тестировании в качестве дублеров обычно применяются следующие: stub (заглушка), mock (мок) и fake (фейк).

  • Stub - самый простой вид дублера, который возвращает заранее заготовленные данные. У него нет логики. Используется когда тестируемой функции нужно получить какие-то данные извне (например из файла конфигурации или текущее время) чтобы продолжить работу. Использование заглушки позволяет ответить на вопрос: «как мой код работает с такими входными данными?».
  • Mock позволяет определить вызывалась ли функция, сколько раз, с какими аргументами, в каком порядке. Используется когда важно протестировать алгоритм работы функции (получение информации по сети, запись в базу данных и т.д.). При этом проверяется взаимодействие с мок функцией. Например, вызвал ли код функцию send ровно один раз?
  • Fake - это упрощенная, но реально работающая реализация функционала. Например, упрощенная, но работающая реализация эмуляции сохранения данных в БД. При этом в качестве хранилища используется массив в оперативной памяти. Используется когда заглушки недостаточно. Например потому что данные должны сохраняться между вызовами. Цель - заменить тяжелую зависимость легкой. Тестируемый код работает с настоящим (пусть и упрощенным) хранилищем данных.

Зачем нужны тестовые дублеры? На то есть несколько причин:

  1. Скорость. Тесты проходят за миллисекунды так как нет работы с диском или сетью.
  2. Детерминизм. Тесты не упадут из-за того, что пропал интернет.
  3. Симуляция ошибок. Можно заставить дублер вернуть ошибку, например, 500 (Internal Server Error), чтобы проверить обработает ли ее вызывающая функция.

В этой статье сосредоточимся на рассмотрении стабов и моков. В gMock стабы реализованы при помощи макроса ON_CALL, состоящего из трех основных частей: самого макроса, условия соответствия (матчера) и действия по умолчанию. Синтаксис:

ON_CALL(mock_object, method_name(matchers))
.With(multi_argument_matcher) // Опционально
.WillByDefault(action); // Обязательно для задания поведения

Назначение аргументов:

  1. mock_object - имя мок-объекта.
  2. method_name(matchers) - имя метода и матчеры для аргументов, например, Eq(5), _, NotNull().
  3. With(multi_argument_matcher) позволяет задать сложное условие на несколько аргументов сразу.
  4. WillByDefault(action) определяет, что именно сделает метод. Самое частое действие - возврат значения Return(value).

Моки реализованы при помощи макроса EXPECT_CALL. Это основной инструмент gmock для проверки взаимодействий. Он устанавливает ожидание того, что конкретный метод будет вызван с определенными аргументами заданное количество раз.

Если ожидание не выполняется (метод не вызван или вызван с другими параметрами), тест провалится. Его синтаксис выглядит следующим образом:

EXPECT_CALL(mock_object, method(matchers))

.Times(cardinality)

.WillOnce(action)

.WillRepeatedly(action);

Макрос принимает два аргумента: сначала фиктивный объект, а затем метод и его аргументы. Обратите внимание, что они разделены запятой. Далее могут следовать необязательные условия, предоставляющие дополнительную информацию об ожидаемом результате. Например, Times(cardinality) устанавливает сколько раз должен быть вызван мок. WillOnce(action) задает что сделать при конкретном вызове. WillRepeatedly(action) определяет что делать при последующих вызовах.

Следующая проверка

EXPECT_CALL(turtle, GetX())

.Times(5)

.WillOnce(Return(100))

.WillOnce(Return(150))

.WillRepeatedly(Return(200));

ожидает, что метод GetX объекта turtle будет вызван 5 раз. Первый вызов вернет 100, второй - 150, а каждый последующий - 200.

Матчер (сопоставитель) упрощает написание тестов. Например, с его помощью не нужно проверять полное совпадение строк или структур данных, если важна только их часть.

Если метод принимает строку можно проверить её содержимое при помощи следующих сопоставителей: testing::StartsWith, testing::HasSubstr,
testing::EndsWith и т.д. Например:
// Ожидаем cтроку лога, которая начинается с "Error:" и содержит подстроку "timeout"
EXPECT_CALL(mockLogger, log(AllOf(StartsWith("Error:"), HasSubstr("timeout"))))
.Times(1);

Числовые сопоставители используются для проверки диапазонов или условий:

  • using ::testing::Gt; // больше
  • using ::testing::Lt; // меньше
  • using ::testing::Ge; // больше либо равно
  • using ::testing::Ne; // не равно

Пример:

// Ожидаем вызов функции setRetryDelay с числом от 1 до 100
EXPECT_CALL(mockApi, setRetryDelay(AllOf(Gt(0), Lt(100))));

Если аргумент функции - это сложный объект/структура, то можно проверить значение конкретного поля, не переопределяя оператор ==.

Пример:
struct User {
int id;
std::string name;
};

// Ожидаем вызов метода saveUser с объектом, у которого name == "Admin"
EXPECT_CALL(mockDb, saveUser(Field(&User::name, "Admin")));

Если не важен аргумент, передаваемый в функцию, используйте нижнее подчеркивание (_).
// Важно, что метод вызван, само значение аргумента не важно
EXPECT_CALL(mockDb, connect(_)).WillOnce(Return(true));

Матчеры могут быть составными, например:

  • AllOf(m1, m2) - проверяет, что аргумент удовлетворяет всем перечисленным условиям одновременно.
  • AnyOf(m1, m2) - проверяет, что аргумент соответствует хотя бы одному из перечисленных условий. Если хотя бы один матчер внутри AnyOf возвращает true, проверка пройдена.
  • Not(m1) - инвертирует результат матчера. Проверка проходит, если аргумент не соответствует заданному условию.

Пример:

// Аргумент НЕ должен быть пустой строкой
EXPECT_CALL(mock, process(Not(Eq(""))));

Важной частью gmock является MOCK_METHOD - это основной инструмент для создания фиктивного метода внутри мок класса.

Если EXPECT_CALL говорит, как метод должен себя вести в тесте, то MOCK_METHOD создает «заглушку», которая заменяет реальную реализацию.

Синтаксис:

MOCK_METHOD(ReturnType, MethodName, (Args...), (Specs...));

Аргументы:

  1. ReturnType задает тип возвращаемого значения (например, int, void, std::string).
  2. MethodName задает имя метода, который вы переопределяете.
  3. (Args...) определяет список аргументов и их типов в скобках.
  4. (Specs...) определяет спецификаторы метода (опционально), которые должны указываться в скобках. Например, const, override, noexcept.

Создадим класс MockBankApi, содержащий тестовые двойники тестируемых методов.

class MockBankApi : public IBankApi {

public:
MOCK_METHOD(double, getBalance, (int accountId), (override));
MOCK_METHOD(void, sendNotification, (const std::string& msg), (override));

};

И это весь необходимый код. Остальное сделает библиотека.

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

ON_CALL превращает метод в стаб. Если вызов не произойдет, то тест не упадет. Это просто настройка ответа по умолчанию.

EXPECT_CALL превращает метод в мок. Если вызов не произойдет или произойдет не с теми аргументами, то тест провалится.

Суммируя полученную информацию, напишем тесты для функции processPayment с использованием gMock.

-4

Как видно из комментариев в коде, объект *mock выступает в виде стаба и мока в одном и том же тесте. При этом важно вставлять макросы ON_CALL и EXPECT_CALL ДО вызова тестируемой функции. В первом тесте проверяется что sendNotification была вызвана один раз (Times(1)) с аргументом "Success". Во втором проверяем что функция не вызывалась (Times(0)).

Таким образом, применяя принципы SOLID и IoC мы выполнили рефакторинг, сделав код функции processPayment чище. К тому же за счет введения абстракции (интерфейса IBankApi) код стал тестируемым. Тестируемый код надежно изолирован от внешней зависимости. Это позволяет удовлетворить требования по скорости выполнения тестов и детерминированности их результатов.

Продолжение следует...