Главная » Хабрахабр » [Из песочницы] Тесты на C++ без макросов и динамической памяти

[Из песочницы] Тесты на C++ без макросов и динамической памяти

Test тяжело завязаны на использование макросов, так что в качестве примера тестов на этих библиотеках вы обычно увидите картину вроде такой: Многие популярные библиотеки для тестирования, например Google Test, Catch2, Boost.

namespace { // Tests the default c'tor.
TEST(MyString, DefaultConstructor) { const MyString s; EXPECT_STREQ(nullptr, s.c_string()); EXPECT_EQ(0u, s.Length());
} const char kHelloString[] = "Hello, world!"; // Tests the c'tor that accepts a C string.
TEST(MyString, ConstructorFromCString) { const MyString s(kHelloString); EXPECT_EQ(0, strcmp(s.c_string(), kHelloString)); EXPECT_EQ(sizeof(kHelloString)/sizeof(kHelloString[0]) - 1, s.Length());
} // Tests the copy c'tor.
TEST(MyString, CopyConstructor) { const MyString s1(kHelloString); const MyString s2 = s1; EXPECT_EQ(0, strcmp(s2.c_string(), kHelloString));
}
} // namespace

К макросам в C++ отношение настороженное, почему же они так процветают в библиотеках для создания тестов?

Когда вы подумаете о том как это сделать, то использование макросов кажется кажется проще всего. Библиотека юнит-тестов должна предоставить её пользователям способ написания тестов, так чтобы среда выполнения тестов могла их как-то найти и выполнить. Макрос TEST() обычно как-то определяет функцию (в случае с Google Test макрос также создает класс) и обеспечивает попадание адреса этой функции в какой-нибудь глобальный контейнер.

Посмотрим её пример из туториала: Хорошо известная мне библиотека, в которой реализован подход без единого макроса, это tut-framework.

#include <tut/tut.hpp> namespace tut
; typedef test_group<basic> factory; typedef factory::object object;
} namespace
{ tut::factory tf("basic test");
} namespace tut
{ template<> template<> void object::test<1>() { ensure_equals("2+2=?", 2+2, 4); }
}

Если вкратце, то у вас базовый класс, в котором реализована шаблонная функция, которая предполагает параметризацию целым числом: Идея которая лежит в основе довольно интересная и работает это все не очень сложно.

template <class Data>
class test_object : public Data
{ /** * Default do-nothing test. */ template <int n> void test() { called_method_was_a_dummy_test_ = true; }
}

Теперь когда вы пишете такой тест:

template<> template<>
void object::test<1>()
{ ensure_equals("2+2=?", 2+2, 4);
}

Вызвав test<N>() среда исполнения тестов может понять был ли это реальный тест или это была заглушка глядя на значение called_method_was_a_dummy_test_ после исполнения теста. Вы фактически создаете специализацию тестового метода для конкретного числа N=1 (именно для этого стоят template<>template<>).

Далее, когда вы объявляете группу тестов:

tut::factory tf("basic test");

Вы, во-первых, совершаете перечисление всех test<N> до некоторой константы, зашитой в библиотеку, и, во-вторых, побочным эффектом добавляете в глобальный контейнер информацию о группе (имя группы и адреса всех тестовых функций).

Мне нравится такой подход, любому разработчику C++ становится сразу понятно, где можно использовать такие ассерты. В качестве условий проверки в tut используются исключения, так что функция tut::ensure_equals() просто бросит исключение если переданные ей два значения не будут равны, а среда запуска теста поймает исключение и засчитает тест как failed. Кроме того, мне понятно, что мой тест должен иметь возможность освободить ресурсы в случае возникновения исключения, как будто это обычный exception-safe код. Например, если мой тест создал вспомогательный поток, то там ассерты расставлять бесполезно, их никто не поймает.

Например, для моего случая я бы хотел, чтобы у теста был бы не только номер, но и другие атрибуты, в частности имя, а также "размер" теста (например интеграционный ли это тест или это unit тест). В принципе библиотека tut-framework выглядит довольно неплохо, но в её реализации есть некоторые недостатки. Это решаемо в рамках API tut, и даже что-то уже есть, а что-то можно реализовать, если добавить в API библиотеки метод, а в тело теста его вызов чтобы установить какие-нибудь его параметры:

template<> template<>
void object::test<1>()
{ set_name("2+2"); // Set test name to be shown in test report ensure_equals("2+2=?", 2+2, 4);
}

Среда выполняет object::test<N>() и она заранее не знает реализован ли тест для данного N, или это просто заглушка. Другая проблема в том, что среда запуска тестов tut ничего не знает о таком событии как начало теста. Эта особенность не очень хорошо показывает себя в системах CI, которые умеют группировать вывод, который делала программа между началом и окончанием теста. Узнает она только когда тест закончится, проанализировав значение called_method_was_a_dummy_test_.

