Привет! Сейчас покажу штуку, которую я довольно долго доводил до ума, и мне кажется, она может пригодиться не только мне.

Задача звучит скучно: нагенерировать тестовые карточки людей. Пол, имя, диагноз. Скучно ровно до того момента, пока не посмотришь, что получилось:

женский, Ольга, Аденома простаты

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

Причина простая: обычный генератор заполняет поля по отдельности. Он не в курсе, что диагноз как‑то связан с полом, — ему про это никто не говорил. И пока полей два‑три, всё нормально. А потом их становится пятнадцать, половина связана друг с другом, и фикстура начинает тихо врать.

Такие инструменты, конечно, есть, и не я один это придумал. Готовя эту статью, я даже проверил даты и выяснил, что в своё время просто плохо искал: Snowfakery и Synth уже существовали. Ну и ладно. Своё уже написано, работает, и жалко мне его не было — поэтому вот, рассказываю, как оно устроено внутри. Заодно, может, кому пригодится.

Поехали.

Карточка пользователя

Я решил сделать свой декларативный язык — такой, чтобы связанные данные описывались просто. Отталкивался от привычной штуки: карточки. В тестировании часто работают с профилем пользователя в виде такой карточки — пол, имя, фамилия, дата рождения, ну и что там ещё нужно по задаче.

Карточка пользователя
Карточка пользователя

Вот такую карточку и хочется получать. Обратите внимание: тут всё сходится между собой

Дальше вопрос был один: как сделать, чтобы поля в карточке знали друг о друге?

Начал с самого простого. Пусть будет набор значений, всего два — мужчина и женщина. Назовём его по‑английски: gender.

Последовательность и генератор

Как такой набор выглядит на десяти карточках? Просто десять значений подряд, одно на карточку — по сути массив. Такой ряд значений я и назвал последовательностью, по‑английски sequence.

А внутри последовательности надо разместить генератор — то, что её наполняет. В нашем случае он должен выдавать всего два значения: мужчина или женщина.

И вот мой первый код выглядел примерно так:

<sequence name="Gender">
    <gen type="text" value="Male,Female"/>
</sequence>

sequence с именем Gender — это и есть тот самый ряд значений, у него просто появилось имя. Внутри текстовый генератор, у которого всего два значения: Male или Female.

Сколько именно значений он выдаст и в каком порядке — задаётся снаружи, в обёртке вокруг всех последовательностей:

<env count="10" seed="demo">
    ... здесь лежат все sequence ...
</env>

count — сколько карточек сгенерировать. seed — стартовое число для случайности: с одним и тем же сидом получаются одни и те же карточки, сколько раз ни запускай. Дальше я его не показываю, чтобы не загромождать примеры, но он есть всегда.

Сама секвенция ничего не решает — это просто именованный ряд ячеек. Наполняет его генератор: раскладывает свои значения по карточкам, в случайном порядке. Получается что‑то вроде такого:

["Female", "Female", "Male",   "Male",  "Male",  "Male",    "Female", "Female", "Female", "Male"]

Последовательность — это именованный ряд ячеек, по одной на карточку. Наполняет его генератор

Проценты я здесь нигде не задавал — а если их не задать, значения делятся поровну. И вот тут стоит сказать одну вещь, которая для меня принципиальна: делятся они не «примерно», а точно. На десяти карточках всегда выйдет ровно пять мужчин и ровно пять женщин — хоть сто раз перезапустите с разными сидами. Случаен здесь только порядок, в котором они лягут по карточкам, а не количество.

Это не то же самое, что бросать монетку на каждую карточку. Монетка на десяти бросках запросто даст семь и три — и потом вы полдня ищете, почему тест иногда падает. Здесь доли раскладываются заранее, на всю выборку, и только потом перемешиваются. Если карточек нечётное число, остаток уходит той доле, у которой больше недобор: на семи карточках получится четыре и три, на ста одной — пятьдесят один и пятьдесят.

Теперь самое главное — связь

Итак, мы получили первую последовательность. Она у нас красивая, всё нам вроде нравится. А теперь давайте сделаем из этого самое главное — связь. И на помощь нам приходит вот такой атрибут, который позволяет указать родителя:

<sequence name="Gender">
    <gen type="text" value="Male,Female"/>
</sequence>

<sequence name="FirstName" parent="Gender">
    <gen if="Gender.Male"   type="file" src="male.firstName.txt"/>
    <gen if="Gender.Female" type="file" src="female.firstName.txt"/>
</sequence>

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

Сразу оговорюсь про файлы, чтобы не создалось неверного впечатления. Брать данные можно не только из них: есть генераторы, работающие как Faker — со списками, уже заготовленными в языковых пакетах, и тогда никаких файлов заводить не надо. Я выбрал файлы специально: так видно, что источником может быть что угодно, хоть ваша собственная выгрузка из базы.

Первое, что здесь непривычно: внутри одной последовательности стоят сразу два генератора. Так можно — их бывает и больше. Последовательность от этого не удваивается: это по‑прежнему один ряд значений, по одному на карточку. Просто заполнять его могут разные генераторы, каждый в своём случае.

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

parent="Gender" — это связь. Она говорит: эта последовательность зависит от той. Отсюда следует порядок — сначала на всю выборку раскладывается Gender, и только потом заполняется FirstName. Без этого имя могло бы посчитаться раньше пола, и связывать было бы не с чем.

parent задаёт порядок: сначала целиком раскладывается ряд значений пола — и только когда он готов, заполняется зависимый от него ряд имён

if="Gender.Male" — это условие запуска конкретного генератора. В нём проверяется, что в данный момент генерации для вот именно этой карточки гендер равен мужскому — и только тогда генератор начинает брать мужское имя и вставлять его. Если же это не так, генератор даже не запустится и вообще ничего не вернёт. Такая же история с генератором для женских имён: он включается только тогда, когда обрабатываемая ячейка выдаёт, что Gender.Female истинно.

Коротко: parent отвечает на вопрос «от чего зависим и что считаем раньше», а if — на вопрос «какой из генераторов сработает в этой строке».

Второй генератор не «отфильтрован» — он просто не запускается и ничего не возвращает

Запускается это одной командой в терминале — конфиг на вход, данные на выход:

$ tdcv2 people.tdc

Вместе с библиотекой ставится и консольная утилита, так что генератором можно пользоваться как обычной программой — из скрипта, из CI, откуда угодно. А можно дёргать из кода как библиотеку, если данные нужны прямо внутри теста.

И вот что мы увидим приблизительно, когда прогоним такой файл на десять карточек. Здесь сформируется своего рода двумерный массив: последовательностей у нас две, значит и строк в массиве две.

Сразу хочу оговориться. Я привожу примеры массивов именно потому, что так легче всего понять, как это работает. Внутри устроено иначе: на быстром движке значения в памяти вообще не лежат — каждая карточка вычисляется в тот момент, когда до неё дошла очередь. Поэтому миллион строк занимает столько же памяти, сколько десять. Но для понимания лучше показать на примере массива: человеческому мозгу, особенно если человек первый раз такое видит, будет легче воспринимать. И сама концепция, мне кажется, будет легче усваиваться. А может, и нет.

Как это примерно могло бы выглядеть, если бы это был двумерный массив:

[["Female", "Female", "Male",   "Male",  "Male",  "Male",    "Female", "Female", "Female", "Male"]
 ["Анна",   "Елена",  "Никита", "Денис", "Денис", "Николай", "Елена",  "Анна",   "Дарья",  "Николай"]]

И вот тут мы уже видим совпадение: если посмотреть в столбик, сверху вниз, то каждая карточка — это по сути столбик, который мы можем собирать из многомерных данных, то есть постоянно подключать что‑то новое. Добавим третью последовательность — появится третий ряд, а карточка станет из трёх этажей. Именно это мы и сделаем чуть позже.

Две последовательности рядом. Карточка — это столбик сверху вниз: каждая новая последовательность добавляет ряд, а связи держатся

«А зачем такие сложности?»

