Тесты как граждане первого сорта

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Уже более двадцати лет в индустрии принято тестировать написанный код до выкатывания его в продакшн. Люди придумали unit-тесты, acceptance-тесты, интеграционные тесты, property-based тесты. Люди даже придумали TDD, чтобы удостовериться в том, что тесты на самом деле работают. Люди придумали моки и контракты, наконец (настоятельно рекомендую прочитать эту заметку Валима, она буквально открыла мне глаза на то, что не так с моками в большинстве случаев).

В теории все выглядит очень красиво. Вот юнит-тест:

test "addition works for integers" do
  assert 3 + 4 = 7
  refute 3 + 4 = 8
end

На практике, мы обычно тестируем чуть более сложные структуры данных, и перед нами в полный рост встает необходимость эти самые данные для тестирования создать. Код каждого теста превращается в огромный кусок бойлерплейта (ну вообразите JSON-ответ от какого-нибудь стороннего сервиса, наподобие прогноза погоды). Ленивые программисты придумали фабрики данных, типа Faker и иже с ним. Умные программисты вместо этого используют вышеупомянутое property-based тестирование, но и там приходится слишком много городить руками.

Я уже жаловался на то, что создатели библиотек крайне редко думают о том, как упростить пользователям тестирование кода, использующего их API.

Лирическое отступление

Какие слова нам приходят в голову, когда мы говорим об инициализации данных? Валидация — это точно. Здесь будет уместно сослаться на блистательный текст Алексис Кинг «Parse Don’t Validate». Приведение (coercion). Правила приведения типа к ожидаемому — нужны, потому что иначе любая попытка добыть адрес порта из переменной среды окружения обрастет ad-hoc приведениями по месту. В ООП coercion and validation обычно осуществляется в сеттерах. Функциональный подход больше тяготеет к параметрическому полиморфизму, приводя и валидируя в специально обученных хелперах. В любом случае, это поле уже триста раз распахано, и в каждом новом проекте добавление такой возможности можент быть выполнено LLM с закрытыми глазами.

Но чтобы протестировать получившийся объект (структуру данных) — приходится каждый раз городить огород. Хорошо, когда User — это два поля: имя, и пол. Но уже добавление даты рождения — влечет за собой необходимость проверять пограничные случаи: дата в будущем, возраст меньше 18, и тому подобное. В реальном мире — данные пользователя — это сто пятьдесят полей, причем на нескольких уровней вложенности. Проверка юнит-тестами становится вообще чуть ли не бессмысленной, но и property-based тесты нуждаются в огромном количестве кода инициализации.

Поэтому в какой-то момент я для себя решил, что генерация тестовых данных — такой же неотъемлемый обязательный атрибут структуры данных, как и валидация с приведением из коробки. Я слышу возражение из зала: продакшн-код не должен нести в себе ничего дополнительного только ради упрощения тестирования. Не должен, так он и не несет. User.generate(options) — это абсолютно обособленная функция, которую тесты могут вызвать, если надо. Ну и REPL-driven девелопмент упрощается в разы.

Estructura

Когда я начинал работу над библиотекой estructura, набора хелперов для работы со структурами в эликсире, я знал, что генерация данных будет в ней гражданином первого сорта (first-class citizen). Чтобы знать, что именно генерировать, нужны как минимум типы (как максимум — правила генерации). При этом эликсир — язык с динамической типизацией. Поэтому пришлось конструировать велосипед (забегая вперед, скажу, что статические типы мне бы никак с генерацией не помогли, так что велосипед оказался первым в своем роде). Тип в контексте этой библиотеки означает имплементацию поведения (behaviour) Estructura.Nested.Type. Это не алгебраический тип, и не set-theoretic type, которые подвозят в корку эликсира. Это утилитарные типы, как тип поля в базе данных.