В tutorial tut-framework довольно много всего: предлагается сначала создать некий класс struct basic{}, а тесты описывать как методы объекта связанные с этим. Однако на мой взгляд главная вещь которую можно улучшить ("фатальный недостаток") это наличие лишнего вспомогательного кода, требуемого для написания тестов. На моей практике работы с tut этот объект почти всегда пустой, однако он тащит за собой какое-то количество строк кода. В этом классе можно определить методы и данные, которые вы хотите использовать в группе тестов, а конструктор и деструктор обрамляют выполнение теста, создавая такую штуку как fixture из jUnit.

Итак, заходим в вело-мастерскую и пробуем оформить идею в виде небольшой библиотеки.

Вот так выглядит минимальный файл теста в библиотеке "tested":

// Test group for std::vector (illustrative purposes)
#include "tested.h"
#include <vector> template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime)
{ runtime->StartCase("emptiness"); std::vector<int> vec; tested::Is(vec.empty(), "Vector must be empty by default");
} template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime)
{ runtime->StartCase("AddElement"); std::vector<int> vec; vec.push_back(1); tested::Is(vec.size() == 1); tested::Is(vec[0] == 1); tested::FailIf(vec.empty());
} void LinkVectorTests()
{ static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);
}

Кроме отсутствия макросов бонусом идет отсутствие использование динамической памяти внутри библиотеки.

Определение тест-кейсов

Где-то в tested.h есть шаблонная функция такого вида: Для регистрации тестов используется шаблонная магия начального уровня на том же принципе что и tut.

template <int N> static void Case(IRuntime* runtime) { throw TheCaseIsAStub(); }

Функция объявлена статической, т.е. Тест кейсы, которые пишут пользователи библиотеки — это просто специализации этого метода. в каждом translation unit мы создаем специализации, которые не пересекаются по именам друг с другом при линковке.

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

Во-первых, если тесты сейчас в режиме запуска, то вы можете сообщить куда-то о том, что тест начал выполнение. Когда тест вызывает runtime->StartTest() могут случится интересные вещи. Во-вторых, если сейчас идет режим сбора информации о доступных тестах StartTest() выбросит специального рода исключение которое будет означать что тест реальный, а не заглушка.

Регистрация

В tested это делается с помощью групп. В какой-то момент нужно собрать адреса всех тест-кейсов и где-то их сложить. Делает это конструктор класса tested::Group в виде побочного эффекта:

static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);

Получается, что в одном translation unit у вас не может быть две группы. Конструктор создает группу с указанным именем и добавляет в нее все кейсы Case<N> которые найдет в текущем translation unit. Это значит также что вы не можете одну группу разбить на несколько translation units.

Параметром шаблона идет сколько тест-кейсов искать в текущем translation unit для создаваемой группы.

Линковка

В приведенном примере создание объекта tested::Group() происходит внутри функции, которую мы должны позвать из нашего приложения чтобы зарегистрировать тесты:

void LinkStdVectorTests()
{ static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);
}

Однако мой опыт такой что линкер иногда "оптимизирует" файл целиком, если он собран внутри библиотеки, и никто из основного приложения не использует каких-либо символов этого cpp файла: Функция не всегда требуется, иногда можно просто объявить объект класса tested::Group внутри файла.

calc.lib <- calc_test.lib(calc_test.cpp) ^ ^ | |
app.exe run_test.exe

Когда из исходников run_test.exe никак не связываются calc_test.cpp, то линкер просто убирает этот файл из рассмотрения целиком, вместе с созданием статического объекта, не смотря на то, что него есть нужные нам побочные эффекты.

Причем неважно как именно это будет сделано, как в примере: Если какая цепочка приводит из run_test.exe, то статический объект появится в исполняемом файле.

void LinkStdVectorTests()
{ static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);
}

или так:

static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);
void LinkStdVectorTests()
{
}

Первый вариант на мой взгляд лучше тем что вызов конструктора выполняется после начала работы main(), и у приложения есть некоторый контроль над этим процессом.

Однако наверное ее можно избежать если линковать тестовую библиотеку с ключом --whole-archive (аналог в MSVC появился только в Visual Studio 2015. Я думаю что эта установка костылей требуется для любой библиотеки юнит-тестирования, которая использует глобальные переменные и побочные эффекты конструктора для создании базы тестов. 3).

Макросы

Рабочий вариант что это используется __COUNTER__, макрос, который компилятор увеличивает на один каждый раз, когда он используется внутри translation unit.
Поддерживается GCC, CLANG, MSVC, но не стандартом. Я обещал, что здесь не будет макросов, но он есть — CASE_COUNTER. Если это расстраивает, то вот какие есть альтернативы:

  • использовать цифры 0, 1, 2
  • использовать стандартный __LINE__.
  • использовать constexpr магию 80 уровня. Можно поискать "constexpr counter" и попытаться найти компилятор, на котором это будет работать.

Именно поэтому я ограничил тип шаблона signed char, получая 128 как максимальное количество тестов в группе. Проблема с __LINE__ в том, что использование больших чисел в параметрах шаблона создает большой размер исполняемого файла.