Человек, который работал с какой‑нибудь программой типа Faker.js, скорее всего скажет: ой, зачем такие сложности, ведь можно было бы просто запросить полное имя с фамилией — и там сразу же вылезет то, что ты запросил у Faker.

Тут могу сказать следующее. Дело в том, что вот это поле Gender влияет не только на фамилию — оно может влиять на медицинский диагноз. Вы же понимаете, что болезни есть общего плана, которыми болеют и мужчины, и женщины, а есть такие, которые в карточке определённого пола появиться просто не должны. Справочник диагнозов это знает — а генератор, который заполняет поля по отдельности, нет.

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

Добавляем болезни, да ещё и с распределением

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

Для начала представим, что у нас есть несколько файлов. Два с именами, которые уже были, и три новых — с диагнозами:

male.firstName.txt     - мужские имена: Олег, Никита, Сергей...
female.firstName.txt   - женские имена: Марина, Светлана, Дарья...
male.diagnosis.txt     - только мужские диагнозы: аденома простаты, варикоцеле...
female.diagnosis.txt   - только женские: миома матки, эндометрит...
common.diagnosis.txt   - общие, бывают у всех: анемия, мигрень, гастрит...

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

Хочу я вот чего: у мужчин каждая пятая карточка — со специфично мужской болезнью, у женщин каждая пятая — со специфично женской, а всем остальным достаются общие. Каждая пятая — это 20%, и именно это число вы дальше и увидите в конфиге.

Если нарисовать, чего я хочу добиться, получается вот такое дерево:

Сначала развилка по полу, потом в каждой ветке — доли. Общий файл достаётся обеим веткам

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

<switch name="Diagnosis" on="Gender">
    <case is="Male">
        <mix percent="20,80">
            <case><gen type="file" src="male.diagnosis.txt"/></case>
            <case><gen type="file" src="common.diagnosis.txt"/></case>
        </mix>
    </case>
    <case is="Female">
        <mix percent="20,80">
            <case><gen type="file" src="female.diagnosis.txt"/></case>
            <case><gen type="file" src="common.diagnosis.txt"/></case>
        </mix>
    </case>
</switch>

Здесь сразу две новые конструкции, и они вложены одна в другую. Разберём по очереди.

switch — это развилка. Он смотрит на значение другой последовательности (какой именно — сказано в on="Gender") и в зависимости от него выбирает, какую ветку выполнять. Мужская карточка идёт в case is="Male", женская — в case is="Female". Третьего не дано, потому что у Gender других значений нет.

mix — это раскладка долей. Он не выбирает по условию, а делит строки в заданной пропорции: внутри лежат варианты, каждый в своём case, а percent="20,80" говорит, что первому достанется 20% строк, второму — 80%.

И здесь работает ровно то же правило, что и с полом выше: это не взвешенный рандом, а квота. Не «у каждой строки 20% шанс», а «ровно пятая часть строк, и ни строкой меньше». Движок сначала считает, сколько карточек кому причитается, и только потом перемешивает порядок. Поэтому распределение можно проверять ассертом, а не глазом.

А вместе получается вот что: сначала карточка попадает в свою половину по полу, и уже внутри этой половины разыгрываются доли. То есть каждая пятая мужская карточка получает мужской диагноз, а остальные четыре — общий. У женщин ровно так же, своим файлом.

И вот здесь стоит притормозить, потому что в процентах легко запутаться. В конфиге везде написано 20 на 80 — но эти 20% считаются не от всей выборки, а внутри своей половины. Про то, что пол мы до этого поделили пополам, легко забыть, а он‑то как раз всё и решает.

Возьмём тысячу карточек. Пол делится поровну — значит мужчин 500. Каждый пятый из них и есть те самые 20%, то есть 100 человек. А сто человек от всей тысячи — это уже 10%. У женщин ровно та же цепочка. Остаток, 800 карточек, забирают общие диагнозы — 80%.

Так что 20% и 10% тут друг другу не противоречат: это одно и то же количество, просто посчитанное от разных величин. 20% от половины и есть 10% от целого. Ровно то, что я и хотел.

А вот весь конфиг целиком, чтобы было видно, как три последовательности стоят рядом:

