Как писать ненормальный код и зачем это может быть нужно

КДПВ отсюдова https://telegra.ph/4-sposoba-preodolet-trudnosti-04-18)

Некоторое время назад на одном из проектов по работе я столкнулся с определенными требованиями, которые сразу же разожгли мой живой интерес разобраться с возможностями их обхода, попутно применив свой прошлый опыт написания фреймворков, интерпретаторов и т.п. В результат получилась вполне рабочая технология, позволяющая обходить с моей точки зрения лишние требования в проекте. За конкретикой прошу под кат.

В чем же суть?

В тестах :) А если быть точнее — в системе автоматического расчета процента покрытия ими кодовой базы.

Если позволить себе холиварное и провокационное высказывание, то я выскажу тезис о том, что тесты не нужны. Точнее, от них вреда больше, чем пользы. Разумеется, их можно писать так, чтобы сместить баланс польза/вред в сторону пользы, но по моему опыту многих проектов, это почти никому и никогда не удается. Но при этом общее убеждение таково, что тесты нужны и важны, поэтому они присутствуют почти в каждом проекте. Впрочем, цель и тема этой публикации не в том, чтобы очередной раз обсудить этот вопрос с философской точки зрения, поэтому продолжим.

Итак, у нас в проекте есть тесты. Причем, наша система деплоя настроена так, что при ошибках в тестах деплой падает с ошибкой. Но нам этого мало :) Мы добавляем в наш проект автоматическую систему расчета процента покрытия тестами исходного кода, так называемый каверадж. И выставляем настройки деплоя таким образом, что при проценте покрытия ниже определенного значения деплой также падает с ошибкой недостаточного кавераджа. И после этого спим спокойно с мыслями как хорошо мы обезопасили наш проект и как изощренно усложнили жизнь его разработчикам :)

Но среди разработчиков попадаются люди творческие, или как минимум любознательные. Которые могут потратить время и разобраться, как работают системы расчета покрытия. И выяснится, что системы расчета кавераджа обладают следующим поведением:

  • они не делают проверок результатов выполнения кода и даже факта того, что в процессе выполнения не будет выброшено исключение — то есть они не заменяют собой тесты

  • зато они проверяют, по каким веткам условных операторов шло выполнение кода, и помечают как непокрытые те участки, выполнения которых не происходило в процессе прогона всех тестов

Имея это в виду, у нас появляется первое заклинание в борьбе против кавераджа — мы можем в тестах не проверять результаты выполнения кода, а просто вызывать его выполнение, а проверять, к примеру, что 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% покрытия и полного прохождения любого количества тестов, фактически не проверяя ни одного участка кода проекта — за что, собственно и боролись. Квест успешно пройден :)

Вот, собственно, и всё, что я хотел рассказать о войне во Вьетнаме ©. Я постарался сделать это кратко, без подробностей, обозначив только основную идею. Чуть больше деталей я показал в своем ролике на ютубе на эту тему

ЗЫ я не питаю иллюзий по поводу Хабра, поэтому жду комментов что я ничего не понимаю, что тесты и каверадж нужны, и т.п. Но и на адекватные комменты тоже хотелось бы надеяться :)

© Habrahabr.ru