скришот Cutter Недавно zerocost написал интересную статью «Тесты на C++ без макросов и динамической памяти», в которой рассматривается минималистический фреймворк для тестирования Си++ кода. Автору (почти) удалось избежать использования макросов для регистрации тестов, однако вместо них в коде появились «волшебные» шаблоны, которые лично мне кажутся, простите, невообразимо уродскими. После прочтения статьи у меня оставалось смутное чувство неудовлетворённости, так как я знал, что можно сделать лучше. Я сразу не смог вспомнить где, но я точно видел код тестов, который не содержит ни единого лишнего символа для их регистрации:


void test_object_addition()
{
    ensure_equals("2 + 2 = ?", 2 + 2, 4);
}

Наконец-то я вспомнил, что этот фреймворк называется Cutter и он использует по-своему гениальный способ идентификации тестовых функций.


(КДПВ взята с сайта Cutter под CC BY-SA.)


В чём же трюк?


Тестовый код собирается в отдельную разделяемую библиотеку. Функции-тесты извлекаются из экспортируемых символов библиотеки и идентифицируются по именам. Тесты исполняет специальная внешняя утилита. Sapienti sat.


$ cat test_addition.c
#include <cutter.h>

void test_addition()
{
    cut_assert_equal_int(2 + 2, 5);
}

$ cc -shared -o test_addition.so      -I/usr/include/cutter -lcutter      test_addition.c

$ cutter .
F
=========================================================================
Failure: test_addition
<2 + 2 == 5>
expected: <4>
  actual: <5>
test_addition.c:5: void test_addition(): cut_assert_equal_int(2 + 2, 5, )
=========================================================================

Finished in 0.000943 seconds (total: 0.000615 seconds)

1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s),
0 omission(s), 0 notification(s)
0% passed

Вот пример из документации Cutter. Можно смело проматывать всё, что связано с Autotools, и смотреть только на код. Фреймворк немного странный, да, как и всё японское.


Я не буду слишком уж подробно разбирать особенности реализации. У меня также нет полноценного (и даже хотя бы чернового) кода, так как лично мне он не очень-то и нужен (в Rust всё есть из коробки). Однако, для заинтересовавшихся людей это может быть хорошим упражнением.


Детали и возможности реализации


Рассмотрим некоторые задачи, которые потребуется решить при написании фреймворка для тестирования с помощью подхода Cutter.


Получение экспортируемых функций


Для начала, до тестовых функций необходимо как-то добраться. Стандарт Си++, естественно, не описывает разделяемые библиотеки вовсе. Windows с недавних пор обзавелась Linux-подсистемой, что позвляет все три главные операционные системы свести к POSIX. Как известно, POSIX-системы предоставляют функции dlopen(), dlsym(), dlclose(), с помощью которых можно получить адрес функции, зная имя её символа, и… в общем-то всё. Список функций, содержащихся в загруженной библиотеке, POSIX уже не раскрывает.


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


В качестве начального приближения можно просто вызывать утилиту nm:


$ cat test.cpp
void test_object_addition()
{
}

$ clang -shared test.cpp

$ nm -gj ./a.out
__Z20test_object_additionv
dyld_stub_binder

разбирать её вывод и пользоваться dlsym().


Для более глубокой интроспекции пригодятся библиотеки вроде libelf, libMachO, pe-parse, позволяющие программно разбирать исполнимые файлы и библиотеки интересующих вас платформ. На самом деле nm и компания как раз ими и пользуются.


Фильтрация тестовых функций


Как вы могли заметить, в библиотеках содержатся какие-то странные символы:


__Z20test_object_additionv
dyld_stub_binder

Вот что это за __Z20test_object_additionv, когда мы называли функцию просто test_object_addition? И что это за левая dyld_stub_binder?


«Лишние» символы __Z20... — это так называемое декорирование имён (name mangling). Особенность компиляции Си++, ничего не поделаешь, живите с этим. Именно так называются функции с точки зрения системы (и dlsym()). Для того, чтобы показывать их человеку в нормальном виде, можно воспользоваться библиотеками вроде libdemangle. Конечно же нужная библиотека зависит от используемого вами компилятора, но формат декорирования обычно одинаков в рамках платформы.


Что касается странных функций вроде dyld_stub_binder, то это тоже особенности платформы, которые придётся учитывать. Какие-то функции вызывать при запуске тестов не надо, так как там рыбы нет.


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


Передача контекста исполняемого теста


Объектные файлы с тестами собираются в разделяемую библиотеку, исполнение кода которой полностью контролируется внешней утилитой-драйвером — cutter для Cutter. Соответственно, внутренние тестовые функции могут этим пользоваться.


Например, контекст исполняемого теста (IRuntime в исходной статье) можно спокойно передавать через глобальную (thread-local) переменную. За управление и передачу контекста отвечает драйвер.


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


void test_vector_add_element()
{
    testing::description("vector size grows after push_back()");
}

Функция description() получает доступ к условному IRuntime через глобальную переменную и таким образом может передать фреймворку комментарий для человека. Безопасность использованя глобального контекста гарантируется фреймворком и не является ответственностью писателя тестов.


При таком подходе в коде будет меньше шума с передачей контекста в утверждения сравнения и внутренние тестовые функции, которые может потребоваться вызывать из основной.


Конструкторы и деструкторы


Так как исполнение тестов полностью контролируется драйвером, то он может выполнять дополнительный код вокруг тестов.


В библиотеке Cutter для этого используются следующие функции:


  • cut_setup() — перед каждым отдельным тестом
  • cut_teardown() — после каждого отдельного теста
  • cut_startup() — перед запуском всех тестов
  • cut_shutdown() — после завершения всех тестов

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


