Некоторое время назад на одном из проектов по работе я столкнулся с определенными требованиями, которые сразу же разожгли мой живой интерес разобраться с возможностями их обхода, попутно применив свой прошлый опыт написания фреймворков, интерпретаторов и т.п. В результат получилась вполне рабочая технология, позволяющая обходить с моей точки зрения лишние требования в проекте. За конкретикой прошу под кат.
В чем же суть?
В тестах :) А если быть точнее - в системе автоматического расчета процента покрытия ими кодовой базы.
Если позволить себе холиварное и провокационное высказывание, то я выскажу тезис о том, что тесты не нужны. Точнее, от них вреда больше, чем пользы. Разумеется, их можно писать так, чтобы сместить баланс польза/вред в сторону пользы, но по моему опыту многих проектов, это почти никому и никогда не удается. Но при этом общее убеждение таково, что тесты нужны и важны, поэтому они присутствуют почти в каждом проекте. Впрочем, цель и тема этой публикации не в том, чтобы очередной раз обсудить этот вопрос с философской точки зрения, поэтому продолжим.
Итак, у нас в проекте есть тесты. Причем, наша система деплоя настроена так, что при ошибках в тестах деплой падает с ошибкой. Но нам этого мало :) Мы добавляем в наш проект автоматическую систему расчета процента покрытия тестами исходного кода, так называемый каверадж. И выставляем настройки деплоя таким образом, что при проценте покрытия ниже определенного значения деплой также падает с ошибкой недостаточного кавераджа. И после этого спим спокойно с мыслями как хорошо мы обезопасили наш проект и как изощренно усложнили жизнь его разработчикам :)
Но среди разработчиков попадаются люди творческие, или как минимум любознательные. Которые могут потратить время и разобраться, как работают системы расчета покрытия. И выяснится, что системы расчета кавераджа обладают следующим поведением:
они не делают проверок результатов выполнения кода и даже факта того, что в процессе выполнения не будет выброшено исключение - то есть они не заменяют собой тесты;
зато они проверяют, по каким веткам условных операторов шло выполнение кода, и помечают как непокрытые те участки, выполнения которых не происходило в процессе прогона всех тестов.
Имея это в виду, у нас появляется первое заклинание в борьбе против кавераджа - мы можем в тестах не проверять результаты выполнения кода, а просто вызывать его выполнение, а проверять, к примеру, что 0 == 0. И тесты будут всегда выполняться, и покрытие будет высокое, и те, кто тащил в проект тесты и каверадж будут спокойны. Но к сожалению, это недостаточное заклинание, его одного мало для победы над злом. Потому что нам в любом случае придется обеспечивать выполнение кода по всем возможным веткам всех условных переходов. А для этого создавать все возможные варианты как корректных так и некорректных входных данных. И если для юнит-тестов это еще можно тупо накопипастить, создав комбинаторно растущую лапшу кода тестовых кейсов, то для интеграционных тестов нам придется долго и нудно писать фикстуры и миграции заполнения кучи таблиц базы данных с наличием нужной определенной неконсистентности или ошибки, которую радостно увидит наш тестируемый код и пройдет по ветке данной ошибки. И так на каждый ошибочный кейс, не говоря уже о корректных.
Что же предлагается для избавления от страданий? Вспомним, как работает система расчета кавераджа. Она проверяет, что были вызваны все ветки всех условных операторов, какие есть в вашем языке (if, switch и т.п.) Рассмотрим тривиальный и немного искусственный пример: нам нужна функция, которая при аргументе 1 будет возвращать 10, при аргументе 2 возвращать 20, а при всех остальных аргументах возвращать 30. Как бы вы написали эту функцию? Я сознательно не конкретизирую язык программирования, поскольку в большинстве языков есть конструкции для условных переходов и условного выполнения. Допустим, вы написали что-то типа
if (1 == n) // вроде так советуют писать с ==, чтобы не присвоить ненароком :)
r = 10;
else if (2 == n)
r = 20;
else
r = 30;
Все работает, код читаемый и понятный. Но система кавераджа заставит вас прогнать эту функцию для всех трех веток условий. Если вы используете switch - тоже. Но если вы напишете что-нибудь из серии
bool t = n >= 1 && n <= 2;
int a[] = {30, 10, 20};
int r = a[n * t];
то логика работы не изменится, но система проверки покрытия удовлетворится едиственным вызовом этого кода с любым значением n. Или заполните хешмапку нужными ключами и значениями, а потом вызовите на ней метод взятия значения по ключу (с возможным дефолтным значением, если такого ключа нет). Или что-нибудь еще, что вам предоставляет ваш язык программирования. Суть в том, чтобы не писать в коде условные операторы. То есть вообще. Если очень хочется, можно написать свою сервисную функцию - тернарный оператор, один единственный юнит-тест для нее, и использовать потом в своем коде.
Это уже серьезное заклинание, с помощью которого можно легко победить систему кавераджа на 100%, при этом не мучаясь с тестами, фикстурами, миграциями и прочей нудной ерундой. Однако, внимательный читатель уже заподозрил подвох! Семантика вычисления условных выражений в подавляющем большинстве языков ленивая! То есть, если условие ложно, то блок кода, соответствующий этому условию, не будет выполняться. Именно это и позволяет нам писать рекурсивные функции, которые не зацикливаются, и вообще выполнять инструкции или вычислять выражения только когда это необходимо. А во всех наших вышеперечисленных вариантах вычисления всегда выполняются по всем веткам!
Но это ограничение преодолимо. Как я уже говорил, приведенная методика проверена на коде из рабочего проекта и отлично работает. Но здесь подходы могут варьироваться в зависимости от выбранного языка программирования и его возможностей. Например, вам надо выполнить определенный блок кода только при определенном условии. Вы создаете сервисную функцию do_when от двух аргументов - значение условия и лямбда-функции, которая вызывается если первый аргумент истина. А при написании кода оборачиваете нужный вам блок кода в нульарную лямбду - древний народный метод организовать ленивое вычисление. Код внутри лямбды не будет выполняться до ее вызова. Лямбда как объект первого класса прекрасно передается в качестве аргумента в сервисную функцию do_when, внутри которой она либо вызовется, либо нет. Результат - один единственный юнит-тест на функцию do_when обеспечит нам 100% каверадж кода всего проекта, в котором мы будем использовать ее многократно.
Я реализовывал и проверял этот подход на языке Clojure, там можно для ленивого выполнения отдельно передавать функцию и вектор ее аргументов, писать свои макросы для удобного засахаривания синтаксических конструкций (например, чтобы не оборачивать руками в нульарную лямбду каждый вызов do_when) и делать еще много какого колдунства. Но и в Си также можно передавать указатели на функции или еще каким-либо образом извращаться. Всё ради того, чтобы избавиться от нативных условных выражений в коде, которые ведут в комбинаторный ужас тестов ради кавераджа. В итоге несложно добиться 100% покрытия и полного прохождения любого количества тестов, фактически не проверяя ни одного участка кода проекта - за что, собственно и боролись. Квест успешно пройден :)
Вот, собственно, и всё, что я хотел рассказать о войне во Вьетнаме (С). Я постарался сделать это кратко, без подробностей, обозначив только основную идею. Чуть больше деталей я показал в своем ролике на ютубе на эту тему
ЗЫ я не питаю иллюзий по поводу Хабра, поэтому жду комментов что я ничего не понимаю, что тесты и каверадж нужны, и т.п. Но и на адекватные комменты тоже хотелось бы надеяться :)
Комментарии (35)
maxzh83
19.05.2023 01:09+5Хочется вам пожелать побольше отлаживать чужого кода, написанного в таком стиле. А как прикол такое вполне имеет право на существование.
Radisto
19.05.2023 01:09+4И так не только в программировании.))) IRL многократно наблюдал подобное. И в таких случаях действительно думаешь, что лучше бы поменьше контроля, чем контроль, приводящий к вот такому (а он приводит, практически неизбежно приводит. Не знаю, как в программировании)))
AlexSteelax
19.05.2023 01:09+12Конечно же тесты нужны, например, они очень помогают при рефакторинге или баг фиксе какой-нибудь дичи, чтобы не наделать больше багов.
Но писать тесты надо на то, что надо, а на то что не надо - писать их не надо)
panzerfaust
19.05.2023 01:09+11Короче вместо того, чтобы бороться с бюрократом, который навязал вам каверадж, без которого не проходит CI, вы призываете бороться с самим кавераджем и заодно с вашими коллегами, которые будут читать и поддерживать код.
XTBZ
19.05.2023 01:09+4Хаха, тут надо код переработать под новые требования. У нас сотрудник хорошо писал, полное покрытие тестов, никогда не было нареканий, в общем, разберешься....
IlyaFD81
19.05.2023 01:09+2Если нормальный техлид увидет это, можно и нарваться за саботаж.
И потом : как все эти выкрутасы в коде объяснить чужим людям при код. ревью ?
santjagocorkez
19.05.2023 01:09-1А говорят, в геймдеве такое сплошь и рядом. Значит, там техлидов не завозят нормальных?
khajiit
19.05.2023 01:09+20Введение любых метрик приводит к работе на выполнение метрик, вместо работы.
VladimirFarshatov
19.05.2023 01:09+5Ну .. табличные процессоры всегда были самым шустрым решением, да и чем меньше нагружаешь код ветвлениями, тем проще работать конвееру проца и его кешатору. Имеет место быть, почему нет?
IIvana Автор
19.05.2023 01:09+5Глас вопиющего в пустыне: приготовьте путь Господу, прямыми сделайте стези Ему (Мф 3:3)
VladimirFarshatov
19.05.2023 01:09На самом деле заставить такого героя покрыть тестами эту фичу, тоже не проблема ни разу на коде ревью: видим статический массив .. требуем фикстуру на каждое значение в тестах. Бинго!
bogolt
19.05.2023 01:09+11А если зайти с другой стороны, перед запуском теста кавереджа сгенерировать пару терабайт бессмысленных нопов, и подсунуть их как часть кодовой базы. Покрыть их один тестом, вызывающим его, и все, у нас 99% покрытия без того чтобы бороться с ифами.
amakhrov
19.05.2023 01:09Часто меряют покрытие не только строк кода, то и ветвлений.
100500 ноопов без ветвлений не помогут. А 100500 нооп-ветвлений еще попробуй-ка протестируй.
Хотя и тут можно выкрутиться, если автоматом (в цикле) сгенерить тесты всех этих нооп-веток
igrek11
19.05.2023 01:09+3Как и многие, я заметил ухудшение читаемости кода. Получается, что Вы экономите на написании тестов, но повышаете время на поддержку кода. Это уже звучит пугающе.
С другой стороны, если можно выдать элегантное решение, которое уменьшит количество тестов и не потеряет читаемости, то надо так и сделать. Поэтому можно потратить больше времени на написание кода, сэкономив на написании тестов и не потеряв читаемости.
Как видите, в любом случае мы чем-то жертвуем
VladimirFarshatov
19.05.2023 01:09Не заметил ухудшения читаемости кода. В чем по вашему оно проявилось? Компактнее , часто "да". Меньше переключений внимания, наоборот лучше.
XaBoK
19.05.2023 01:09+4Ну это известный способ уменьшить ветвление кода с помощью карты (reduce branching with decision map). Улучшает метрики (complexity/maintenability) и производительность, снижает читаемость. Я так в C# писал, но без загонов со словарём из делегатов. Из дополнительных плюшек - такой код хорошо читают системы аудита кода. Как и SCA так и SAST /DAST, так что прям вот все R#, SonarCube, Chekmarx и т.д.
arTk_ev
19.05.2023 01:09+4Веселая компания, саботаж и полное непонимание что такое тесты.
Сталкивался с саботажем, когда "коллеги" обмазывали код пустым try-catch, что починить ошибку. Что-то из этой серии.
aleksandy
19.05.2023 01:09+2Но система кавераджа заставит вас прогнать эту функцию для всех трех веток условий.
То ли лыжи не едут, то ли я не особо умный. Но ведь все эти 3 ситуации в любом случае надо протестировать, чтобы проверить соответствие реализации заданию.
нужна функция, которая при аргументе 1 будет возвращать 10, при аргументе 2 возвращать 20, а при всех остальных аргументах возвращать 30.
Далее,
для юнит-тестов это еще можно тупо накопипастить, создав комбинаторно растущую лапшу кода тестовых кейсов
Модульный тест на то и модульный, чтобы быть максимально простым, а если в нём получается "комбинаторнорастущая лапша", то это просто означает, что пишется такой код, который не только сложно тестировать, но и читать/поддерживать.
для интеграционных тестов нам придется долго и нудно ...
При достаточном количестве качестве модульных тестов, интеграционных может быть совсем чуть-чуть, только для того, чтобы проверить happy-case.
Perlovich
19.05.2023 01:09ЗЫ я не питаю иллюзий по поводу Хабра, поэтому жду комментов что я ничего не понимаю, что тесты и каверадж нужны, и т.п. Но и на адекватные комменты тоже хотелось бы надеяться :)
Непонятно, зачем вы под конец статьи так явно выражаете пренебрежение в сторону пользователей ресурса, которые будут читать эту статью.
IIvana Автор
19.05.2023 01:09-1Вопрос зачем призван выявить целеполагание, а я не настолько манипулятор, чтобы вставлять подобные фразы ради достижения какой-то цели или эффекта. Скорее, тут более уместен вопрос почему, выявляющий причины. Я просто честный и открытый человек, и считаю недостойным скрывать мое отношение к определенной части данного ресурса. Я здесь достаточно давно, чтобы видеть динамику "нетортовости" и иметь по этому поводу определенное мнение.
ЗЫ один из нескольких минусов в карму за эту статью я получил с пометкой "Статья/тема не для Хабра". Так вот когда (если) Хабр снова станет торт, подобные темы снова станут онтопом, ресурс снова станет профессиональным а не школьным, тогда я с радостью поменяю свое мнение и отношение.panzerfaust
19.05.2023 01:09+2ресурс снова станет профессиональным а не школьным
Вы себя тоже тут профессионалом не показываете.
Профессионально - это собрать своих коллег и ЛПРов и предметно доказать им, что ваша точка зрения ("тесты не нужны", "покрытие ну нужно") верна. Далее в вашей организации дружно отменили бы все "лишние" проверки, тесты и код-ревью заодно. Потом вы бы собрали метрики, которые показывают, что без тестов сложное ПО пишется быстрее и содержит меньше ошибок. Потом с этой фактурой вы пришли бы на Хабр и доказали бы уже всем, что ваша точка зрения верна.
А ничего этого не происходит, и вы просто выражаете маргинальную точку зрения без пруфов. А "школьный" при этом хабр, да.
IIvana Автор
19.05.2023 01:09Вы себя тоже тут профессионалом не показываете.
Возможно, да. А возможно, что это вы не видите :) Или вы действительно считаете, что я запушил в мастер код по вышеприведенным принципам?
Если бы я написал статью в стиле, который вы описали выше, вероятно это бы придало больший вес моему виртуальному образу в глазах вас и ваших единомышленников. И да, проблема не техническая а административная, и решать ее надо соответственно... И самое смешное, что я могу делать так, как вы описали. Но это скучно :) Успешно решить задачу обхода кавераджа технически - гораздо интереснее!
В плане же взаимоотношений с социумом, меня больше привлекает реакция типа "о, это тот самый чувак, который нагнул систему кавераджа, предложив рабочий метод ее обхода!" и "господа, давайте не будем усердствовать с лимитом процента покрытия для прохождения деплоя, а то на Хабре есть статья как это дело прохачивать". Я понимаю, что есть большой соблазн навесить на меня ярлык маргинала, и многие ему поддаются :) Но я не готов лишать себя маленьких радостей решения задач и совершения открытий ради поддержания образа "серьезного человека".panzerfaust
19.05.2023 01:09И самое смешное, что я могу делать так, как вы описали. Но это скучно :)
Успешно решить задачу обхода кавераджа технически - гораздо интереснее!"господа, давайте не будем усердствовать с лимитом процента покрытия для прохождения деплоя, а то на Хабре есть статья как это дело прохачивать"
Пруфы, Билли, нам нужны пруфы. Без пруфов вы никакой не "тот чувак", а именно что маргинал, который хотел пошатать систему, а пошатал полторы строчки кода и родил из этого статью.
IIvana Автор
19.05.2023 01:09В видео показано больше примеров и подробнее. Не полторы строчки кода, а апи-хендлер из реального проекта. Если даже после этого для вас все еще не убедительно, то сорри, математически строгого доказательства не искал, мне хватило моих примеров.
sorgpro
19.05.2023 01:09+1Хорошая статья. Сохраню ссылку и буду использовать для примера как, уж точно, делать не надо.
На таком лаконичном примере псевдокода наглядно демонстрируется усложнение читабельности кода, да еще и в угоду достижения ложной цели.
Именно поэтому разберу подробнее.Итак, имеем псевдокод А:
if (1 == n) // вроде так советуют писать с ==, чтобы не присвоить ненароком :) r = 10; else if (2 == n) r = 20; else r = 30;
и псевдокод B:
bool t = n >= 1 && n <= 2; int a[] = {30, 10, 20}; int r = a[n * t];
С точки зрения code review псевдокод A выглядит сносно, а вот для псевдокода B потребуется, как минимум, переименование переменной t, чтобы объяснить её дальнейшее участие в алгоритме.
Читабельность кода пострадала. В более сложных случаях, хотя даже и в этом, было бы разумно потребовать написать комментарий, объясняющий неочевидность происходящего при беглом просмотре кода, в отличии от псевдокода A. Более очевидным выбором было бы использование, например, словаря ключ-значение, вместо массива, но здесь не так, и поэтому следует указать, что эта конструкция - альтернатива if/switch.
-
Использование "хитрого" алгоритма привносит дополнительные трудности:
при возникновении еще одного условия, например, при n == 3 нужно возвращать 25, легко добавить правку в код, чтобы все отлично заработало, но также легко забыть добавить тест для этого значения. При этом инструмент проверки покрытия кода тестами для псевдокода B нам ничем не поможет, в то время как для псевдокода A он обязательно бы отметил этот момент, изменив процент покрытия, поскольку добавленная ветка else if (3 == n) не выполняется.
требования меняются, нужно добавить в существующую программу какой-то специфичный случай, и придется менять этот неочевидный алгоритм. Например, если для текущего псевдокода при n == 0 или n == 8 нужно возвращать какое-либо значение, то алгоритм псевдокода B
a[n * t]
перестанет работать и придется придумывать еще более изощренный и менее читабельный вариант.
-
Язык программирования не конкретизировался, поэтому я и назвал это пседкокодом, а значит у кого-то может возникнуть идея реализовать этот подход на других языках. Но в других языках придется вносить правки, поскольку компилироваться/работать этот код не будет. Это чревато последствиями, которые в случае псевдокода A просто бы не возникли:
для компиляции объявления массива, например в C#, интуитивно напрашивается ключевое слово new и вуаля - строка скомпилировалось! Только вот теперь объявление приведет к выделению объекта в куче, а это влечет за собой снижение производительности, а также добавление работы сборщику мусора, что дополнительно снизит производительность.
для того, чтобы как в оригинале на C++, код использовал только стек, нужно писать иначе, но не каждый начинающий разработчик догадается/умеет, а ведь именно такие разработчики могут последовать советам из этой статьи. Но! Даже если использовать инициализацию массива на стеке мы потеряем в производительности по сравнению с псевдокодом A.
чтобы производительность все же приросла, массив можно объявить статическим, как это сделал автор одного из комментариев выше, в котором он приводит замеры производительности. Но это значит, что мы выделили объект в куче на все время работы программы - мы повысили требование к памяти (размеру кучи), которое при массовом использовании этого приема может составить существенное значение, а в случае псевдокода A такое явление не возникает.
Так чего же добились, применяя псевдокод B?
Усложнили работу разработчикам, которые будут поддерживать и развивать программу?
Обманули инструмент, контролирующий покрытие кода тестами?
Себя? Тестировщиков? Работодателя? Качество продукта?Если позволить себе холиварное и провокационное высказывание, то я выскажу тезис о том, что тесты не нужны.
Точнее, от них вреда больше, чем пользы.Это троллинг, но, как я и указал в начале комментария, статья может принести много пользы, как пример-антипаттерн.
DBalashov
19.05.2023 01:09чёрт, у меня кончился заряд на плюсы, не могу плюсануть каммент.
@sorgproнаписал максимально правильно. В моменте (натянув разные ограничения) оно и может быть прокатит (опять же с оговорками), но как часть процесса разработки - полная лажа.
IIvana Автор
19.05.2023 01:09Как-то безапелляционно и бездоказательно :) Люди, к примеру, придумывают всякие Реакты, после чего огромная масса разработчиков вынужденно постигает его модель и паттерны и начинает писать кот в рамках данного фреймворка. Тут не сложнее.
Причем, необязательно применять технологию полностью. Например, заменить чистые свитч-кейсы на хешмапку повлечет исключительно только положительные следствия (со всех сторон, включая и читаемость с расширяемостью и поддержкой), исключая возможно только перформанс (и то не всегда). Но и полное следование подходу также не сильно больно, можете сравнить примеры кода в видеоролике.
Хотя я согласен, что если снять идиотские требования на каверадж, то извращаться таким образом не имеет практического смысла :) Но мы начали с конкрентых условий окружающей среды и выработали эффективный метод выживания в них :)
libroten
Это просто саботаж
santjagocorkez
В ситуациях, когда приходится выжимать максимум производительности, такие "хаки" могут стать единственным выходом, поскольку не всегда компилятор сможет схлопнуть хорошо пачку if.
К примеру, код выше на https://perfbench.com/:
С условными переходами:
С магией (только я определение массива вынес в `static const`):
Казалось бы, немного, Но, тем не менее, разница есть.
IIvana Автор
В ролике на ютубе приводил пример. Сравните
PS и это не считая лишнего напряжения предиктора переходов по меткам, в панике мечущегося - какой код подгружать
isadora-6th
Без замеров оратора
Вообще, что это у вас за язык такой магический, на котором byte вычисления быстрее чем int? Тем более что
137
или215
- это литерал int для C/C++.Тут я хотел найти статью где
sturct RGBA[]
быстрей чемsturct RGB[]
при итерировании c подсчетом средней яркости из-за выравниваний и необходимости сдвигов. Но не нашел...Спасибо за сайт perfbench, очень полезный тул в копилочку.
Ну и предикторы в процессорах хорошо работают.
Branchless Programming in C++ - Fedor Pikus - CppCon 2021
Так что перф... это сильно сложней чем влазим в байт.
IIvana Автор
Этот магический язык называется C. На архитектурах с однобайтовыми регистрами, например многих МК. И в оригинале кот писался на ассемблере, так что весь перф колхозился руками, без расчета на умных разработчиков оптимизирующих компиляторов. Впорчем, это оффтоп к теме данной публикации.
nice_nick_matter
Но ваш код совсем другой. Оригинальный, с if, для любого n заменит значение переменной на 215. Единственное исключение -- если n == 215, тогда результатом будет 137. А "исправленный" код этим свойством не обладает, в чем нетрудно убедиться (мы говорим про байты, поэтому перебрать 256 значений можно даже руками).
И это как раз тот случай, когда тесты помогают -- при рефакторинге кода они сразу скажут, что что-то стало не так.
Jianke
Не согласен! Это реально круто! Всегда восхищал такой код: