Тест с захардкоженным alice@example.com получит конфликт уникальности при втором прогоне если очистка предыдущего теста не сработала. Faker.js решает это генерируемыми данными, но вызов faker.internet.email() дважды (один раз для заполнения формы, другой для проверки что значение появилось) даёт два разных значения: генерируй один раз, сохраняй в переменную, используй везде. Эта статья разбирает паттерн фабричной функции которая генерирует все поля за один вызов и принимает переопределения для того что реально важно тесту, UUID-адреса гарантирующие отсутствие коллизий, API-сидирование которое полностью обходит UI регистрации, и Playwright-фикстуры которые запускают очистку безусловно независимо от результата теста.

Проблема захардкоженных данных

Захардкоженные данные ломаются тремя способами, и каждый усугубляет остальные.

Первый: конфликты уникальности. Большинство реальных приложений требуют уникальных email, логинов, номеров заказов. Если тест использует alice@example.com а вчерашний прогон оставил эту запись в базе, сегодняшний прогон упадёт при создании. Не потому что фича сломана, а потому что очистка не произошла.

Второй: разделяемое состояние. Пять тестов которые работают с item-id-42 ждут чтобы помешать друг другу. При параллельном запуске коллизии происходят постоянно. При последовательном достаточно часто чтобы казаться случайными.

Третий: хрупкость самих данных. Захардкоженная дата 2024-01-15 которая была «в будущем» когда ты писал тест сейчас два года в прошлом. Статус "pending" который имел смысл для воркфлоу переименован в "awaiting_approval". Каждое захардкоженное значение: долг обслуживания в будущем.

// Что нужно перестать делать
test('user can update their profile', async ({ page }) => {
  // Рано или поздно возникнут конфликты или устаревшие значения
  await loginAs(page, 'alice@example.com', 'password123');
  await page.goto('/profile');
  await page.getByLabel('Display name').fill('Alice Smith');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByText('Profile updated')).toBeVisible();
});

Решение не в том чтобы быть осторожнее с захардкоженными значениями. Решение в том чтобы перестать их хардкодить.

Faker.js: установка и основы

Faker.js генерирует реалистичные фейковые данные. Имена, email, адреса, UUID, даты, номера телефонов, названия продуктов: всё случайное, всё правдоподобное.

npm install --save-dev @faker-js/faker

API группирует генераторы по категориям. Вот те которые чаще всего нужны в тест-сьютах:

import { faker } from '@faker-js/faker';

// Личность
faker.person.firstName()           // 'Marcus'
faker.person.lastName()            // 'Holloway'
faker.internet.email()             // 'marcus.holloway@gmail.com'
faker.internet.username()          // 'marcus_holloway42'
faker.internet.password({ length: 12 }) // 'Kd9$mXp2vLqR'

// ID и ссылки
faker.string.uuid()                // 'e2d4f6a8-...'
faker.number.int({ min: 1, max: 9999 }) // 7342

// Даты
faker.date.future()                // объект Date в будущем
faker.date.past({ years: 2 })      // объект Date за последние 2 года
faker.date.between({ from: '2025-01-01', to: '2025-12-31' }) // Date в диапазоне

// Контент
faker.lorem.sentence()             // 'Voluptas et dolorem rerum.'
faker.commerce.productName()       // 'Sleek Rubber Shoes'
faker.commerce.price()             // '42.99'

Инициализируй Faker через faker.seed(12345) в начале тестового файла чтобы сделать генерацию детерминированной. Один и тот же seed всегда даёт одну и ту же последовательность значений. Удобно для отладки флакующего теста зависящего от конкретных генерируемых данных: запусти с seed, зафиксируй данные, стабильно воспроизводи падение.

Важная привычка: генерируй значение один раз и сохраняй в переменную. Не вызывай faker.internet.email() в двух местах ожидая одинаковый результат.

// Неверно — два разных email
await page.getByLabel('Email').fill(faker.internet.email());
await expect(page.getByText(faker.internet.email())).toBeVisible(); // другое значение!

// Верно — генерируй один раз, используй везде
const email = faker.internet.email();
await page.getByLabel('Email').fill(email);
await expect(page.getByText(email)).toBeVisible();

Фабричные функции: buildUser() и buildOrder()

Фабричная функция: обычная TypeScript-функция которая возвращает полный объект данных. Faker заполняет дефолты, а переопределения дают тестам возможность указать только то что реально важно.

// factories/user.factory.ts
import { faker } from '@faker-js/faker';

export interface User {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: Date;
}

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    email: faker.internet.email(),
    password: faker.internet.password({ length: 12 }),
    role: 'viewer',
    createdAt: faker.date.past({ years: 1 }),
    ...overrides,
  };
}

