Как стать автором
Поиск
Написать публикацию
Обновить

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

Время на прочтение5 мин
Количество просмотров1.1K

Уже более двадцати лет в индустрии принято тестировать написанный код до выкатывания его в продакшн. Люди придумали 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: "񦦔𴃩󸒥򖔝",
      street: %Estructura.User.Address.Street{
        name: ["ZkPjN", "tf", "J8", "kBA6iaQ"],
        house: 3
      }
    },
    ip: ~IP[123.67.34.92],
    title: "junior",
    tags: ["backend", "frontend"],
    person: "cn, 񦦔𴃩󸒥򖔝",
    created_at: ~U[2025-02-28 12:36:02Z],
    homepage: [
      %URI{
        scheme: "https",
        userinfo: nil,
        host: "example.com",
        port: 443,
        path: "/",
        query: "bar=ok",
        fragment: "anchor2"
      }
    ],
    birthday: ~D[2025-02-05]
  }
]

Ну а теперь можно заняться property-based тестированием, например, проверить, что возраст должен быть больше 18, или типа того.

Разумеется, если есть дополнительные требования к формату полей, генератор можно подкрутить в любом месте. Можно даже заставить его брать данные для адреса из Faker, но я лично крайне не рекомендкю так делать; лучше сразу увидеть, как ваш код загибается на юникоде из третьей плоскости, чем наткнуться на это в продакшене.

Заключение

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

Теги:
Хабы:
Всего голосов 4: ↑3 и ↓1+3
Комментарии8

Публикации

Ближайшие события