🔥 Важное для QA-специалистов! 🔥
В QaRocks ты найдешь туториалы, задачи и полезные книги, которых нет в открытом доступе. Уже более 18.000 подписчиков – будь среди нас! Заходи к нам в телеграм канал QaRocks
Понимание механизмов работы Google Test: по одному макросу за раз
Если вы когда-нибудь пользовались Google Test, то, вероятно, сотни раз вводили TEST(Suite, Name), не задумываясь о том, как это на самом деле работает. Как макрос превращается в саморегистрирующийся тест? Как EXPECT_EQ узнает, к какому тесту он относится? Как фикстура настраивается и удаляется автоматически?
В этой статье мы создадим собственную библиотеку для модульного тестирования — назовем её DuckTest — с нуля, и к концу вы точно поймёте, что происходит «под капотом» тестовых фреймворков, которыми вы пользуетесь каждый день.
Над чем мы работаем
В оставшейся части статьи мы будем опираться на этот небольшой список констант и цветовых кодов ANSI (запоминать его не нужно, просто имейте в виду, что он есть):
define PASSED 1 << 0
#define FAILED 1 << 1
#define SKIP 1 << 2
#define NONE 0
#define ALL 7
#define RESET "\033[0m" ///< Reset all attributes.
#define RED "\033[31m" ///< Red foreground.
#define GREEN "\033[32m" ///< Green foreground.
#define YELLOW "\033[33m" ///< Yellow foreground.
#define BLUE "\033[34m" ///< Blue foreground.
#define MAGENTA "\033[35m" ///< Magenta foreground.
#define CYAN
Моделирование теста
Любой фреймворк для тестирования нуждается в модели данных ещё до того, как ему понадобятся макросы. Итак, давайте зададимся вопросом: что же, с точки зрения структуры, представляет собой тест?
Для теста необходимы три компонента: набор для его классификации, имя для его однозначной идентификации и функция для его фактического запуска.
struct Test {
std::string suite;
std::string name;
std::function<void()> func;
};
После запуска теста нам нужно где-то сохранить его результат. Элемент Result связывает тест Test, в результате которого он был получен, с метаданными о том, как он прошел:
struct Result {
Test test;
TestInfo info;
};
А самому TestInfo требуется лишь статус (прошло ли тестирование?) и данные о времени выполнения:
struct TestInfo {
unsigned int status = PASSED;
double elapsedMs = 0.0;
};
И вот тут дело становится интересным. Когда мы пишем утверждения типа EXPECT_EQ внутри тестовой функции, этот код не имеет прямого доступа к объекту TestInfo, созданному для него средством запуска тестов. Мы решаем эту проблему с помощью глобального указателя, который всегда указывает на «тот тест, который выполняется в данный момент»:
TestInfo* currenTest = nullptr;
Но есть одна загвоздка: если этот заголовочный файл включается в несколько .cpp-файлов, каждый из них получает свою копию указателя, что противоречит поставленной цели. Решением является использование inline, что указывает компилятору: «предоставь каждой единице перевода, включающей этот файл, ту же самую переменную, а не её копию».
struct TestInfo {
unsigned int status = PASSED;
double elapsedMs = 0.0;
};
inline TestInfo* currenTest = nullptr;
Система регистрации
На данный момент наши тесты фактически ничем не запускаются автоматически — пользователю пришлось бы вручную создавать структуру Test и самостоятельно добавлять её в какой-нибудь список. Нам же нужно обратное: написать функцию, которая выглядит совершенно обычно, и сделать так, чтобы она незаметно регистрировалась сама.
Хитрость заключается в том, чтобы отделить объявление от определения. Макрос объявляет функцию, регистрирует её, а затем открывает определение — пользователь заполняет тело функции:
#define TEST(Suite, Name) \
static void Suite##_##Name(); \
static dt::TestRegisterer \
Suite##_##Name##_registerer( \
#Suite, #Name, Suite##_##Name); \
static void Suite##_##Name()
Краткое пояснение по синтаксису: ## объединяет токены в один идентификатор (так что Suite##_##Name превращается в Math_Add), тогда как #Suite превращает токен в строковый литерал в стиле C ("Math").
Так как же TestRegisterer на самом деле что-либо регистрирует? Используя тот факт, что конструкторы запускаются автоматически — нам просто нужно, чтобы каждый TestRegisterer был статическим объектом, который, как гарантирует C++, будет создан до запуска main().
std::vector<Test>& GetTests() {
static std::vector<Test> tests;
return tests;
}
class TestRegisterer {
public:
TestRegisterer(std::string suite, std::string name, std::function<void()> func) {
GetTests().push_back({
std::move(suite),
std::move(name),
std::move(func)
});
}
};
При таком подходе TEST(Suite, Name) работает точно так же, как обычная функция, за исключением того, что она незаметно добавляет себя в глобальный реестр ещё до того, как main() даже запустится.
Нам пока не хватает утверждений. Чтобы их поддерживать, TestInfo нужно место для записи каждого утверждения по мере его появления. Здесь хорошо подойдет дек:
struct AssertionResult {
std::string assertionType; ///< Human-readable name of the assertion macro (e.g. "EXPECT_EQ").
bool passed; ///< True if the assertion succeeded.
std::string expected; ///< Expected value expression.
std::string actual; ///< Actual value or diagnostic string.
std::string file; ///< Source file where the assertion was evaluated.
unsigned int line; ///< Source line number of the assertion.
bool fatal = false; ///< True if the assertion is fatal and should stop the test.
};
TestInfo Теперь выглядит так:
struct TestInfo {
unsigned int status = PASSED;
double elapsedMs = 0.0;
std::deque<AssertionResult> assertions;
};
inline TestInfo* currenTest = nullptr;
Каждый макрос утверждения будет использовать указатель currenTest для доступа к деке того теста, который выполняется в данный момент.
Реализация утверждений
Теперь, когда модель данных готова, мы можем приступить к написанию самих макросов утверждений. Ключевым проектным решением является то, что нам потребуются два варианта: с фатальными и нефатальными ошибками.
ASSERT_*является фатальной ошибкой — она немедленно прерывает тест, возвращаясь из тестовой функции с помощьюreturn.EXPECT_*является некритической ошибкой — она фиксирует сбой, но позволяет тесту продолжить выполнение.
«Наивная» версия, до применения принципа DRY
Давайте начнём с «очевидного» варианта, именно так, как вы, вероятно, написали бы это в первый раз — по одному макросу на каждое сравнение, с полным развертыванием кода, без какого-либо совместного использования логики. Вот как могли бы выглядеть EXPECT_EQ и ASSERT_EQ, если бы мы просто написали каждый из них отдельно:
#define EXPECT_EQ(lhs, rhs) \
do { \
auto&& _lhs = (lhs); \
auto&& _rhs = (rhs); \
bool passed = (_lhs == _rhs); \
dt::AssertionResult result{ \
"EXPECT_EQ(" #lhs ", " #rhs ")", \
passed, \
#lhs " == " #rhs, \
dt::ToDisplayString(_lhs) + " vs " + dt::ToDisplayString(_rhs), \
__FILE__, __LINE__ \
}; \
if (result.passed) \
dt::CurrentTest->assertions.push_back(std::move(result)); \
else { \
dt::CurrentTest->status = FAILED; \
dt::CurrentTest->assertions.push_front(std::move(result)); \
} \
} while(false)
#define ASSERT_EQ(lhs, rhs) \
do { \
auto&& _lhs = (lhs); \
auto&& _rhs = (rhs); \
bool passed = (_lhs == _rhs); \
dt::AssertionResult result{ \
"ASSERT_EQ(" #lhs ", " #rhs ")", \
passed, \
#lhs " == " #rhs, \
dt::ToDisplayString(_lhs) + " vs " + dt::ToDisplayString(_rhs), \
__FILE__, __LINE__ \
}; \
if (result.passed) \
dt::CurrentTest->assertions.push_back(std::move(result)); \
else { \
dt::CurrentTest->status = FAILED; \
result.fatal = true; \
dt::CurrentTest->assertions.push_front(std::move(result)); \
return; \
} \
} while(false)
Присмотритесь внимательнее, и вы увидите, что EXPECT_EQ и ASSERT_EQ практически идентичны байт за байтом. Единственное реальное отличие заключается в трёх строках внизу (fatal = true и return).
А теперь представьте, что вы добавляете EXPECT_NE, ASSERT_NE, EXPECT_LT, ASSERT_LT и так далее. Каждый из них будет повторять весь этот блок, меняя только оператор и его строковую форму. Это огромное количество дублирующейся логики, которую нужно поддерживать: если вы обнаружите ошибку в том, как строится результирующая строка, вам теперь придётся исправлять её в дюжине мест вместо одного. Именно для устранения такого рода повторений и предназначена остальная часть этого раздела.
Шаг первый: выделение критерия «зачет/незачет»
Первым дублирующимся фрагментом, который нужно удалить, является логика «записать результат, изменить статус, принять решение о выходе из цикла», расположенная внизу каждого из приведённых выше макросов. Эта логика не зависит от того, что именно сравнивалось — для неё важно только то, прошла ли проверка и является ли она фатальной или нефатальной. Поэтому мы можем вынести её в два небольших макроса диспетчеризации:
#define DT_EXPECT_IMPL(result) \
do { \
if ((result).passed) \
dt::CurrentTest->assertions.push_back(std::move(result)); \
else { \
dt::CurrentTest->status = FAILED; \
dt::CurrentTest->assertions.push_front(std::move(result)); \
} \
} while(false)
#define DT_ASSERT_IMPL(result) \
do { \
if ((result).passed) \
dt::CurrentTest->assertions.push_back(std::move(result)); \
else { \
dt::CurrentTest->status = FAILED; \
(result).fatal = true; \
dt::CurrentTest->assertions.push_front(std::move(result)); \
return; \
} \
} while(false)
Здесь есть несколько моментов, которые легко упустить из виду, так что давайте не будем торопиться:
do { ... } while(false)обхватывает всё тело макроса. Это классическая идиома препроцессора C, а не случайная формальность. Подобный макрос разворачивается в несколько операторов (а именноif/else), и без оболочкиdo { } while(false)его вызов в видеif (x) EXPECT_EQ(a, b); else other();привел бы к незаметной ошибке: операторelseпривязался бы к внутреннему операторуifмакроса, а не к вашему. Обёрнув всё вdo { ... } while(false), вы превращаете всё расширение в одно выражение, так что оно ведёт себя точно так же, как обычный вызов функции, за которым следует точка с запятой, независимо от того, где вы его используете.- Добавление в конец
push_backв случае успеха, ноpush_frontпри сбое. Это сделано намеренно, как упоминалось выше: именно это позволяет консольному выводу сначала отображать сбои, просто проходя по списку от начала. std::move(result)позволяет избежать ненужного копирования объектаAssertionResult(содержащего несколько элементов типаstd::string) при передаче его в дек.- Единственное реальное отличие между этими двумя макросами заключается в том, что
DT_ASSERT_IMPLустанавливаетfatal = true, а затем выполняетreturn. Поскольку тестовая функция имеет типvoid, этот простой операторreturnпросто досрочно завершает выполнение тела теста. Именно по этой причине фикстуры позже оборачиваются в лямбду — операторreturnзавершает только тело теста, а не весь запускающий модуль.
Это устраняет нижнюю половину дублирования. Однако верхняя половина — вычисление lhs и rhs, построение AssertionResult, форматирование сообщения — по-прежнему дублируется между EXPECT_EQ и ASSERT_EQ, и будет дублироваться снова для каждого нового оператора сравнения, который мы добавим.
Шаг второй: вынести саму логику сравнения в отдельный блок
Чтобы устранить оставшиеся дубликаты, мы обобщаем само сравнение в один макрос, параметризованный оператором, а также макросом диспетчеризации (DT_EXPECT_IMPL или DT_ASSERT_IMPL), которому будет передана задача:
#define DT_BINARY_CHECK(macroname, lhs, rhs, op, opstr, revopstr, impl) \
do { \
auto&& _lhs = (lhs); \
auto&& _rhs = (rhs); \
std::string actual; \
\
if (!dt::IsLiteral(#lhs, _lhs) && !dt::IsLiteral(#rhs, _rhs)) { \
actual = \
std::string(#lhs) + " = " + dt::ToDisplayString(_lhs) + \
", " + \
#rhs + " = " + dt::ToDisplayString(_rhs); \
} else { \
actual = \
dt::ToDisplayString(_lhs) + " vs " + \
dt::ToDisplayString(_rhs); \
} \
\
bool passed = (_lhs op _rhs); \
\
dt::AssertionResult result{ \
macroname "(" #lhs ", " #rhs ")", \
passed, \
#lhs " " opstr " " #rhs, \
actual, \
__FILE__, \
__LINE__ \
}; \
\
impl(result); \
} while(false)
Это самый сложный макрос во всей библиотеке, поэтому давайте разберем его по частям — включая пару деталей, которые легко упустить из виду, не понимая, зачем они там нужны:
auto&& _lhs = (lhs)гарантирует, что каждая часть выражения вычисляется ровно один раз. Без этого в выражении типаEXPECT_EQ(foo(), bar())foo()иbar()будут встроены непосредственно в сравнение и, возможно, будут переоцениваться в других местах макроса, что может привести к их двукратному вызову (классическая ловушка макросов). Символ&&здесь является отсылкой вперёд, поэтому он работает независимо от того, является лиlhsлевым значением, правым значением или временной переменной, без его копирования.IsLiteral(#lhs, _lhs)— это небольшая эвристика, которая проверяет, выглядит ли исходный текст выражения как литеральное значение ("5") или как имя переменной ("x"). Она существует исключительно для удобства чтения сообщений об ошибках: если обе части являются литералами, выводx = 5, y = 10будет излишним и сбивающим с толку (ведь нет переменной с именемx—5и10являются самими значениями), поэтому вместо этого мы выводим5 vs 10. Если любая из сторон является именованной переменной, мы выводим как само выражение, так и его значение (x = 5, y = 10), потому что знание того, какая переменная содержала какое значение, — это именно то, что нужно при отладке неудачного теста.macroname— это строковый литерал, переданный из места вызова ("EXPECT_EQ"," ASSERT_EQ"и т. д.). Он объединяется со строковыми аргументами для формирования удобочитаемой метки, напримерEXPECT_EQ(x, y), которая и отображается в выводе в качестве имени утверждения.#lhsи#rhsиспользуют оператор преобразования в строку препроцессора, чтобы преобразовать литеральный исходный код каждого аргумента в строку. Поэтому, если вы напишетеEXPECT_EQ(count, 5),#lhsпревращается в строку"count", т.е. не в её значение, а в исходный текст. Именно так сообщение об ошибке может отображать имя переменной, а не только число, к которому она была вычислена.opstr— это удобочитаемая строка, обозначающая оператор ("==","!=","<"и т. д.), передаваемая отдельно от самого оператораop, посколькуopиспользуется в реальном коде (_lhs op _rhs), тогда какopstrвсегда встраивается только в сообщение — они не могут быть одним и тем же параметром макроса, поскольку один из них должен быть литеральным токеном, таким как==, а другой — строкой, такой как"==".revopstr— строка «обратного» оператора, например«!=»для==. Передается в качестве параметра, но фактически нигде внутриDT_BINARY_CHECK. Она зарезервирована для случаев, когда в более подробном сообщении об ошибке необходимо указать противоположное ожидаемому — например, вывод сообщений типа «ожидалось неравенство, но получено равенство». Если вам это не нужно, ее можно смело удалить из вашей собственной версии. Здесь эта строка сохранена в основном для того, чтобы показать, что список параметров макроса предусматривает не только текущие, но и будущие потребности в форматировании.__FILE__и__LINE__— это встроенные макросы препроцессора, а не то, что мы определили сами — компилятор заменяет их путем к текущему исходному файлу и номером строки в том месте, где макрос разворачивается. Именно это позволяет неудачному утверждению точно указать, где именно в вашем тестовом файле произошла ошибка, без какого-либо ручного учёта.impl(result)— вот результат всей этой конструкции:impl— это само по себе имя макроса, передаваемое в качестве параметра (либоDT_EXPECT_IMPL, либоDT_ASSERT_IMPL), поэтому именно эта одна строка направляет готовыйAssertionResultк правильному поведению (с фатальной или нефатальной ошибкой), которое мы определили на первом шаге — безDT_BINARY_CHECKнеобходимости знать или заботиться о том, какое именно это поведение.
Подведение итогов
После вынесения этих двух фрагментов определение фактических макросов, которые вызывают пользователи, сводится теперь к простому созданию псевдонимов — по одной строке на каждый, без какого-либо дублирования логики:
#define EXPECT_EQ(lhs, rhs) DT_BINARY_CHECK("EXPECT_EQ", lhs, rhs, ==, "==", "!=", DT_EXPECT_IMPL)
#define ASSERT_EQ(lhs, rhs) DT_BINARY_CHECK("ASSERT_EQ", lhs, rhs, ==, "==", "!=", DT_ASSERT_IMPL)
Сравните это с двумя полными макротелами, с которых мы начали этот раздел. Добавление EXPECT_NE или ASSERT_LT теперь занимает по одной строке, а не по тридцать.
#define EXPECT_NE(lhs, rhs) DT_BINARY_CHECK("EXPECT_NE", lhs, rhs, !=, "!=", "==", DT_EXPECT_IMPL)
#define ASSERT_LT(lhs, rhs) DT_BINARY_CHECK("ASSERT_LT", lhs, rhs, <, "<", ">=", DT_ASSERT_IMPL)
В этом и заключается весь смысл задачи: мы не начали с DT_BINARY_CHECK просто потому, что это умно — мы пришли к этому, заметив, какие именно строки постоянно повторялись, и вынеся только эти строки в общий макрос.
Расписание: TEST_F
TEST(Suite, Name) хорошо справляется с простыми случаями, но как быть с тестами, в которых используется общая логика подготовки и очистки? Копирование и вставка одного и того же кода инициализации в каждый тест — это именно тот вид повторений, которого мы пытаемся избежать, и именно эту проблему решают фикстуры.
Идея: определить класс, содержащий общее состояние, переопределить SetUp() и TearDown(). Для реализации логики «до» и «после» нужно написать тесты, использующие эту фикстуру. Затем фреймворк должен создать экземпляр фикстуры, вызвать SetUp(), выполнить тело теста и вызвать TearDown() — и всё это автоматически, для каждого теста.
Для начала — базовый класс:
class TestFixture {
public:
virtual ~TestFixture() = default;
virtual void SetUp(); // default: empty body
virtual void TearDown(); // default: empty body
};
Реализации по умолчанию специально оставлены пустыми — не каждой фикстуре требуются как настройка, так и очистка, поэтому пользователь переопределяет только то, что ему действительно нужно.
TEST_F более сложен, чемTEST, поскольку ему приходится за один раз сгенерировать класс, лямбду и регистрацию:
#define TEST_F(Fixture, Name) \
class Fixture##_##Name##_Test : public Fixture { \
static_assert(std::is_base_of_v<dt::TestFixture, Fixture>, \
"Fixture must derive from dt::TestFixture"); \
public: \
void TestBody(); \
}; \
\
static dt::TestRegisterer \
Fixture##_##Name##_registerer( \
#Fixture, \
#Name, \
[]() { \
Fixture##_##Name##_Test test; \
test.SetUp(); \
test.TestBody(); \
test.TearDown(); \
}); \
\
void Fixture##_##Name##_Test::TestBody()
Пошагово:
- Макрос генерирует класс
Fixture_Name_Test, который наследуется от фикстуры пользователя.static_assertотлавливает ошибку, если пользователь забыл унаследовать свою фикстуру отTestFixture. - Он объявляет
TestBody()как метод класса; тело этого метода пользователь предоставляет сразу после вызова макроса. - Он создает статический объект
TestRegisterer, вызываемым объектом которого является лямбда-функция. Эта лямбда создает свежий экземпляр фикстуры на стеке, а затем последовательно вызываетSetUp(),TestBody()иTearDown(). - Пользователь пишет собственно тело функции
TestBody()сразу после макроса.
Важная деталь: для каждого прогона теста внутри лямбда-функции создается новый экземпляр фикстуры, поэтому тесты никогда не передают свое состояние друг другу. И поскольку TearDown() выполняется после завершения TestBody() — даже если TestBody() завершился досрочно из-за фатального оператора return в утверждении, — очистка происходит всегда. Это дает ту же гарантию, что и концепция RAII: деструктор фикстуры сработает в любом случае, даже если сам метод TearDown() выбросит исключение.
Параметризованные тесты: TEST_PARAM + INSTANTIATE
Иногда возникает необходимость применить одну и ту же тестовую логику к списку различных значений. Вместо того чтобы писать N почти одинаковых тестов, вы пишете эту логику один раз в виде функции, принимающей один параметр, и применяете её к N значениям.
#define TEST_PARAM(Suite, Name, DataType) \
static void Suite##_##Name(DataType param)
Этот макрос специально сделан скучным — он просто объявляет статическую функцию, принимающую один параметр. Настоящая работа происходит в INSTANTIATE:
#define INSTANTIATE(Suite, Name, values) \
static dt::ParamRegisterer< \
decltype(values), \
decltype(&Suite##_##Name)> \
Suite##_##Name##_param_registerer( \
#Suite, #Name, values, Suite##_##Name)
А вот и ParamRegisterer:
template<typename Container, typename Func>
class ParamRegisterer {
public:
ParamRegisterer(std::string suite, std::string name,
const Container& values, Func func)
{
size_t index = 1;
for (const auto& value : values) {
GetTests().push_back({
suite,
name + "." + std::to_string(index++),
[func, value]() {
func(value);
}
});
}
}
};
Конструктор обходит контейнер values в цикле и регистрирует по одному тесту для каждого значения. Вызываемым объектом каждого теста является лямбда-функция, которая захватывает по значению (по копии) как указатель на функцию, так и само значение — благодаря чему тесты выполняются независимо друг от друга. Имена тестов автоматически получают числовые суффиксы — Math.IsFive.1, Math.IsFive.2 и так далее.
Тесты с вводом данных: TYPED_TEST
Что делать, если тестируемый код представляет собой шаблон, и вам нужно запустить один и тот же тест для типов int, float и double? Вы могли бы написать отдельный TEST для каждого типа, но это как раз и есть то повторение, которого мы стараемся избежать. Типизированные тесты решают эту проблему так же, как параметризованные тесты решают её для значений — только в этом случае мы просматриваем типы по циклу.
Во-первых, контейнер для хранения списка типов:
template<typename... Ts>
struct TypeList {};
Затем TypeRegisterer, который использует выражение fold из C++17 для регистрации одного теста на каждый тип:
template<typename... Ts, typename Func>
class TypeRegisterer<TypeList<Ts...>, Func>
{
public:
TypeRegisterer(std::string suite, std::string name, Func func) {
(Register<Ts>(suite, name, func), ...); // fold expression
}
private:
template<typename T>
static void Register(std::string suite, std::string name, Func func) {
GetTests().push_back({
suite,
name + "." + GetTypeName<T>(),
[func]() {
func.template operator()<T>();
}
});
}
};
Выражение свёртки (fold expression) (Register<Ts>(...), ...) развертывается в Register<int>(...), Register<float>(...), Register<double>(...), по одному вызову на каждый тип в TypeList. Каждый вызов регистрирует тест, к имени которого добавляется суффикс с типом, например Math.ZeroValue.int.
Получение читаемого имени типа — это небольшая платформенно-зависимая деталь: GetTypeName<T>() использует RTTI (typeid) вкупе со специфичным для конкретной платформы деманглингом. MSVC вырезает добавляемые им префиксы "class " и "struct ", в то время как GCC и Clang используют abi::__cxa_demangle.
Сам макрос объединяет все это воедино:
#define TYPED_TEST(Suite, Name, Types) \
template<typename TypeParam> \
void Suite##_##Name##_body(); \
\
struct Suite##_##Name##_caller { \
template<typename T> \
void operator()() const { \
Suite##_##Name##_body<T>(); \
} \
}; \
\
static dt::TypeRegisterer<Types, Suite##_##Name##_caller> \
Suite##_##Name##_registerer(#Suite, #Name, {}); \
\
template<typename TypeParam> \
void Suite##_##Name##_body()
Он создаёт небольшую структуру caller, чей шаблонный operator() перенаправляет вызов в тело функции. Затем TypeRegisterer вызывает этот оператор один раз для каждого типа в TypeList. Пользователь пишет тело функции Suite_Name_body<TypeParam>() после макроса, при этом TypeParam доступен внутри в качестве текущего тестируемого типа.
Средство запуска тестов
После регистрации всех типов тестов нам понадобится что-то, что будет их запускать. Программа-запускатель оказалась удивительно простой: она собирает соответствующие тесты из глобального реестра, запускает каждый из них, измеряя время выполнения, и возвращает вектор объектов Result.
std::vector<Result> RunTests(const Filter& filter = {}, const RunConfig& config = {})
{
std::vector<Result> results;
std::vector<Test> testsToRun = detail::CollectMatching(filter);
for (unsigned int i = 0; i < config.repeat; ++i) {
if (config.shuffle)
detail::ShuffleTests(testsToRun, config.seed, i);
for (const auto& test : testsToRun) {
Result result;
CurrentTest = &result.info; // <-- sets the global pointer
result.test = test;
auto begin = std::chrono::steady_clock::now();
test.func(); // <-- runs the test
auto end = std::chrono::steady_clock::now();
result.info.elapsedMs =
std::chrono::duration<double, std::milli>(end - begin).count();
results.push_back(std::move(result));
}
}
return results;
}
Строка, которая связывает всё воедино, — это CurrentTest = &result.info. Именно благодаря установке этого глобального указателя непосредственно перед вызовом test.func() каждый макрос проверки в любой части кода знает, в какой TestInfo необходимо записать данные. Это простая, хотя и не слишком эффектная, форма инъекции зависимостей через глобальное состояние.
Внешний цикл повторяет весь прогон config.repeat раз. При включенной перестановке тесты переупорядочиваются с использованием std::mt19937 с начальным значением seed + iteration, что обеспечивает детерминированный, но изменяющийся порядок при повторении, если указано начальное значение, или истинную случайность с помощью std::random_device, если оно не указано.
Система фильтрации
Программа-запускатель не выполняет слепо все зарегистрированные тесты. Сначала она обращается к Filter, который может отбирать тесты по названию набора, полному названию теста или шаблону с подстановой.
struct Filter {
std::unordered_set<std::string> searchParam; // glob patterns
unsigned int flags = ALL; // status bitmask
};
Сопоставление происходит в четком порядке приоритетов:
bool Match(const Filter& filter, const Test& test) {
if ((ALL & filter.flags) == 0) return false;
if (filter.searchParam.empty()) return true; // no filter = match all
std::string fullName = test.suite + "." + test.name;
for (const auto& param : filter.searchParam) {
if (param == test.suite) return true; // exact suite
if (param == fullName) return true; // exact full name
if (detail::GlobMatch(param, fullName)) return true; // glob on full name
if (detail::GlobMatch(param, test.suite)) return true; // glob on suite
}
return false;
}
Сопоставление по маске (glob matching) поддерживает символы ? (любой один символ) и * (ноль или более символов) и реализовано с помощью классического алгоритма с двумя указателями и возвратом (backtracking).
Программа для отображения консоли
После завершения тестов кто-то должен отобразить результаты. ConsoleDisplayer выводит данные в терминал с использованием цветовых кодов ANSI и имеет лаконичную структуру: три частных вспомогательных метода и одна общедоступная точка входа.
class ConsoleDisplayer {
private:
static void PrintAssertion(const std::deque<AssertionResult>& assertions);
static void PrintStatus(const Result& result);
static void PrintSummary(size_t total, size_t passed, size_t failed, size_t assertions);
public:
static void Display(const std::vector<Result>& results);
};
Общий алгоритм выглядит следующим образом:
Display(results)
for each result:
count assertions
count passed/failed
PrintStatus(result)
prints "[ RUN ] Suite.Name" in blue/bold
if PASSED: prints "[ OK ] Suite.Name (X.XXXX ms)" in green
if FAILED: prints "[ FAILED ] Suite.Name (X.XXXX ms)" in red
if FAILED: PrintAssertion(assertions)
for each FAILED assertion (in reverse order):
prints " file:line" in red
prints " Assertion : MACRO" in yellow
prints " Expected : expr" in yellow
prints " Actual : value" in yellow
if fatal: prints " Test stopped due to fatal error" in red
PrintSummary(total, passed, failed, assertions)
Обратите внимание, что утверждения выводятся в обратном порядке (от rbegin до rend). Именно поэтому ранее мы помещали фатальные утверждения в начало дека: обход в обратном порядке выводит их первыми, так что наиболее критический сбой оказывается первым, что увидит пользователь.
Добавление новых дисплееров
Система дисплееров намеренно сделана расширяемой, но без привычных издержек на абстрактный базовый класс или виртуальный интерфейс. Каждый дисплеер — это просто отдельный класс со статическим методом Display(): таблицы виртуальных функций (vtable) незачем использовать для того, что запускается всего один раз в самом конце прогона тестов.
Чтобы добавить новый дисплеер (например, для XML, JSON или вашего собственного пользовательского формата):
1. Создайте новый заголовок в файле include/DuckTest/Displayer/YourDisplayer.h:
class YourDisplayer {
private:
static void WriteSomething(std::ostream& out, ...);
public:
static void Display(const std::vector<Result>& results,
const std::string& outputPath = "output.ext");
};
2. Реализуйте это в src/DuckTest/Displayer/YourDisplayer.cpp:
void YourDisplayer::Display(const std::vector<Result>& results,
const std::string& outputPath)
{
std::ofstream out(outputPath);
// iterate results, write your format
// for failed tests, iterate result.info.assertions
// write summary
}
3. Добавьте исходный файл в файл CMakeLists.txt, расположенный в каталоге с исходными кодами библиотеки DuckTest.
4. Добавьте #include "DuckTest/Displayer/YourDisplayer.h" в общий заголовочный файл DuckTest.h.
5. Реализуйте это в CLI.cpp — добавьте флаг в CommandLine, обработайте его в ParseCommandLine и вызовите YourDisplayer::Display() из CLI::Run().
Весь контракт интерфейса сводится к одному предложению: возьмите std::vector<Result> и делайте с ним всё, что хотите. Каждый Result содержит дескриптор Test (набор тестов, имя) и TestInfo (статус, время выполнения, утверждения) — всё, что нужно для отображения результатов.
CLI
CLI — это связующее звено, которое объединяет все компоненты: он анализирует аргументы командной строки, настраивает программу запуска и передает данные тем средствам отображения, которые были запрошены.
Во-первых, структура для хранения проанализированных аргументов:
struct CommandLine {
bool help = false;
bool version = false;
bool listTests = false;
bool shuffle = false;
std::string filterPattern;
unsigned int repeatCount = 1;
unsigned long long randomSeed = 0;
bool consoleOutput = true; // default: always on
bool markdownOutput = false;
bool htmlOutput = false;
bool junitOutput = false;
std::string markdownFile = "test_results.md";
std::string htmlFile = "test_results.html";
std::string junitFile = "test_results.xml";
};
Сам парсер представляет собой простой цикл, проходящий по argv:
CommandLine ParseCommandLine(int argc, char* argv[]) {
CommandLine cmd;
for (int i = 1; i < argc; ++i) {
const char* arg = argv[i];
if (strcmp(arg, "--help") == 0)
cmd.help = true;
else if (strcmp(arg, "--version") == 0)
cmd.version = true;
else if (strcmp(arg, "--list") == 0)
cmd.listTests = true;
else if (strcmp(arg, "--shuffle") == 0)
cmd.shuffle = true;
else if (strncmp(arg, "--filter=", 9) == 0)
cmd.filterPattern = arg + 9;
else if (strncmp(arg, "--repeat=", 9) == 0)
cmd.repeatCount = ParseULL(arg + 9, "--repeat");
else if (strncmp(arg, "--seed=", 7) == 0)
cmd.randomSeed = ParseULL(arg + 7, "--seed");
else if (strncmp(arg, "--markdown", 10) == 0) {
cmd.markdownOutput = true;
std::string val = ExtractValue(arg, "--markdown");
if (!val.empty()) cmd.markdownFile = val;
}
else if (strncmp(arg, "--html", 6) == 0) {
cmd.htmlOutput = true;
std::string val = ExtractValue(arg, "--html");
if (!val.empty()) cmd.htmlFile = val;
}
else if (strncmp(arg, "--junit", 7) == 0) {
cmd.junitOutput = true;
std::string val = ExtractValue(arg, "--junit");
if (!val.empty()) cmd.junitFile = val;
}
else
throw std::runtime_error("Unknown option: " + arg);
}
return cmd;
}
strcmp обрабатывает точные совпадения, такие как --help и --version, тогда как strncmp обрабатывает префиксные совпадения, такие как --filter=, --repeat= и --markdown. Вспомогательная функция ExtractValue извлекает всё, что следует за знаком =.
И, наконец, основной пункт, который связывает всё воедино:
class CLI {
public:
static int Run(int argc, char* argv[]);
private:
static void PrintHelp();
static void PrintVersion();
static void PrintTestList();
static std::unordered_set<std::string> ParseFilterPattern(const std::string& pattern);
};
И сама функция Run():
int CLI::Run(int argc, char* argv[]) {
CommandLine cmd;
try {
cmd = ParseCommandLine(argc, argv);
}
catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n\n";
PrintHelp();
return 1;
}
if (cmd.help) { PrintHelp(); return 0; }
if (cmd.version) { PrintVersion(); return 0; }
if (cmd.listTests) { PrintTestList();return 0; }
Filter filter;
filter.searchParam = ParseFilterPattern(cmd.filterPattern);
RunConfig config;
config.repeat = cmd.repeatCount;
config.shuffle = cmd.shuffle;
config.seed = cmd.randomSeed;
std::vector<Result> allResults = RunTests(filter, config);
if (cmd.consoleOutput)
ConsoleDisplayer::Display(allResults);
if (cmd.markdownOutput)
MarkdownDisplayer::Display(allResults, cmd.markdownFile);
if (cmd.htmlOutput)
HtmlDisplayer::Display(allResults, cmd.htmlFile);
if (cmd.junitOutput)
JUnitDisplayer::Display(allResults, cmd.junitFile);
for (const auto& result : allResults) {
if (result.info.status == FAILED)
return 1;
}
return 0;
}
Если читать сверху вниз, последовательность действий следующая:
- Обработать аргументы, перехватить возможные ошибки и вывести справку, если что-то пошло не так.
- Обработать информационные флаги —
--help,--version,--list— и завершить работу досрочно, если был запущен любой из них. - Создать
фильтриконфигурацию запускана основе проанализированных аргументов. - Вызвать
RunTests(), чтобы запустить все, что соответствует критериям. - Передать результаты каждому участнику, у которого включен дисплеер.
- Вернуть
0в случае успешного выполнения,1в случае сбоя — стандартная конвенция для кодов завершения работы оболочки.
А вот функция main() по умолчанию, с которой пользователи связывают свои программы:
int main(int argc, char* argv[]) {
return dt::CLI::Run(argc, argv);
}
Любой, кому требуется собственная функция main() — например, для выполнения дополнительной настройки перед запуском тестов — может не подключать DuckTestMain и вызывать RunTests() напрямую, чтобы получить полный контроль.
Полная картина
Давайте проследим весь процесс от начала до конца на самом простом примере:
TEST(Math, Add) {
EXPECT_EQ(1 + 1, 2);
}
Вот что именно происходит, в порядке последовательности:
- Макрос
TESTразвертывается в предварительное объявлениеMath_Add(), статическийTestRegistererи начало тела функции. - До запуска
main()в ходе статической инициализации вызывается конструкторTestRegisterer, который добавляет{"Math", "Add", Math_Add}в глобальный реестр. main()вызываетCLI::Run(), который анализирует аргументы и вызываетRunTests().- Функция
RunTests()проходит по реестру, находит наш тест, устанавливаетCurrentTest = &result.infoи вызываетMath_Add(). - Внутри функции
Math_Add()операторEXPECT_EQразворачивается с помощьюDT_BINARY_CHECK, вычисляет1 + 1и2, сравнивает их с помощью==и передаёт результат вDT_EXPECT_IMPL. - Условие выполняется, поэтому результат помещается в конец дека.
- Функция
RunTests()измеряет время выполнения и возвращает результат завершения. CLI::Run()передаёт результаты вConsoleDisplayer::Display(), который выводит:
[ RUN ] Math.Add [ OK ] Math.Add (0.0001 ms)
[==========] 1 tests ran. [ PASSED ] 1 [ FAILED ] 0 [ASSERTIONS] 1
И это вся система от начала и до конца — разворачивание макросов, статическая регистрация, выполнение в раннере и вывод результатов. Никакой магии, лишь несколько возможностей C++ (статическая инициализация, манипуляции с токенами ## и #, выражения свёртки и RAII), аккуратно скомпонованных между собой так, что они всё равно ощущаются как магия.
Если вы соберёте такую систему самостоятельно, вы больше никогда не будете смотреть на TEST(Suite, Name) по-прежнему.
GitHub: https://github.com/Ali-Wael-Hassan/DuckTest/tree/main
Перевод статьи «Let’s Build a Unit Testing Library From Scratch».