// factories/order.factory.ts
import { faker } from '@faker-js/faker';

export interface Order {
  id: string;
  customerId: string;
  items: { productId: string; quantity: number; price: number }[];
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
  total: number;
  placedAt: Date;
}

export function buildOrder(overrides: Partial<Order> = {}): Order {
  const items = Array.from({ length: faker.number.int({ min: 1, max: 4 }) }, () => ({
    productId: faker.string.uuid(),
    quantity: faker.number.int({ min: 1, max: 5 }),
    price: parseFloat(faker.commerce.price({ min: 5, max: 200 })),
  }));

  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return {
    id: faker.string.uuid(),
    customerId: faker.string.uuid(),
    items,
    status: 'pending',
    total: parseFloat(total.toFixed(2)),
    placedAt: new Date(),
    ...overrides,
  };
}

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

// Тест которому важна только роль пользователя
const adminUser = buildUser({ role: 'admin' });

// Тест которому важен только статус заказа
const shippedOrder = buildOrder({ status: 'shipped' });

// Тест которому нужен конкретный email (для логина)
const user = buildUser({ email: 'known-test-user@example.com' });

Тесты становятся выразительными. Когда видишь buildUser({ role: 'admin' }), сразу понятно что роль важна для этого теста. Остальные десять полей: неважный шум который Faker обработал за тебя.

Паттерн builder для сложных объектов

Для объектов со многими взаимозависимыми полями (где установка одного свойства должна менять другое) класс builder с fluent API читается лучше чем фабричная функция с большим объектом переопределений.

// builders/UserBuilder.ts
import { faker } from '@faker-js/faker';
import { User } from '../factories/user.factory';

export class UserBuilder {
  private user: User;

  constructor() {
    this.user = {
      firstName: faker.person.firstName(),
      lastName: faker.person.lastName(),
      email: faker.internet.email(),
      password: faker.internet.password({ length: 12 }),
      role: 'viewer',
      createdAt: new Date(),
    };
  }

  withRole(role: User['role']): UserBuilder {
    this.user.role = role;
    return this;
  }

  withEmail(email: string): UserBuilder {
    this.user.email = email;
    return this;
  }

  withName(firstName: string, lastName: string): UserBuilder {
    this.user.firstName = firstName;
    this.user.lastName = lastName;
    return this;
  }

  asAdmin(): UserBuilder {
    this.user.role = 'admin';
    this.user.email = `admin-${faker.string.uuid().slice(0, 8)}@company.com`;
    return this;
  }

  createdDaysAgo(days: number): UserBuilder {
    const date = new Date();
    date.setDate(date.getDate() - days);
    this.user.createdAt = date;
    return this;
  }

  build(): User {
    return { ...this.user };
  }
}

Код вызова читается почти как обычный английский:

const admin = new UserBuilder().asAdmin().build();

const recentUser = new UserBuilder()
  .withRole('editor')
  .createdDaysAgo(3)
  .build();

const namedUser = new UserBuilder()
  .withName('Jordan', 'Reeves')
  .withRole('viewer')
  .build();

Builder особенно ценен когда asAdmin() должен установить сразу несколько полей (роль, домен email, возможно флаг isVerified) и ты не хочешь разбрасывать эту логику по каждому тесту создающему пользователя-администратора.

Используй фабричные функции для простых объектов и классы builder там где часто нужны заранее определённые комбинации полей. Они хорошо совместимы: фабричная функция может внутри использовать builder если объект сложный.

API-сидирование данных

Создавать тестовые данные через UI медленно и хрупко. Регистрационный флоу который занимает 8 секунд в браузере выполняется за 80 миллисекунд через API. Важнее другое: UI-настройка связывает твой тест сразу с двумя фичами. Если форма регистрации сломается, каждый тест использующий её как настройку тоже сломается, даже если эти тесты проверяют что-то совершенно другое.

Фикстура request в Playwright даёт API-контекст который можно использовать прямо в настроечном коде.

