Тесты на Си без SMS и регистрации
Автору (почти) удалось избежать использования макросов для регистрации тестов, однако вместо них в коде появились «волшебные» шаблоны, которые лично мне кажутся, простите, невообразимо уродскими. Недавно 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
Можно смело проматывать всё, что связано с Autotools, и смотреть только на код. Вот пример из документации Cutter. Фреймворк немного странный, да, как и всё японское.
У меня также нет полноценного (и даже хотя бы чернового) кода, так как лично мне он не очень-то и нужен (в Rust всё есть из коробки). Я не буду слишком уж подробно разбирать особенности реализации. Однако, для заинтересовавшихся людей это может быть хорошим упражнением.
Детали и возможности реализации
Рассмотрим некоторые задачи, которые потребуется решить при написании фреймворка для тестирования с помощью подхода Cutter.
Получение экспортируемых функций
Стандарт Си++, естественно, не описывает разделяемые библиотеки вовсе. Для начала, до тестовых функций необходимо как-то добраться. Как известно, POSIX-системы предоставляют функции dlopen()
, dlsym()
, dlclose()
, с помощью которых можно получить адрес функции, зная имя её символа, и… в общем-то всё. Windows с недавних пор обзавелась Linux-подсистемой, что позвляет все три главные операционные системы свести к POSIX. Список функций, содержащихся в загруженной библиотеке, 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()
.
На самом деле nm и компания как раз ими и пользуются. Для более глубокой интроспекции пригодятся библиотеки вроде libelf, libMachO, pe-parse, позволяющие программно разбирать исполнимые файлы и библиотеки интересующих вас платформ.
Фильтрация тестовых функций
Как вы могли заметить, в библиотеках содержатся какие-то странные символы:
__Z20test_object_additionv
dyld_stub_binder
И что это за левая dyld_stub_binder
? Вот что это за __Z20test_object_additionv
, когда мы называли функцию просто test_object_addition
?
Особенность компиляции Си++, ничего не поделаешь, живите с этим. «Лишние» символы __Z20...
— это так называемое декорирование имён (name mangling). Для того, чтобы показывать их человеку в нормальном виде, можно воспользоваться библиотеками вроде libdemangle. Именно так называются функции с точки зрения системы (и dlsym()
). Конечно же нужная библиотека зависит от используемого вами компилятора, но формат декорирования обычно одинаков в рамках платформы.
Какие-то функции вызывать при запуске тестов не надо, так как там рыбы нет. Что касается странных функций вроде 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 и асбест, что является несомненным плюсом для экологии и экономики Земли.