В первой части мы рассмотрели примеры тестов, из которых не все одинаково полезны. Затем попытались определиться, что же такое качество ПО, и предложили "распрямлять" код.


Теперь посмотрим, от каких ошибок защищают тесты, а от каких — другие инструменты из арсенала разработчика.





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


Ошибки, обнаруживаемые компилятором


Есть несколько классов ошибок, которые преследуют разработчиков на интерпретируемых языках с динамической типизацией:


  • опечатки;
  • вызов несуществующих функций;
  • неправильный набор аргументов функций;
  • несовместимые типы данных в одном выражении (причём некоторые языки в рантайме это проглатывают и втихаря выдают какой-то неожиданный результат);
  • использование неинициализированных переменных (при включенной опции -Ysafe-init);
  • ...

Эти ошибки обнаруживаются в ходе выполнения именно той самой строчки кода, где они содержатся. По-видимому, это является причиной, по которой разработчики на таких языках пытаются обеспечить высокий процент покрытия строк кода — чтобы компенсировать отсутствие компилятора.


Компилятор является мощным инструментом доказательства теорем о корректности программного обеспечения. Желательно по максимуму переносить все потенциальные проблемы на этап компиляции. Тем самым будут предотвращены многие ошибки времени исполнения и отпадёт необходимость писать избыточное количество тестов.


Ошибка на миллиард долларов (NPE)


Похоже, что эта ошибка существует уже более полувека: Null References: The Billion Dollar Mistake, Tony Hoare. Суть проблемы заключается в том, что ссылочный тип данных помимо нормального значения, обладающего ожидаемым поведением, может иметь специальное значение null, обладающее неожиданным поведением. И многие языки скромно умалчивают (aka подразумевают по умолчанию) о том, что в любом месте программы в переменной типа MyCuteObject может прятаться какая-то неожиданность.


По-видимому, обычные юнит-тесты не в силах предсказать NPE (NullPointerException). Если имеющиеся тесты проходят, то они и в дальнейшем будут проходить. Возможно, мутационное тестирование или property-based тестирование как-то поможет.


В Scala 3 (с включённой опцией -Yexplicit-nulls), как и в Kotlin, реализован подход, в котором Null представлен явным образом. В Java применяются аннотации @NotNull и статический анализ кода.


Использование Option или явных Null типов, наряду с отказом от использования null в проекте, позволяет надеяться на то, что ошибка NPE окажется практически исключена.


Выход за пределы диапазона допустимых значений


Например, номер порта больше 65К. Или номер порта меньше 1024 для обычного пользователя. Или отрицательное значение там, где допускаются только неотрицательные. Или строковое значение, используемое в качестве модели enum, не входящее в список известных значений.


Refined-типы позволяют сформировать ограничения на значения примитивных типов и тем самым отделить валидацию от использования проверенных типов. Вместо строковых значений, очевидно, следует использовать собственно enum, специально придуманного для закрытого перечня взаимоисключающих вариантов.


При попытке решить эту проблему с помощью тестов обычно рекомендуется на каждую границу диапазона написать два теста — положительный и отрицательный. Причём для одного параметра, возможно, придётся протестировать множество функций, которые с этим параметром работают. Объём работы, по-видимому, больше, чем при использовании refined-типов.


Попадание данных, не прошедших валидацию, в бизнес-логику


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


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


Рассмотрим пример.


В языке Go алгебраические типы данных не поддерживаются. Для сообщения об ошибках имеется "джентельменское соглашение" — возвращается пара (A, error) и значение проверяется тогда, когда error равен nil. К сожалению, такое соглашение соблюдается не всегда. Иногда на вызывающей стороне разработчик может проигнорировать ошибку. А иногда автор библиотеки может вернуть сразу и ошибку и значение. (Например, в стандартной библиотеке работы с файлами возвращается псевдо-ошибка EOF и, одновременно, прочитанные данные.)


В языке Scala для похожей задачи можно воспользоваться типом Either[error, A]. Этот тип представляет алгебраическую сумму. Значением такого типа будет строго либо левая, либо правая часть. Тем самым исключаются невозможные комбинации — "и левая и правая часть", "нет ни левой, ни правой части".


Пример elm-css. В стандарте CSS предусмотрен ряд жёстких правил:


  • @charset может быть указан не более одного раза;
  • @import, @namespace если имеются, должны быть указаны до всех декларация и следовать в этом порядке.

