Недавно 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 и асбест, что является несомненным плюсом для экологии и экономики Земли.
Автор: ilammy