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

в 16:58, , рубрики: c++, Программирование, тесты

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

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
{
    struct basic{};
    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);
}

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

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

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

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

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

В принципе библиотека tut-framework выглядит довольно неплохо, но в её реализации есть некоторые недостатки. Например, для моего случая я бы хотел, чтобы у теста был бы не только номер, но и другие атрибуты, в частности имя, а также "размер" теста (например интеграционный ли это тест или это unit тест). Это решаемо в рамках 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);
}

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

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

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

Вот так выглядит минимальный файл теста в библиотеке "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__);
}

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

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

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

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__);

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

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

Линковка

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

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

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

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).

Макросы

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

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

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

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

Оказалось, что при регистрации тестов можно не использовать динамическую память, чем я и воспользовался. Возможно, в вашей среде нет динамической памяти или вы используете поиск утечек памяти в тест-кейсах, так что вмешательство среды исполнения тестов — это не то, что вам нужно. 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", при этом включая большой объем кода, который по сути совсем не для заголовочных файлов.

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

Итог

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

Автор: zerocost

Источник

* - обязательные к заполнению поля