Для Си++ возможно придумать более идиоматичный интерфейс:


  • более объектно-ориентированный и типобезопасный
  • с лучшей поддержкой концепции RAII
  • использующий лямбды для отложенного исполнения
  • задействующий контекст исполнения тестов

Но мне пока опять размышлять над этим всем в деталях сейчас.


Самодостаточные исполнимые файлы с тестами


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


Прочее


У Cutter и других фреймворков также есть множество других полезняшек, которые могут облегчить жизнь при написании тестов:


  • гибкие и расширяемые тестовые утверждения
  • построение и получение тестовых данных из файлов
  • исследование стектрейсов, обработка исключений и падений
  • настраиваемые «уровни поломки» тестов
  • запуск тестов в нескольких процессах

Стоит оглядываться на существущие фреймворки при написании своего велосипеда. UX — гораздо более глубокая тема.


Заключение


Подход, используемый фреймворком Cutter, позволяет проводить идентификацию тестовых функций с минимальной когнитивной нагрузкой на программиста: просто пиши тестовые функции и всё. В коде не требуется использовать никаких специальных шаблонов и макросов, что повышает его читабельность.


Особенности сборки и запуска тестов можно спрятать в переиспользуемые модули для систем сборки вроде Makefile, CMake, и т. д. Вопросами отдельной сборки тестов всё равно придётся так или иначе задаваться.


Из недостатков такого подхода можно отметить сложность размещения тестов в том же файле (той же единице трансляции), что и основной код. К сожалению, в таком случае без дополнительных подсказок уже не разобраться, какие функции запускать надо, а какие нет. К счастью, в Си++ обычно и так принято разносить тесты и реализацию в разные файлы.


Что касается окончательного избавления от макросов, мне кажется, что принципиально от них отказываться не стоит. Макросы позволяют, например, более коротко записывать утверждения сравнения, избегая дублирования кода:


void test_object_addition()
{
    ensure_equals(2 + 2, 5);
}

но при этом сохраняя ту же информативность выдачи в случае ошибок:


Failure: test_object_addition
<ensure_equals(2 + 2, 5)>
expected: <5>
  actual: <4>
test.c:5: test_object_addition()

Имя тестируемой функции, имя файла и номер строки начала функции в теории можно извлечь из отладочной информации, содержащейся в собираемой библиотеке. Ожидаемое и фактическое значение сравниваемых выражений известны функции ensure_equals(). Макрос же позволяет «восстановить» исходное написание тестового утверждения, из которого более понятно, почему ожидается именно значение 4.


Впрочем, это на любителя. Заканчиваются ли на этом преимущества макросов для тестового кода? Я пока особо не думал над этим моментом, который может оказаться хорошим полем для дальнейших извращений исследований. Гораздо более интересный вопрос: возможно ли как-то сделать мок-фреймворк для Си++ без макросов?


Внимательный читатель также заметил, что в реализации действительно отсутствуют SMS и асбест, что является несомненным плюсом для экологии и экономики Земли.

Комментарии (9)


  1. Gorthauer87
    04.01.2019 14:40

    Интересно а на манер rust не получится пометить функции тесты каким нибудь атрибутом из тех что в стандарте имеются?


    1. ilammy Автор
      04.01.2019 16:22

      В стандарте — не уверен. В перечне стандартных ничего подходящего нет, вроде бы.


      С помощью макросов по идее можно облагородить [[gnu::section("test")]] или __attribute__((section("tests"))). Тогда все тестовые функции и данные можно разместить в отдельной секции, что даёт ряд плюшек:


      • нет путаницы с тестовыми функциями и остальными
      • линкер может выкинуть всё тестовое добро при сборке


      1. Door
        04.01.2019 19:59
        +1

        В плюсах нет возможности в рантайме посмотреть все ф-и с таким-то атрибутом. Даже предлагаемые пропозалы, по-моему, по статической рефлексии не включают в себя возможность в компайл тайме узнать атрибуты ф-и. Мне кажется, атрибуты также не влияют на name mangling, то есть по символам ф-й также не получится что-либо узнать.
        Без сторонней тулзы, которая парсит код, не получится. Или я что-то упустил?


        1. ilammy Автор
          03.01.2019 23:39

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


          Я про атрибуты имел в виду то, что можно, скажем, написать


          #include <iostream>
          
          [[gnu::section("__TEXT,__tests")]]
          void run_test()
          {
              std::cout << "run_test()\n";
          }
          
          void test_function()
          { 
              std::cout << "test_function()\n";
          }

          тогда функции run_test и test_function попадут в разные секции:


          $ nm -gU libtest.dylib -s __TEXT __text | grep test
          0000000000001380 T __Z13test_functionv
          $ nm -gU libtest.dylib -s __TEXT __tests | grep test
          0000000000001e80 S __Z8run_testv

          Таким образом удобнее фильтровать именно функции-тесты, рассматривая только секцию __tests.


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


          1. MikailBag
            05.01.2019 18:40

            Для вырезки секций есть что-то вродe objcopy -j.


  1. Kotyamba
    04.01.2019 01:06

    Что за ОС на скиншоте?


    1. ilammy Автор
      04.01.2019 02:42

      Linux, старая оболочка GNOME 2.


  1. zerocost
    04.01.2019 19:37

    Интересно, я смотрел различные библиотеки тестирования на С++, но не смотрел библиотеки для С, а тут такая оказывается бывает такая годнота.


  1. Livid
    05.01.2019 17:39
    +2

    extern "C" и не будет никакого mangling функций на верхнем уровне. Так что посыл "живите с этим" в корне неверен.