Итак, для реализации типа нам доступны функции coerce/1, validate/1 и generate/1. Для генерации элементов структуры доступен параметрический полиморфизм, то есть чтобы успешно генерировать структуры вида %User{name: :string, %Address{street: :string}} — достаточно генератора для типа :string, все остальное библиотека сделает сама. Вот, например, как выглядит структура User из тестов библиотеки:

  use Estructura.Nested

  shape %{
    created_at: :datetime,
    name: {:string, kind_of_codepoints: Enum.concat([?a..?c, ?l..?o])},
    address: %{city: :string, street: %{name: [:string], house: :positive_integer}},
    person: :string,
    homepage: {:list_of, Estructura.Nested.Type.URI},
    ip: Estructura.Nested.Type.IP,
    data: %{age: :float},
    birthday: Estructura.Nested.Type.Date,
    title: {Estructura.Nested.Type.Enum, ~w|junior middle señor|},
    tags: {Estructura.Nested.Type.Tags, ~w|backend frontend|}
  }

Приведение и валидация

Это все хорошо, но эликсир — язык иммутабельный. Поэтому просто запихнуть все проверки в сеттер не получится: сеттера нет. Казалось бы, это серьёзное препятствие на пути изящности решения, ведь требовать вызывать Address.validate(address) и потом User.validate(user) от пользователя — не вариант. Но тут нам на помощь приходит одно из самых недооцененных решений в корке эликсира — линзы — Access, которые позволяют буквально в одно действие обновлять любые самые потайные уголки структуры через ядерные функции доступа update_in/3 и компанию. Собственно, реализация Access для структуры тривиальна, а в награду мы получаем код, куда можно и нужно воткнуть приведение и валидацию.

Также через Access ходит сгенерированная функция User.cast/2, которая, например, разложит по полям пришедший от соседнего сервиса джейсон.

Да, установка значений напрямую через Map.put/3 или %User{user | ...} сломает валидацию, ну так и рефлексия в джаве ее обойдет. Хочешь пользоваться шуруповертом? — Включи вилку в розетку и нажми кнопку, а не фигачь рукояткой по шляпке болта.

Стоит отметить, что помимо кошерных приведения и валидации через пользовательские типы, доступны и ad-hoc версии (ниже нет ошибки в синтаксисе, всё именно так для вложенных полей: адресация через точку):

coerce do
  def data.age(age) when is_float(age), do: {:ok, age}
  def data.age(age) when is_integer(age), do: {:ok, 1.0 * age}
  def data.age(age) when is_binary(age), do: {:ok, String.to_float(age)}
  def data.age(age), do: {:error, "Could not cast #{inspect(age)} to float"}
end

Генерация

С генерацией все немного сложнее. Но рекурсия победила страх перед неведомыми глубинами вложенности, а StreamData предоставила генераторы для всех более-менее употребимых типов. Ну я добавил еще URI, IP и мета-типы Enum и Tag.

Теперь безо всяких дополнительных манипуляций мы можем определить структуру User как показано выше и попробовать нагенерировать пару-тройку пользователей.

iex|%_{}|1 ▶ Estructura.User.__generator__ |> Stream.drop(10) |> Enum.take(1)
[
  %Estructura.User{
    data: %Estructura.User.Data{age: 0.853515625},
    name: "cn",
    address: %Estructura.User.Address{
      city: "						
Источник: https://habr.com/ru/articles/887018/


Интересные статьи

Интересные статьи

В этой статье рассмотрим, как реализовать аутентификацию с помощью централизованного сервера авторизации (Authorization Server) и API-шлюза (API Gateway).
Решения для больших компаний обычно должны выдерживать высокие нагрузки. Когда в штате много десятков тысяч человек, и значительная доля из них ежедневно пользуются ...
Месяц назад один из моих подкастов стал лучшим по итогам премии «Золотого сайта» 2020 года. Он выходит на Хабре в виде текстовых расшифровок и — как мне кажется — этот фа...
Этот пост будет из серии, об инструментах безопасности, которые доступны в Битриксе сразу «из коробки». Перечислю их все, скажу какой инструмент в какой редакции Битрикса доступен, кратко и не очень р...
На сегодняшний день у сервиса «Битрикс24» нет сотен гигабит трафика, нет огромного парка серверов (хотя и существующих, конечно, немало). Но для многих клиентов он является основным инструментом ...