Тесты на Си без SMS и регистрации

скришот 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 

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

expected: <5>
  actual: <4>
test.c:5: test_object_addition()

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

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

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

© Habrahabr.ru