Чтобы автоматически обеспечить невозможность представления невалидных CSS в коде, структура данных может иметь вид:


case class ValidCSS(charset: Option[Charset], imports: List[Import], namespace: List[Namespace], declaration: List[Declaration])

При этом в коде невозможно создать экземпляр, где было бы два charset'а. Тем самым исключаются как соответствующие ошибки, так и необходимость писать тесты.


Выход за границы массива в цикле


Если при обработке коллекций пользоваться высокоуровневым инструментарием наподобие map, filter, fold, ..., то необходимость пользоваться циклами практически отпадает. Тем самым исключается класс ошибок выхода за границы массива.


Здесь срабатывает фактор переноса основных проблемных моментов (переменной цикла и вычисления границ итерации) вглубь хорошо спроектированной и протестированной библиотеки. На прикладном уровне остаётся только концепция обращения с коллекцией как с единым целым.


Ошибки неправильного понимания требований


Если разработчик понял требования неправильно, то и тесты и типы будут соответствовать пониманию разработчика. По-видимому, такие ошибки предотвратить трудно.


Примеры нарушения требований


Если нам известны примеры, то по ним легко можно написать тест, который будет это нарушение воспроизводить.


Для исправления такого обнаруженного нарушения желательно понять, нельзя ли нарушенное требование представить на уровне типов данных. Если это возможно, то тем самым будут исключены все возможные нарушения требования в будущем.


Ошибки некорректного состояния


При использовании протоколов с состоянием, когда одна из сторон формирует состояние другой стороны цепочкой отдельных сообщений, и полагается на это сформированное состояние, потеря отдельного сообщения или логическая ошибка при формировании цепочки сообщений легко могут привести к тому, что вторая сторона будет иметь состояние, отличающееся от того, на которое рассчитывает первая сторона. Одним из вариантов защиты от таких ошибок является отказ от протоколов с состоянием и передача всей необходимой информации одним сообщением. Если тип этого сообщения разработать с учётом принципа непредставимости невалидных данных, то, по-видимому, этот класс ошибок будет исключён.


Ошибки, вызванные дублированием параметров


Два компонента, связанных через TCP порт, должны быть сконфигурированы с одинаковым значением номера порта. Иначе они не смогут связаться друг с другом. Если конфигурации этих компонентов независимы, то номер порта окажется продублирован. В дальнейшем при изменении конфигурации легко упустить необходимость согласованного изменения во всех точках, использующих этот же номер порта. Такую проблему можно искоренить пользуясь принципом DRY на уровне всей системы. Номер порта должен быть указан единственный раз в конфигурации системы, а конфигурация связанных компонентов должна выводиться автоматически. (Подробнее: Компилируемая конфигурация распределённой системы.)


Несовместимость версий


Есть популярное поверье в semver — якобы достаточно следовать простым правилами инкремента номеров версий, и приложение будет автоматически согласовано. К сожалению, даже автоматический переход на следующую минорную версию зависимости вполне может привести к непредсказуемым последствиям. Особенно, если автоматически подтягиваются транзитивные зависимости.


Наилучших результатов по стабильности мне приходилось наблюдать в тех проектах, где версии библиотек были жёстко зафиксированы. Более того, сам исходный код библиотек был закэширован в корпоративном репозитории. Несмотря на то, что переход на свежии версии давался с некоторым трудом, однако воспроизводимость сборки и результатов тестов была 100%.


Одним из неплохих промежуточных вариантов для компаний, в которых работает несколько команд, может служить понятие "платформы" — совокупности зафиксированных версий библиотек, которой даётся собственный номер версии (что-то похожее может быть представлено с помощью Maven BOM). В этом случае отдельные микросервисы, взаимодействующие между собой, должны как минимум использовать одну версию "платформы".


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


Заключение


Рассмотренные типы не исчерпывают всего разнообразия ошибок, однако, по-видимому, являются достаточно распространёнными и характерными. При этом видно, что тесты не позволяют предотвратить большинство этих ошибок. Другие инженерные практики, и в первую очередь, типы данных, предоставляют больше возможностей по радикальному устранению ошибок определённых классов.


Вся серия заметок:


  1. Примеры тестов.
  2. Что делать?
    1. Качество ПО.
    2. "Прямолинейность" кода.
    3. Классификация ошибок.
    4. Эквивалентность функций.
    5. Применимость тестов.
  3. Исправление примеров.

Комментарии (0)