Отказ от динамической памяти

Возможно, в вашей среде нет динамической памяти или вы используете поиск утечек памяти в тест-кейсах, так что вмешательство среды исполнения тестов — это не то, что вам нужно. Оказалось, что при регистрации тестов можно не использовать динамическую память, чем я и воспользовался. Google Test с этим борется, вот фрагмент оттуда:

// Use the RAII idiom to flag mem allocs that are intentionally never
// deallocated. The motivation is to silence the false positive mem leaks
// that are reported by the debug version of MS's CRT which can only detect
// if an alloc is missing a matching deallocation.
// Example:
// MemoryIsNotDeallocated memory_is_not_deallocated;
// critical_section_ = new CRITICAL_SECTION;
class MemoryIsNotDeallocated

А мы можем просто не создавать трудностей.

Это больше технические внутренности, которые проще посмотреть в исходном коде, но я все равно расскажу. Как же мы тогда получаем список тестов?

Вот как это устроенно: При создании группы ее класс получит указатель на функцию tested::CaseCollector<CASE_COUNTER>::collect, которая соберет все тесты translation unit в список.

// Make the anonymouse namespace to have instances be hidden to specific translation unit namespace { template <Ordinal_t N> struct CaseCollector { // Test runtime that collects the test case struct CollectorRuntime final : IRuntime { void StartCase(const char* caseName, const char* description = nullptr) final { // the trick is exit from test case function into the collector via throw throw CaseIsReal(); } }; // Finds the Case<N> function in current translation unit and adds into the static list. It uses the // reverse order, so the case executed in order of appearance in C++ file. static CaseListEntry* collect(CaseListEntry* tail) { CaseListEntry* current = nullptr; CollectorRuntime collector; try { Case<N>(&collector); } catch (CaseIsStub) { current = tail; } catch (CaseIsReal) { s_caseListEntry.CaseProc = Case<N>; s_caseListEntry.Next = tail; s_caseListEntry.Ordinal = N; current = &s_caseListEntry; } return CaseCollector<N - 1>::collect(current); } private: static CaseListEntry s_caseListEntry; }; // This static storage will be instantiated in any cpp file
template <Ordinal_t N> CaseListEntry CaseCollector<N>::s_caseListEntry; }

Примерно таким же образом список формирует группы тестов, но без шаблонов и рекурсии.
Получается что в каждом translation unit создается много статических переменных вида CaseListEntry CaseCollector\::s_caseListEntry, которые являются элементами списка тестов, а метод collect() собирает эти элементы в односвязный список.

Структура

У меня есть виденье как это можно сделать, которое отличается от того, что я видел раньше в библиотека тестирования. Тестам нужна различная обвязка, вроде вывода в консоль красными буквами Failed, создание тест-репортов в формате понятном для CI или GUI в котором можно посмотреть список тестов и запустить выбранные — в общем много чего. Претензия главным образом к библиотекам которые называют себя "header-only", при этом включая большой объем кода, который по сути совсем не для заголовочных файлов.

Для написание тестов нужен только tested.h, который сейчас C++17 (из-за здоровского std::string_view) но предполагается что будет C++98. Подход, который я предполагаю в том, что мы разделяем библиотеку на front-end — эта сама tested.h и back-end — библиотеки. Back-end библиотеки, которых еще не существует, могут делать все что нужно, в плане вывода результатов и запуска, использовав функционал экспорта. Tested.h осуществляет фактически регистрацию и поиск тестов, минимально удобный вариант запуска, а также возможность экспорта тестов (группы, адреса функций тест-кейсов). Таким же образом можно приспособить запуск под нужды своего проекта.

Итог

В ближайших планах добавить возможность выполнения асинхронных тестов (нужно для интеграционный тестов в WebAssembly) и указания размера тестов. Библиотеке tested (код на github) предстоит еще некоторая стабилизация. Интересно ли вам было бы воспользоваться такого рода библиотекой? На мой взгляд библиотека еще не вполне готова к production применению, но я потратил уже неожиданно много времени и наступил такой этап чтобы остановится, перевести дух и спросить обратной связи от сообщества. Интересна ли вообще такая постановка задачи? Может быть в арсенале С++ есть какие-нибудь еще идеи как было бы можно создать библиотеку без макросов?


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Крупнейший дамп в истории: 2,7 млрд аккаунтов, из них 773 млн уникальных

Каждый может проверить там свой email на предмет утечки. Известный специалист по безопасности Трой Хант уже несколько лет поддерживает сайт Have I Been Pwned (HIBP) с миллионами записей об украденных аккаунтов. Но он никогда не видел, чтобы на продажу выставляли ...

Подборка @pythonetc, декабрь 2018

Это седьмая подборка советов про Python и программирование из моего авторского канала @pythonetc. Предыдущие подборки: Множественные контексты Иногда бывает нужно запустить какой-то блок кода в нескольких менеджерах контекста: with open('f') as f: with open('g') as g: with open('h') as h: ...