Содержание
- Введение
- Варианты решения
- 1. Написать фикстуры руками
- 2. Сгенерировать через LLM
- 3. Написать скрипт для генерации фикстур
- Как сгенерировать фикстуру на основе описания типа?
- Поиск готовой реализации
- Библиотеки для генерации тестовых данных
- Тип —> JSON schema —> Фикстура
- Генерация фикстуры на основе типа
- Создание собственной реализации
- Подытожим
Введение
В одном из рабочих проектов есть около 120 энтрипоинтов — фронтовых приложений. Каждый энтрипоинт принимает на входе ModuleData — объект с данными, необходимыми для отрисовки приложения. Структура ModuleData в каждом энтрипоинте своя.
Для реализации одной из задач потребовалось сгенерировать фикстуры для каждого из энтрипоинтов. Фикстура — объект, который своей структурой полностью соответствует структуре ожидаемой ModuleData, но при этом его содержимое (значение конкретных полей) не имеет значения. Важно только чтобы структура была полностью идентична и поля соответствовали ожидаемым типам.
Как решить эту задачу?
Варианты решения
Вариантов решения может быть несколько.
1. Написать фикстуры руками
Вариант неплохой, но только если в проекте немного энтрипоинтов. Для 120 — описывать всё руками очень долго, нудно и неэффективно.
2. Сгенерировать через LLM
Более современный вариант. Можно попросить модель изучить все энтрипоинты и сгенерировать фикстуры. Она отлично с этим справится, и если задача разовая, то этот вариант очень даже неплох. Но в моём кейсе нужно было генерировать фикстуры регулярно (на каждый билд), поэтому хотелось иметь какое-то программное решение.
3. Написать скрипт для генерации фикстур
Проект написан на TypeScript’е. В каждом энтрипоинте есть описание типа / интерфейса ожидаемой ModuleData.
Появилась идея — что если взять имеющийся у нас тип, и на основе его структуры сгенерировать фикстуру?
Если удастся реализовать, то скрипт можно будет встроить в пайплайн сборки, и при любом изменении ModuleData фикстура автоматически обновится.
Для моей задачи этот вариант подходит больше всего, так как не нужно будет вручную следить и адаптировать фикстуры после каждого обновления контракта.
Как сгенерировать фикстуру на основе описания типа?
Решение видится следующим: есть некая функция, в которую мы передаем тип / интерфейс или его название, а на выходе получаем готовую фикстуру.
Например, есть тип ModuleData со следующей структурой.
// структура данных, которую ожидает энтрипоинт на входе
type ModuleData = {
header: Header;
footer: Footer;
}
type Header = {
user: {
name: string;
subscriptionsCount: number;
}
};
type Footer = {
links: Array<{
name: string;
url: string;
}>;
};
Далее каким-то образом нужно передать его в функцию.
// например, передать имя типа
const fixture = getFixture('ModuleData');
На выходе должны получить объект, который полностью соответствует переданному типу.
{
"header": {
"user": {
"name": "name",
"subscriptionsCount": 1
}
},
"footer": {
"links": [
{
"name": "name",
"url": "url"
},
{
"name": "name",
"url": "url"
},
{
"name": "name",
"url": "url"
}
]
}
}
Поиск готовой реализации
Я подумал — “Наверняка, кто-нибудь уже решал похожую задачу и есть библиотеки, позволяющие сделать подобное” — и пошел искать.
Библиотеки для генерации тестовых данных
Есть много библиотек, которые предоставляют функции для генерации типовых данных: username, id, email и т.д. Например, faker. Их суть сводится к тому, что с помощью методов библиотеки создается фабрика, которая возвращает нужные объекты.
Для простоты будем разбирать последующие примеры на основе типа User.
import { faker } from '@faker-js/faker';
type User = {
id: string;
email: string;
firstName: string;
lastName: string;
rating: number;
isActive: boolean;
};
const createRandomUser = (): User => {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
rating: faker.datatype.number({ min: 0, max: 5 }),
isActive: faker.datatype.boolean(),
};
};
const user = createRandomUser();
Удобно, что есть множество готовых типовых стабов, но по сути, этот способ ничем не отличается от ручного создания фикстуры, так как придется для каждого энтрипоинта руками описать нужные фабрики.
Подобный подход отлично подходит для написания стабов под тесты, но под мою задачу не очень, так как хочется совсем исключить ручное описание фикстур.
Тип —> JSON schema —> Фикстура
Далее я наткнулся на библиотеки typescript-json-schema, ts-json-schema-generator, которые позволяют на основе типа сгенерировать JSON schema.
// user.ts
type User = {
id: string;
email: string;
firstName: string;
lastName: string;
rating: number;
isActive: boolean;
};
// user-schema.ts
import tsj from 'ts-json-schema-generator';
const config = {
tsconfig: 'tsconfig.json',
path: 'user.ts',
type: 'User',
};
const generator = tsj.createGenerator(config);
export const schema = generator.createSchema(config.type);
В результате должна получиться JSON schema, описывающая тип User.
{
"$schema": "http://json-schema.org",
"title": "User",
"type": "object",
"properties": {
"id": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"rating": {
"type": "number"
},
"isActive": {
"type": "boolean"
}
},
"required": [
"id",
"email",
"firstName",
"lastName",
"rating",
"isActive"
],
"additionalProperties": false
}
Затем используя сформированную схему и json-schema-faker, можно создать фикстуру.
import { generate } from 'json-schema-faker';
import { schema } from './user-schema';
const fixture = await generate(schema);
Но у меня этот способ не взлетел: и typescript-json-schema, и ts-json-schema-generator возвращали ошибки при попытке создать JSON schema в моём проекте. Плюс, показалось, что этот способ более сложный, так как требует создание промежуточных схем, которые мне не были нужны, и хотелось решение попроще.
Генерация фикстуры на основе типа
В какой-то момент я наткнулся на библиотеку от гугла — intermock. Судя по документации она делает ровно то, что мне нужно: получает на входе тип, возвращает готовую фикстуру.
Библиотека рассчитана больше под CLI использование
intermock --files user.ts --interfaces User
но можно использовать и программно
import fs from 'fs';
import { mock } from 'intermock';
const getFileContent = (filePath: string): string => {
return fs.readFileSync(filePath).toString();
};
const fixture = mock({
language: 'typescript',
output: 'object',
files: [['user.ts', getFileContent('user.ts')]],
interfaces: ['User'],
});
На выходе получается вот такой объект:
{
"User": {
"id": "18998a7c-4d8d-473d-809e-afa51223874a",
"email": "Jules_Hoppe68@gmail.com",
"firstName": "Thurman",
"lastName": "Sauer",
"rating": 4,
"isActive": true
}
}
То есть данная библиотека работает и, кажется, решает мою задачу, но есть пара нюансов.
intermockне покрывает все возможности TypeScript, которые используются в моём проекте.
Например, если одно из свойств типа User будет содержать в себе ссылку на другой тип, то intermock этого не понимает, и просто возвращает пустой объект, даже если передать файл, где описан вспомогательный тип.
// contacts.ts
export type Contacts = {
phone: string;
email: string;
address: string;
};
// user.ts
import type { Contacts } from './contacts';
type User = {
id: string;
firstName: string;
lastName: string;
rating: number;
isActive: boolean;
contacts: Contacts,
};
// script.ts
const fixture = mock({
files: [
['user.ts', getFileContent('user.ts')]
// передаем дополнительный файл, где описан тип Contacts
['contacts.ts', getFileContent('contacts.ts')]
],
interfaces: ['User'],
});
Результат будет таким.
{
"User": {
"id": "18998a7c-4d8d-473d-809e-afa51223874a",
"firstName": "Thurman",
"lastName": "Sauer",
"rating": 4,
"isActive": true,
"contacts": {} // пустой объект, вместо нужной структуры
}
}
При этом наследование интерфейсов библиотека отлично понимает.
// contacts.ts
export interface Contacts {
phone: string;
email: string;
address: string;
}
// user.ts
import type { Contacts } from './contacts';
interface User extends Contacts {
id: string;
firstName: string;
lastName: string;
};
// script.ts
const fixture = mock({
files: [
['user.ts', getFileContent('user.ts')]
['contacts.ts', getFileContent('contacts.ts')]
],
interfaces: ['User'],
});
В данном случае библиотека все сделает, как и ожидается.
{
"User": {
"phone": "(179) 305-2418",
"email": "Gaylord38@yahoo.com",
"address": "Voluptas debitis voluptatem velit.",
"id": "18998a7c-4d8d-473d-809e-afa51223874a",
"firstName": "Thurman",
"lastName": "Sauer",
"rating": 4,
"isActive": true
}
}
- репозиторий
intermockархивирован на гитхабе и больше не поддерживается. За все время накопилось около 30 issue и они уже не будут решены. Поэтому, даже если бы библиотека решала все мои потребности, завязываться на нее было не очень разумно.
Создание собственной реализации
Готовых библиотек полностью решающих мою задачу не нашлось. Нужно писать реализацию самому.
Я подумал, что вот есть же у нас TypeScript, с помощью которого описаны все типы в проекте. Есть TypeScript Compiler, который умеет раскладывать сложные, вложенные типы до простых структур. Нужно лишь получить API, который позволит работать с получившимся AST-деревом и перебирать его, чтобы сгенерировать фикстуру.
Поиски привели меня к библиотеке ts-morph. Она является обёрткой над API TypeScript Compiler и предоставляет удобные методы для работы с ним.
Чтобы начать работать с ts-morph, необходимо сперва инициализировать проект и передать в него путь до tsconfig.json.
import { Project } from 'ts-morph';
const project = new Project({
tsConfigFilePath: 'path/to/tsconfig.json',
});
Далее нужно добавить в проект файл, который содержит нужный нам тип и зарезолвить все его зависимости.
const sourceFile = project.addSourceFileAtPath('user.ts');
project.resolveSourceFileDependencies();
Затем можно сформировать алиас для нашего типа
const root = sourceFile.addTypeAlias({
name: '__Fixture__',
type: 'User',
});
и получить его структуру
const rootType = root.getType();
Теперь, когда у нас есть структура типа, остается только пробежаться по ней и создать нужные стабовые данные. Для этого я написал несколько хелперов.
Первый возвращает новое целое число для какого-то из ключей — будем его использовать в качестве префикса, чтобы данные хотя бы немного друг от друга отличались.
const counters: Record<string, number> = {};
const seed = (key: string) => {
counters[key] = (counters[key] ?? 1) + 1;
return counters[key];
};
Второй хелпер рекурсивно перебирает пришедшую на вход структуру типа и в итоге возвращает готовую фикстуру.
import { TypeAliasDeclaration, Type } from 'ts-morph';
const getFixture = (
root: TypeAliasDeclaration,
type: Type,
hint: string,
seen = new Set(),
): unknown => {
if (type.isString()) {
return `${hint ?? 'str'}_${seed('string')}`;
}
if (type.isNumber()) {
return seed('number');
}
if (type.isBoolean()) {
return false;
}
if (type.isBooleanLiteral()) {
return type.getText() === 'true';
}
if (type.isStringLiteral()) {
return type.getLiteralValue();
}
if (type.isNumberLiteral()) {
return type.getLiteralValue();
}
if (type.isEnum() || type.isEnumLiteral()) {
const m = type.isUnion() ? type.getUnionTypes()[0] : type;
return m.getLiteralValue?.() ?? null;
}
// для юниона выбираем
// первое не null, не undefined, не never значение
if (type.isUnion()) {
const real = type.getUnionTypes().filter((u) => !u.isUndefined() && !u.isNull() && !u.isNever());
return getFixture(root, real[0] ?? type.getUnionTypes()[0], hint, seen);
}
if (type.isTuple()) {
return type.getTupleElements().map((t) => getFixture(root, t, hint, seen));
}
if (type.isArray()) {
const el = type.getArrayElementType();
if (el === undefined) {
return [];
}
if (seen.has(el.getText())) {
return [];
}
return Array.from({ length: 4 }, () => getFixture(root, el, hint, seen));
}
if (type.isObject() || type.isIntersection()) {
const idx = type.getStringIndexType() ?? type.getNumberIndexType();
if (idx) {
return { key: getFixture(root, idx, hint, seen) };
}
const key = type.getText();
if (seen.has(key)) {
return undefined;
}
const seen2 = new Set(seen).add(key);
const obj = {};
for (const prop of type.getProperties()) {
// getTypeAtLocation всегда зовём относительно root —
// иначе у дженериков теряется контекст подстановки
const v = getFixture(root, prop.getTypeAtLocation(root), prop.getName(), seen2);
if (v === undefined) {
continue; // опциональное поле / оборванный цикл
}
obj[prop.getName()] = v;
}
return obj;
}
return null;
};
В итоге, для типа User
type User = {
id: string;
email: string;
firstName: string;
lastName: string;
rating: number;
isActive: boolean;
};
вызов самописной реализации
const fixture = getFixture(root, rootType);
вернет такую фикстуру.
{
"id": "id_2",
"email": "email_3",
"firstName": "firstName_4",
"lastName": "lastName_5",
"rating": 2,
"isActive": false
}
С примером из начала статьи
type ModuleData = {
header: Header;
footer: Footer;
}
type Header = {
user: {
name: string;
subscriptionsCount: number;
}
};
type Footer = {
links: Array<{
name: string;
url: string;
}>;
};
самописная реализация тоже справляется и на выходе получается объект с нужной структурой.
{
"header": {
"user": {
"name": "name_2",
"subscriptionsCount": 2
}
},
"footer": {
"links": [
{
"name": "name_3",
"url": "url_4"
},
{
"name": "name_5",
"url": "url_6"
},
{
"name": "name_7",
"url": "url_8"
},
{
"name": "name_9",
"url": "url_10"
}
]
}
}
Подытожим
Для генерации фикстур можно использовать, как ручной, так и частично или полностью автоматизированный подходы.
Есть готовые библиотеки, которые могут по-разному облегчить решение подобной задачи.
В моём случае не нашлось полностью подходящего готового решения, поэтому была написана собственная реализация на основе ts-morph.
В итоге, получилось решение, которое учитывает настройки TypeScript’а в проекте, а также понимает всю используемую функциональность данного языка. Благодаря этому удалось написать скрипт, который автоматически генерирует фикстуры для всех энтрипоинтов в проекте. Скрипт встроили в пайплайн сборки, и теперь, даже если какой-то из контрактов поменяется, то при новом билде фикстура обновится и отразит эти изменения.
Из возможных улучшений — можно подружить получившуюся реализацию с faker, чтобы стабовые данные внутри фикстуры были более реалистичными.