Уже более двадцати лет в индустрии принято тестировать написанный код до выкатывания его в продакшн. Люди придумали 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 году — не только хороший тон, но и вообще признак зрелости разработчика.