// helpers/api.helpers.ts
import { APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';

const BASE_URL = process.env.API_BASE_URL ?? 'https://lab.becomeqa.com/api';

export async function createUserViaApi(
  request: APIRequestContext,
  overrides: Partial<User> = {}
): Promise<User & { id: string }> {
  const userData = buildUser(overrides);

  const response = await request.post(`${BASE_URL}/users`, {
    data: userData,
    headers: {
      Authorization: `Bearer ${process.env.API_SEED_TOKEN}`,
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok()) {
    throw new Error(`Failed to create user: ${response.status()} ${await response.text()}`);
  }

  const created = await response.json();
  return { ...userData, id: created.id };
}

export async function deleteUserViaApi(
  request: APIRequestContext,
  userId: string
): Promise<void> {
  await request.delete(`${BASE_URL}/users/${userId}`, {
    headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
  });
}

Тестам которым нужен пользователь достаточно вызвать хелпер и мгновенно получить реальную запись в базе:

test('admin can deactivate a user account', async ({ page, request }) => {
  const user = await createUserViaApi(request, { role: 'editor' });

  await page.goto('/admin/users');
  await page.getByTestId(`user-row-${user.id}`).getByRole('button', { name: 'Deactivate' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  await expect(page.getByTestId(`user-row-${user.id}`).getByText('Inactive')).toBeVisible();

  await deleteUserViaApi(request, user.id); // явная очистка
});

Этот тест проверяет UI деактивации администратором без зависимости от UI регистрации, UI логина или любого другого пути который мог бы упасть по несвязанной причине.

Никогда не используй продакшн-учётные данные или продакшн API-токен в тестах, даже для «быстрой проверки». Используй выделенное тестовое окружение с собственными учётными данными. Seed-токены хранятся в переменных окружения, не в закоммиченном коде.

Фикстура данных с автоматической очисткой

Тест выше обрабатывает очистку вручную. Это работает, но если тест бросает исключение до достижения deleteUserViaApi, очистка не выполнится и пользователь останется в базе. Playwright-фикстура решает это делая очистку безусловной.

// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildUser, User } from '../factories/user.factory';
import { createUserViaApi, deleteUserViaApi } from '../helpers/api.helpers';

type DataFixtures = {
  testUser: User & { id: string };
  testAdminUser: User & { id: string };
};

export const test = base.extend<DataFixtures>({
  testUser: async ({ request }, use) => {
    const user = await createUserViaApi(request);

    await use(user);

    // Выполняется после теста — при прохождении и при падении
    try {
      await deleteUserViaApi(request, user.id);
    } catch (error) {
      console.warn(`Cleanup failed for user ${user.id}:`, error);
    }
  },

  testAdminUser: async ({ request }, use) => {
    const user = await createUserViaApi(request, { role: 'admin' });

    await use(user);

    try {
      await deleteUserViaApi(request, user.id);
    } catch (error) {
      console.warn(`Cleanup failed for admin user ${user.id}:`, error);
    }
  },
});

export { expect } from '@playwright/test';

Тесты получают готового пользователя и никогда не занимаются очисткой сами:

// tests/profile.spec.ts
import { test, expect } from '../fixtures/data.fixture';

test('user can update their display name', async ({ page, testUser }) => {
  await loginAs(page, testUser.email, testUser.password);
  await page.goto('/profile');

  await page.getByLabel('Display name').fill('New Display Name');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Profile updated')).toBeVisible();
  // testUser удаляется после этой строки — автоматически
});

test('admin can view user details', async ({ page, testAdminUser, testUser }) => {
  await loginAs(page, testAdminUser.email, testAdminUser.password);
  await page.goto(`/admin/users/${testUser.id}`);

  await expect(page.getByText(testUser.email)).toBeVisible();
  // Оба пользователя удаляются после этого теста
});

try/catch в блоке teardown написан намеренно. Если очистка бросает исключение и ты его не перехватываешь, Playwright может вывести запутанное вторичное падение которое скрывает что реально пошло не так в самом тесте. Логируй предупреждение, но не перебрасывай исключение.

Данные с областью видимости worker для справочников

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

Фикстуры с областью видимости worker создают данные один раз на воркерный процесс и делятся ими со всеми тестами в этом воркере. Тип фикстуры переходит во второй дженерик параметр extend().

// fixtures/worker-data.fixture.ts
import { test as base } from '@playwright/test';

interface Category {
  id: string;
  name: string;
  slug: string;
}

type WorkerFixtures = {
  productCategories: Category[];
};

export const test = base.extend<{}, WorkerFixtures>({
  productCategories: [
    async ({ request }, use) => {
      // Сидируем категории один раз на воркер
      const created: Category[] = [];

      for (const name of ['Electronics', 'Books', 'Clothing']) {
        const response = await request.post('/api/categories', {
          data: { name, slug: name.toLowerCase() },
          headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
        });
        const category = await response.json();
        created.push(category);
      }

      await use(created);

      // Очистка после завершения всех тестов в воркере
      for (const category of created) {
        try {
          await request.delete(`/api/categories/${category.id}`, {
            headers: { Authorization: `Bearer ${process.env.API_SEED_TOKEN}` },
          });
        } catch (error) {
          console.warn(`Category cleanup failed for ${category.id}:`, error);
        }
      }
    },
    { scope: 'worker' },
  ],
});

export { expect } from '@playwright/test';

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

Никогда не используй область видимости worker для данных которые тесты модифицируют. Если один тест изменяет разделяемое состояние, последующие тесты в том же воркере увидят изменённую версию, и получишь трудно отлаживаемые зависимые от порядка падения.

Гарантия уникальности: UUID в email и ID без коллизий

Даже с Faker коллизии уникальности возможны. internet.email() тянет из пула имён и распространённых доменов, так что marcus.holloway@gmail.com может появиться дважды в длинном прогоне. Для любого поля которое база данных требует уникальным нужна стратегия гарантирующая отсутствие повторов.

Самый надёжный подход: встраивать UUID в значение:

// helpers/unique.ts
import { faker } from '@faker-js/faker';

export function uniqueEmail(prefix = 'test'): string {
  const id = faker.string.uuid().slice(0, 8);
  return `${prefix}+${id}@test-suite.local`;
}

export function uniqueUsername(): string {
  const id = faker.string.uuid().slice(0, 8);
  return `user_${id}`;
}

export function uniqueSlug(base: string): string {
  const id = faker.string.uuid().slice(0, 6);
  return `${base}-${id}`.toLowerCase().replace(/\s+/g, '-');
}

Результат: значения вроде test+a3f9c2b1@test-suite.local и user_a3f9c2b1. Восемь символов UUID дают 16^8 = 4,3 миллиарда возможных значений, вероятность коллизии в тест-сьюте практически нулевая. Домен test-suite.local также упрощает идентификацию и массовое удаление тестовых данных если очистка отстанет.

Обнови фабрику чтобы использовать эти хелперы:

// factories/user.factory.ts (обновлённый)
import { faker } from '@faker-js/faker';
import { uniqueEmail, uniqueUsername } from '../helpers/unique';

export function buildUser(overrides: Partial<User> = {}): User {
  return {
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    email: uniqueEmail(),          // гарантированно уникальный
    username: uniqueUsername(),    // гарантированно уникальный
    password: faker.internet.password({ length: 12 }),
    role: 'viewer',
    createdAt: faker.date.past({ years: 1 }),
    ...overrides,
  };
}

Если приложение строго валидирует email-домены, замени test-suite.local на реальный домен который ты контролируешь, или настрой специальный тестовый домен в списке разрешённых в стейджинг-окружении. Некоторые приложения также отвергают + в email-адресах, в таком случае используй UUID-префиксный субдомен: a3f9c2b1.test@yourdomain.com.

Для числовых ID генерируемых последовательностью базы данных, последовательность сама обеспечивает уникальность и генерировать ID заранее не нужно. Просто дай API вернуть созданный ID и используй его в тесте. Генерируй ID на клиенте только когда тестируешь системы принимающие ID от клиента, например UUID хранимые напрямую в базе.

FAQ

Фабричная функция или класс builder?

Фабричные функции проще и подходят для большинства случаев. Используй builder когда есть несколько значимых пресетов (например asAdmin(), asUnverified(), asSuspended()) которые объединяют несколько значений полей. Если замечаешь что передаёшь один и тот же объект переопределений повторно, это сигнал что именованный метод builder был бы чище.

API требует аутентификации для создания тестовых данных, что делать?

Храни seed-токен в переменной окружения и загружай через process.env. В CI передавай переменную через секреты пайплайна (GitHub Actions: secrets.API_SEED_TOKEN). Никогда не хардкоди учётные данные в исходных файлах.

Можно ли использовать несколько фикстур данных в одном тесте?

Да. Запрашивай столько фикстур сколько нужно тесту: async ({ testUser, testAdminUser, productCategories }). Playwright создаёт все до запуска теста и удаляет все после в обратном порядке создания.

Как обрабатывать очистку если тест оставил приложение в сломанном состоянии?

Teardown фикстуры запускается вне зависимости от результата теста. Паттерн try/catch в teardown-блоке гарантирует что если удаление упадёт (возможно тест сам удалил ресурс как часть проверяемого флоу), ошибка логируется но не создаёт ложное падение. Если ресурс был намеренно удалён тестом, проверь перед удалением: if (response.status() !== 404) await deleteViaApi(id).

Faker.js безопасно держать только как devDependency?

Да. Устанавливай с --save-dev и импортируй только в тестовых файлах и фабриках/хелперах. В продакшн не попадает. Если Faker случайно окажется в продакшн-коде, границы модулей TypeScript и tree-shaking это поймают, или можно принудить правилом ESLint.

→ See also: Управление тестовыми данными в Playwright: стратегии и паттерны | Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми | Изоляция тестов: почему каждый тест Playwright должен быть stateless