Comments 27
В случае с макросами в библиотеке тестирования мне не нравится что не очень понятно что происходит на уровне языка когда я пишу тест. Когда разрабатываются unit-тесты как по учебнику, это понимание может быть не требоваться, но когда это какой-нибудь тяжелый интеграционный тест мне надо выяснять как работают все эти дополнительные волшебные коробочки завернутые в макросы и как они поведут себя в моем сложном тесте. В этом смысле голый С++ ближе для понимания, потому что его все уже знают и не требуется учить некий новый DSL на макросах. Еще одна претензия к макросам что исходники библиотеки написанной на макросах довольно тяжело читать.
В итоге, мне показалось интересным покопать вопрос насколько далеко можно уменьшить boilerplate код если выставить ограничение «без макросов».
Проводя аналогию: для того, чтобы использовать std::vector, необходимо знать что там у него под капотом?
А у Вас получается, что за деревьями леса не видно: наружу торчит много низкоуровневых деталей реализации теста, плюс ограничение в духе «640 килобайт хватит всем!»
Про vector: мне обязательно надо знать что под капотом std::vector, по крайней мере много чего из внутреннего устройства (есть ли там блокировки, где и когда выделяется память, как он растет когда мы туда добавляем элементы — как минимум).
А что за ограничения вам показались напрягающими?
Ну например отключенные в мелких embedded-системах исключения.
Про vector: мне обязательно надо знать что под капотом std::vectorvs чуть выше
В случае с макросами в библиотеке тестирования мне не нравится что не очень понятно что происходит на уровне языка когда я пишу тест.
Вы бы определились что ли.
Библиотеки предоставляют абстракции для сокрытия сложности. В C++ есть много способов это сделать (и добавить тоже), в том числе пришедшие в наследство из C макросы.
Может быть в арсенале С++ есть какие-нибудь еще идеи как было бы можно создать библиотеку без макросов?Например, у cxxtest такая идея: тесты пишутся обычными методами в классах, которые определены в h-файлах, а main.cpp, запускающий эти тесты по очереди и подсчитывающий статистику успехов, генерируется питон-скриптом, который довольно легко запускать перед компиляцией через makefile, или задачей «before build» в проекте Visual Studio.
Интересна ли вообще такая постановка задачи?
Без подробного сравнения с google test/boost test выглядит не очень интересно.
Потому что "на вскидку", без подробного анализа сложно понять насколько это нужные изменения, а вот насколько все ухудшилось видно сразу.
Поясню:
без макросов
Ошибки которые потенциально могут внести макросы, это "TestCase" не зарегистрируется и таким образом не запуститься или "assert" реализованный с помощью макросов не заметит ошибки. Честно говоря за > 10 лет опыта я такого ни разу не видел, но это конечно не аргумент, аргументом был бы анализ используемых макросов с описанием как можно было ошибиться в их использовании.
и динамической памяти
А какое количество TestCase нужно написать чтобы это стало заметно,
чтобы скажем запуск тестов замедлился бы время большее 1 секунды на современной машине?
Миллион, два? Существует ли в мире хотя бы один проект с таким количеством "TestCase".
Плюс на мой взгляд по приоритету важнее:
- Сколько нужно кода чтобы описать testcase и написать assert. В вашем варианте я как понимаю нужно в два раза больше строчек кода на каждый testcase по сравнению со "стандартными"?
- Сколько занимает запуск измененного testcase? То есть время компиляции+линковки+собственно время выполнения программы с юнит-тестами.
На первый взгляд время выполнения должно измениться в пределах погрешности,
а вот компиляция+линковка? - Возможно фильтрации testcase, запуск всех тестов название которых удовлетворяет регулярному выражению.
То есть если 1-3 у "test framework" одинаковы или лучше, то можно рассматривать "макросы" и "динамическую память", хотя если "лучше", то я бы и не стал смотреть на "макросы" и "динамическую память", а сразу бы стал использовать ваш "test framework".
Есть такая категория разработок, как Embedded, где динамическая память часто бывает либо обрезана, либо имеет ограничения. Но там, как правило, и исключения порезаны в ноль, поэтому в полной мере указанные подходы использовать нельзя. А исключения как правило обходятся куда дороже чем динамическое выделение памяти.
Ну встраиваемая разработка, где еще и памяти мало, это не то на что стоит ориентироваться автору, а то попытка усидеть на двух стульях может печально закончится.
Из личного опыта. Для запуска юнит тестов (для С кода правда, но в данном случае это не принципиально) разработали собственный "framework". Каждый "testcase" компилировался в отдельный бинарник, линковщик выкидывал не нужное и поэтому (а также благодаря хорошей модульности) бинарники были достаточно маленькие чтобы не прошивать их на флешку, а загружать прямо в SRAM. А потом специальный скрипт по JTAG грузил сначала инициализатор периферии, а потом последовательно каждый testcase.
Очень сомнительно, что "test framework" общего назначения даже без использования кучи нам бы подошел.
Хотя это конечно субъективно, но я бы не стал заморачиваться пытаясь решить такие случаи. Нужно ведь и интерфейс как надергать "test case" в отдельные бинарники и и возможно в некоторых случаях вызывать в assert не std::abort/std::terminate, а бесконечный цикл с миганием лампочками и прочие странные и специфичные вещи.
Про говорить про «киллер-фичу» с динамической памятью, то я смотрю на это так что динамическая память зашитая внутрь библиотеки — это как дополнительная зависимость. Если отсутствие такой дополнительной зависимости обходится недорого, то от нее можно отказаться. В этом случае библиотека может подойти как тем кому это не принципиально, так и тем кому это важно.
Миллион, два? Существует ли в мире хотя бы один проект с таким количеством «TestCase».Миллион не нужен. В DEQP порядка трёхсот тысяч тестов и старт занимает заметное время на слабых телефонах. Впрочем там не только из-за харнеса задержки при создании, так что не факт, что предлагаемый подход «спасёт отца русской демократии».
Сколько нужно кода чтобы описать testcase и написать assert.
Assert примерно одинаково в обоих случаях, а вот определения тесто в моем случае явно чуть побольше. Если привести примеры из статьи:
TEST(MyString, DefaultConstructor) {
}
versus:
template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime)
{
runtime->StartCase("emptiness");
}
// ... and one group per translation unit
static tested::Group<CASE_COUNTER> x("std.vector", __FILE__);
То есть увеличение есть и может быть можно подумать как его уменьшить (а обернуть в макрос всегда успеем). Но мне кажется что если смотреть с позиции гранулярности тестов то дополнительный код не такой значительный. По моему опыту когда тесты особенно интеграционные вполне выходят за 10+ строк, так что выигрыш по строкам на моих тестах будет не слишком значительным.
2. Сколько занимает запуск измененного testcase?
Я не встраивал эту библиотеку в большой проект, но если честно то я не ожидаю какого-то оверхеда у tested. Изменение test-case это пересборка *.cpp файла + линковка, вроде нет ничего что можно было бы улучшить. Запуск теста — это то где можно что-то по-улучшать если это будет тонким местом.
Момент в производительности который я тестировал — это какой размер исполняемого файла можем получить если укажем глубокий уровень рекурсии для перебора шаблонной функции (типа Group<1024*1024> — я не дождался окончания компиляции). То есть если у вас в translation unit очень много тестов (скажем 1000), то это может увеличивать время компиляции, размер бинарника и поисков по списку. Чтобы на такого рода ограничения не попадать, я пока ограничил количество тестов в группе положительным диапазоном типа signed char (0-127).
3. Возможно фильтрации testcase, запуск всех тестов название которых удовлетворяет регулярному выражению.
Тут неплохо бы фидбэк насколько это нужно. То что я реализовал сейчас, это возможность запуска определенного теста и всех тестов в определенной группе. Например у нас группа «std.container.vector» и тест «construction». Получается что у теста можно сделать штуку под названием адрес, «std.container.vector:construction». И далее запускать регулярные выражения по таком адресу «std.container.*» или «std.container.vector:*». Но только не вполне понятно какой именно кейс для запуска именно по регулярным выражениям?
Не плохой фреймворк, видимо, если спрятать boilerplate code в макросы, может получиться почти так же удобно, как в gtest.
Это да, но волков бояться — в лес не ходить. Когда будет распространено constexpr пошире, да побольше возможностей кодогенерации хотя бы с тем же уровнем лаконичности на этапе использования, который сейчас даёт препроцессор, тогда можно будет и думать о том, что бы отказываться от возможности сэкономить огромное количество человекочасов и человеконервов. Тесты должны писаться быстро, что бы их было не жалко выкидывать.
Второй момент — много мусора в заголовках кейсов, очень не выразительно. А при чтении это первое куда нужно смотреть. И тут даже сложно понять, чей подход лучше gtest или catch, но точно не то, что продемонстрировано в посте.
Возможно я не прав по поводу фреймворка и просто не удачные примеры в статье. Тогда, было бы здорово увидеть альтернативу для такого набора сценариев, не вдаваясь в тела самих кейсов:
TEST_F(CorruptedStorage, try_write_block){//...}
TEST_F(CorruptedStorage, try_read_block){//...}
На мой взгляд, там и так много буков, на предлагаемый функционал, например в pytest это выглядело бы примерно так:
def test_try_write_block(corrupted_storage):
pass
def test_try_read_block(corrupted_storage):
pass
Некоторые изменения в API которые думаются на ближайшее время, это возможность указания «размера» теста, которое может быть выглядеть как параметр StartCase():
template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime)
{
runtime->StartCase("try_write_block", INTEGRATION_TEST);
}
А также указание на то что тест асинхронный:
template<> void tested::Case<CASE_COUNTER>(tested::IRuntime* runtime)
{
tested::callback *cb = runtime->StartAsyncCase("try_write_block");
}
Т.е. некоторая избыточность вроде метода StartCase() она все равно будет нужна для других целей.
Тесты на C++ без макросов и динамической памяти