Варианты проведения data-driven тестирования
постановка проблемы
Иногда при написании модульных тестов нужно проверить работу модуля на некотором количестве вводных данных. Убедиться, что обработка строк ведет себя предсказуемо для разных случаев, а фабрика пакетов выдает нужные байты в зависимости от запроса.
Такие тесты можно и захардкодить, навернув 10-20-∞ вызовов макроса EXPECT_EQ
.
Вот только такое и писать неприятно, и читать, и поддерживать.
А если пишешь одну и ту же конструкцию несколько раз, надо задуматься – можно ли абстрагироваться?
Для этого иметь возможность как-то связать набор входных параметров с предполагаемым результатом. Сложить все в набор данных для тестирования и прогонять в цикле через модуль в рамках одного кейса. Достаточно распространенная задача, не правда ли?
Рассмотрим, какие варианты доступны на двух фреймворках для тестирования, котоырми я пользуюсь – QTest и Google Test.
side note: установка и настройка фреймворков тестирования в проекте в данном материале не рассматриваются.
решение №1 – QTest
Разработчики Qt в своей манере решили дать разработчикам почувствовать себя настоящими офисными крысами сотрудниками и писать data тесты как…таблички?
Рассмотрим на примере из документации (1).
Внутри класса теста заводятся два метода – метод <тест>
и метод <тест_data>
.
Полагаясь на магию макросов, ожидается, что data-метод будет вызван первым и положит в некие скрытые поля данные для тестирования.
class TestQString: public QObject
{
Q_OBJECT
private slots:
void toUpper_data();
void toUpper();
};
Данные заполняются в два присеста – сначала добавляем колонки по именам, а потом добавляем строки с именами и пайпим (!) в них нужные значения:
void TestQString::toUpper_data()
{
QTest::addColumn<QString>("string");
QTest::addColumn<QString>("result");
QTest::newRow("all lower") << "hello" << "HELLO";
QTest::newRow("mixed") << "Hello" << "HELLO";
QTest::newRow("all upper") << "HELLO" << "HELLO";
}
Следит ли кто в этот момент за совпадением количества запайпленных элементов строки с количеством столбцов я не знаю, и пробовать не очень хочется. Скорее всего будет ошибка компиляции, ну и на том спасибо.
Ну зато хоть тело самого теста получается стройное и красивое, правда? Правда ведь?
void TestQString::toUpper()
{
QFETCH(QString, string);
QFETCH(QString, result);
QCOMPARE(string.toUpper(), result);
}
Ну, если кто-то может назвать внешний вид макроса QFETCH
интуитивно понятным – напишите мне на почту о своих проблемах, мне очень интересно.
Опять идет надежда на то, что будет сохранен порядок вытаскивания данных из колонок.
Достучаться до имени колонки/строки, кажется, нельзя.
Прогонка в цикле сделана неявно и появляется из где-то из макросов.
И вот вроде обещали простоту жизни, и в виде таблички данные можно “представить”:
index | name | string | result |
---|---|---|---|
0 | all lower | “hello” | HELLO |
1 | mixed | “Hello” | HELLO |
2 | all upper | “HELLO” | HELLO |
Вот только выглядит это как лишний оверхэд с хрупким для использования api.
Не хочется использовать 😞
решение №2 – GTest + struct
Попробуем сделать немного почеловечнее.
На моих проектах сейчас применяется Google Test, в нем и будем экспериментировать.
Самое первое что приходит в голову – завернуть входные и выходные данные в структуру, создать массив данных и гонять в цикле. Попробуем на qt-шном примере со строками:
TEST(TestQString, toUpper_Struct) {
struct Subject{
QString subject;
QString result;
};
const std::array<Subject, 3> subjects{
Subject{"hello", "HELLO"}, // all lower
Subject{"Hello", "HELLO"}, // mixed
Subject{"HELLO", "HELLO"} // all upper
};
for(auto&& subject: subects) {
EXPECT_EQ(subject.subject.toUpper(), subject.result);
}
}
Выглядит красиво и аккуратно! Набивать новые данные понятно куда – добавляешь еще строчку в массив и увеличиваешь циферку количества кейсов. А так как с появлением C++17 у нас есть deduction guides для std::array (2), можно специализацию массива вовсе опустить, ведь элементы мы создаем прямо in-place:
// it compiles but require c++17
const std::array subjects{
Subject{"hello", "HELLO"}, // all lower
Subject{"Hello", "HELLO"}, // mixed
Subject{"HELLO", "HELLO"} // all upper
};
Супер! Супер?
У меня на проекте сейчас, например, 150+ тестов для генераторов байтовых посылок, кодирующих команды, отправляемые по UART в исполнительные механизмы. Например, вот такой тест (закодированные байты опущу, они тут роли не играют):
TEST(ProtocolTests, CreateEngineEnableRequest)
{
struct Subject
{
bool state;
QByteArray expected;
};
const std::array subjects{ Subject{ true, QByteArray::fromHex("") },
Subject{ false, QByteArray::fromHex("") } };
for (auto&& subject : subjects) {
const EngineEnableRequest request{ pid, engine_id, subject.state };
EXPECT_EQ(set_param(request), subject.expected);
}
}
И вот вроде все и здорово, но внутри каждого теста описывать структуру данных для тестирования? Лениво же! Зачем писать код, который можно не писать? Мы же все равно описываем сам набор данных для тестирования. Вот было бы можно совместить описание структуры и набора данных…
решение №3 – GTest + std::tuple
Выход есть: std::make_pair
для одного входного параметра и std::make_tuple
для нескольких!
Благодаря deduction guides опять можно явно не параметризовать массив, а просто лепить туда данные строка за строкой.
Попробуем на qt-шном примере:
TEST(TestQString, toUpper_tuple) {
enum TupleFields: int { Subject = 0, Result };
const std::array subjects {
std::make_pair("hello", "HELLO"), // all lower
std::make_pair("Hello", "HELLO"), // mixed case
std::make_pair("HELLO", "HELLO") // all upper
};
for(auto&& subject: subjects) {
EXPECT_EQ(std::get<Subject>(subject).toUpper(), std::get<Result>(subject));
}
}
На stack overflow (3) и reddit (4) программисты справедливо возмущаются, что синтаксис доступа к полям кортежа через std::get
неудобен, ведь надо держать в голове индекс нужного поля.
Или, как в примере выше, завести enum, что не сокращает количество кода.
А в случае использования пары обычно применяют доступ к полям .first
/ .second
, что не вносит ясности при чтении из блокнота.
Но эти претензии становятся неактуальными, если в паре/кортеже находятся значения разных типов. Как довольно часто бывает при тестировании модулей, порождающих некоторый результат из набора параметров. Например вот:
TEST(ProtocolTests, CreateEngineEnableRequest)
{
const std::array subjects{
std::make_pair(true, QByteArray::fromHex("")),
std::make_pair(false, QByteArray::fromHex(""))
};
for (auto&& subject : subjects) {
const EngineEnableRequest request{ pid,
engine_id,
std::get<bool>(subject) };
EXPECT_EQ(set_param(request), std::get<QByteArray>(subject));
}
}
Тогда можно обращаться, используя специализацию get’а для нужного типа! На мой взгляд это читается как минимум не хуже, чем в случае со структурами, а кода меньше.
почему array, а не map / unordered_map?
- map’ы действительно удобны для ситуации, когда можно обойтись парой;
- использование qt-шных классов в качестве ключа, например,
QByteArray
как предполагаемого результата выполнения теста, требует дополнительного бойлерплейта; - если у нас больше одного входного параметра, то их все равно надо группировать в какую-то структуру;
- array может быть
constexpr
, если все его значения тоже могут бытьconstexpr
(sic!); - по идее, в рамках теста нам в любом случае нужно итерироваться по контейнеру от начала до конца, поэтому преимущество доступа к произвольному месту контейнера за O(1) не принесет пользы.
сравниваем перфу struct vs. tuple
На задворке сознания все же точится червячок неуверенности:
а не будет ли просадки производительности?? какие-то функции make_чего_то_там, вместо просто описания структуры. Описание-то бесплатное!
А давайте попробуем посмотреть. Заодно пощупаем Google Benchmark.
Создадим две функции – в одной будет массив из 11 структур:
static void BM_UseStructs(benchmark::State &state) {
struct Subject {
uint32_t value;
QByteArray expected;
};
const std::array subjects{
Subject{10, QByteArray::fromHex("")},
Subject{50, QByteArray::fromHex("")},
Subject{100, QByteArray::fromHex("")},
Subject{125, QByteArray::fromHex("")},
Subject{150, QByteArray::fromHex("")},
Subject{250, QByteArray::fromHex("")},
Subject{500, QByteArray::fromHex("")},
Subject{1000, QByteArray::fromHex("")},
Subject{1250, QByteArray::fromHex("")},
Subject{1500, QByteArray::fromHex("")},
Subject{5000, QByteArray::fromHex("")}};
for (auto _ : state) {
for (auto &&subject : subjects) {
const requests::PowerSetWdtTimeout request{pid, subject.value};
assert(utils::set_param(request.pid, request.payload) ==
subject.expected);
}
}
}
BENCHMARK(BM_UseStructs)->Repetitions(10)->Iterations(10000);
а в другой тоже массив, но из 11 кортежей:
static void BM_UseTuples(benchmark::State &state) {
const std::array subjects{
std::make_tuple(UINT32_C(10), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(50), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(100), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(125), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(150), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(250), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(500), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(1000), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(1250), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(1500), QByteArray::fromHex("")),
std::make_tuple(UINT32_C(5000), QByteArray::fromHex(""))};
for (auto _ : state) {
for (auto &&subject : subjects) {
const requests::PowerSetWdtTimeout request{pid,
std::get<uint32_t>(subject)};
assert(utils::set_param(request.pid, request.payload) ==
std::get<QByteArray>(subject));
}
}
}
BENCHMARK(BM_UseTuples)->Repetitions(10)->Iterations(10000);
Идея следующая: для каждого test-case (то есть для каждой структуры/кортежа) мы:
- вытаскиваем число;
- создаем на основе числа запрос (на установку интервала работы сторожевого таймера
requests::PowerSetWdtTimeout
, не суть); - функция
utils::set_param
– фабрика, которая берет запрос, создает на его основе объектPacket
и кодирует его вQByteArray
(за кадром); - сравниваем получившийся
QByteArray
с тем, который был в кейсе.
ВАЖНОЕ ЗАМЕЧАНИЕ:
Замечание, чтобы корректно интерпретировать полученные данные:
- Бенчмарк у меня был отдельным проектом, без Google Test, поэтому использовался просто
assert
;- гонял на Intel i7-11700k @ 4.0 GHz;
- не отключал cpu frequency scaling (5), поэтому результаты могут быть слегка некорректными;
- эффективность самого процесса кодирования и использования qt-шных классов остается за кадром;
- мне было интересно, есть ли существенная разница между использованием
struct
иstd::tuple
, чтобы прям на порядки различалось;
проверяем в режиме сборки Debug
После запуска 10000 итераций и 50 повторов каждого теста получаем вот такую картину:
benchmark | Time | CPU |
---|---|---|
BM_UseStructs mean |
13372 ns | 13371 ns |
BM_UseStructs median |
13352 ns | 13352 ns |
BM_UseTuples mean |
13549 ns | 13548 ns |
BM_UseTuples median |
13528 ns | 13528 ns |
за исключением одного выброса (который скорее всего появился в следствие плавающей частоты процессора), результаты ровные и почти совсем одинаковые!
В бенчмарке есть инструменты (6), позволяющие сравнить результаты выполнения по U-критерию Манна-Уитни.
Запустим python3 /usr/share/benchmark/compare.py --display_aggregates_only filters ./build/test-benchmark BM_UseStructs BM_UseTuples
и получим следующие результаты:
BM_UseStructs vs. BM_UseTuples |
Time | CPU | Time Old | Time New | CPU Old | CPU New |
---|---|---|---|---|---|---|
pvalue | 0.0000 | 0.0000 | ||||
mean | +0.0132 | +0.0132 | 13372 | 13549 | 13371 | 13548 |
median | +0.0132 | +0.0132 | 13352 | 13528 | 13352 | 13328 |
stddev | +0.3492 | +0.3510 | 56 | 75 | 56 | 75 |
cv | +0.3316 | +0.3333 | 0 | 0 | 0 | 0 |
OVERALL_GEOMEAN | +0.0132 | +0.0132 | 0 | 0 | 0 | 0 |
разберем по пунктам:
- pvalue: значение U-критерия, значение 0.0000 намекает на статистически достоверную разницу в производительности;
- mean: относительная разница между средними значениями времени выполнения. Положительное значение (+0.0132) намекает на то, что вариант с кортежами медленнее на 1.3%;
- median: схожим образом, показывает относительную разницу между медианным временем выполнения. Опять же, вариант с кортежами медленнее на 1.3%;
- stddev: стандартное отклонение времени выполнения. Положительное значение (+0.3492) показывает на бОльший разброс у варианта с кортежами;
- cv: относительное стандартное отклонение (коэффициент вариации). Положительное значение также показывает бОльший относительный разброс значений у варианта с кортежами.
OVERALL_GEOMEAN
: среднее геометрическое. Положительное значение указывает на общее ухудшение во втором варианте.
проверяем в режиме сборки Release
Только я дописал предыдущий параграф, изрядно расстроившись, как вспомнил – а что ж я в дебаге-то гоняю? Тесты-то у меня в CI все равно в релизной конфигурации гоняются, с оптимизациями и всем прочим.
А ну-ка пробуем.
benchmark | Time | CPU |
---|---|---|
BM_UseStructs mean |
407 ns | 407 ns |
BM_UseStructs median |
387 ns | 387 ns |
BM_UseTuples mean |
389 ns | 389 ns |
BM_UseTuples median |
388 ns | 388 ns |
ОГО вот это я бы без красивывых выводов сейчас бы остался!
Аггрегируем результаты:
BM_UseStructs vs. BM_UseTuples |
Time | CPU | Time Old | Time New | CPU Old | CPU New |
---|---|---|---|---|---|---|
pvalue | 0.6368 | 0.6466 | ||||
mean | -0.0451 | -0.0451 | 407 | 389 | 407 | 389 |
median | +0.0026 | +0.0026 | 387 | 388 | 387 | 388 |
stddev | -0.8867 | -0.8865 | 94 | 11 | 94 | 11 |
cv | -0.8813 | -0.8812 | 0 | 0 | 0 | 0 |
OVERALL_GEOMEAN | -0.0298 | -0.0299 | 0 | 0 | 0 | 0 |
разбираем:
- pvalue: достоверность просела радикально и находится на уровне “возможно???”. При таком значении формально надо принять нулевую гипотезу, что между двумя бенчами нет статистически достоверной разницы;
- mean: тем не менее, среднее значение показывает улучшение перфоманса на 4.5%;
- median: при этом медиана практически идентична в обоих случаях, разница 0.2%;
- stddev: отрицательное значение показывает, что во втором случае разброс в значениях существенно меньше, чем в первом;
- cv: тот же вывод;
OVERALL_GEOMEAN
: отрицательное значение указывает на общее улучшение во втором варианте.
К сожалению, откровения не произошло – на релизной сборке оба варианта показывают одинаковый перфоманс без существенных различий.
итоги
Штош, выглядит немного грустно. То ли тест некорректный, то ли вариант с кортежами действительно слегка проигрывает варианту со структурами.
Хотя hungptit в ответе на аналогичный вопрос (3) пришел к противоположному выводу, он проверял другой кейс – сортировку вектора структур против сортировки вектора кортежей. Вот в таком случае кортежи победили по скорости, в довесок предложив отсутствие необходимости писать для структуры boilerplate – операторы сравнения.
На масштабах моего текущего проекта проигрыш в 1.3% (тем более на дебаге) вряд ли отразится – не так уж и много у меня возможных вариантов данных для тестирования каждого юнита. Но если в Вашем проекте тестируется от 20+ кейсов, возможно, не стоит идти fancy путем и использовать структуры. Также структуры будут уместны, если внутри кейсов есть данные с одинаковым типом. Вариант с кортежами выглядит красиво, но подходит не для всех возможных случаев.
Стоит иметь установленные договоренности правила написания тестов на проекте.
Выберите понравившийся вариант и пользуйтесь только им, не разводите зоопарк.