<env count="10" seed="demo">
    <sequence name="Gender">
        <gen type="text" value="Male,Female"/>
    </sequence>
    <sequence name="FirstName" parent="Gender">
        <gen if="Gender.Male"   type="file" src="male.firstName.txt"/>
        <gen if="Gender.Female" type="file" src="female.firstName.txt"/>
    </sequence>
    <switch name="Diagnosis" on="Gender">
        <case is="Male">
            <mix percent="20,80">
                <case><gen type="file" src="male.diagnosis.txt"/></case>
                <case><gen type="file" src="common.diagnosis.txt"/></case>
            </mix>
        </case>
        <case is="Female">
            <mix percent="20,80">
                <case><gen type="file" src="female.diagnosis.txt"/></case>
                <case><gen type="file" src="common.diagnosis.txt"/></case>
            </mix>
        </case>
    </switch>
</env>

Отдельно отмечу, что вкладывать так можно как угодно и на любую глубину: switch в switch, mix в mix, mix в switch и наоборот. Комбинируется всё со всем — и это открывает дорогу к куда более сложным зависимостям, чем в моём примере.

Все четыре сочетания работают, и глубина не ограничена

Откуда берутся 10%, если в конфиге написано 20%. Двадцать процентов от пятисот — это сто, а сто от тысячи — уже десять процентов

Проверим на тысяче карточек. Не «примерно десять» — ровно сто, сто и восемьсот:

мужские специфичные   100   10.0 %
женские специфичные   100   10.0 %
общие                 800   80.0 %

Те самые 20% внутри каждой половины — вот они же, пересчитанные на всю тысячу.

А вот как выглядят первые десять карточек:

Female, Анна,    Хронический гастрит
Female, Елена,   Мигрень
Male,   Никита,  Анемия
Male,   Денис,   Варикоцеле
Male,   Денис,   Хронический гастрит
Male,   Николай, Анемия
Female, Елена,   Анемия
Female, Анна,    Эндометрит
Female, Дарья,   Анемия
Male,   Николай, Мигрень

И вот уже из безобидного примера мы получили довольно‑таки интересный набор. У Анны эндометрит — болезнь, которой у мужчины быть не может. У Дениса варикоцеле, которое никогда не попадёт в женскую карточку. А анемия, мигрень и гастрит достаются и тем, и другим — потому что лежат в общем файле.

Что из этого выросло

То, что я показал, — самая основа, с неё всё начиналось. За ней потянулось остальное, и каждый пункт вырос из той же одной мысли: поле может зависеть от поля.

  • Уникальные значения — чтобы значение не повторялось от карточки к карточке, а внутри одной карточки два поля не совпадали между собой.

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

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

  • Распределение, которое рисуешь картинкой — рисуешь кривую в редакторе, и данные ложатся по её форме.

И две вещи, которые я считаю в этой истории главными.

Один и тот же конфиг работает в пяти языках. TypeScript, Python, Java, C# и Rust — и выдают они не «похожие» данные, а совпадающие. Один файл, один сид, одни и те же карточки, куда бы вы их ни подключили: в джава‑сервис, в питоновый пайплайн, в тайпскриптовые тесты.

Пять независимых реализаций одного языка. Один конфиг, один сид — и вывод совпадает построчно, а не «примерно похож»

И вывод можно сложить в любой формат. Данные отдаются либо прямо в программу, либо в файл — и структуру этого файла вы описываете сами, там же в конфиге. CSV, JSON, XML, YAML, SQL‑дамп, лог под ваш парсер, да хоть формат, который придумают через два года. Готового списка форматов просто нет, потому что он вам не нужен. Как это устроено — тема отдельной статьи, здесь бы не поместилось.

Пет‑проект я вёл для себя несколько лет, в 2023-м завёл под него первый репозиторий — приватный, потому что выкладывать было незачем: инструмент работал ровно под мои задачи, и половина задуманного в нём просто не была сделана. В этом году я решил, что из него получится нормальный открытый проект, и переписал движок целиком. Называется TDCV2, лежит под MIT, платных версий не будет.

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