Пять строк логина скопированы в 40 тестов: при изменении флоу входа придётся обновить 40 файлов. Кастомные фикстуры убирают это через test.extend(): код до await use(value) выполняется как setup, код после как teardown, который выполняется независимо от того прошёл тест или упал. Эта статья разбирает создание фикстуры authenticatedPage, композицию фикстур с автоматическим разрешением зависимостей, worker scope для общей дорогостоящей подготовки и случаи когда вспомогательная функция чище фикстуры.

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

Playwright поставляет фикстуры page, browser, context и request. Они покрывают основы. Но они ничего не знают о твоём приложении: о флоу входа, об авторизованном состоянии, о доменных объектах.

Как только тестов становится больше нескольких штук, одна и та же проблема повторяется снова и снова:

// tests/items.spec.ts
import { test, expect } from '@playwright/test';

test('user can add a travel item', async ({ page }) => {
  // Setup — повторяется в каждом тесте
  await page.goto('https://lab.becomeqa.com');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByLabel('Username').fill('admin@becomeqa.com');
  await page.getByLabel('Password').fill('testpass123');
  await page.getByRole('button', { name: 'Submit' }).click();
  await page.getByText('My Travel Items').waitFor();

  // Сам тест
  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Passport');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Passport' })).toBeVisible();
});

Пять строк логина до начала теста. Умножь на 40 тестов: 200 строк кода которые не добавляют ценности. Измени флоу входа и будешь обновлять все 40 файлов.

Кастомные фикстуры выносят setup из теста в общее определение. Тест получает результат (авторизованную страницу, готовый page object) не зная как он был подготовлен.

Паттерн test.extend()

API: test.extend(). Передаёшь объект: имя ключа становится именем фикстуры, а значением служит асинхронная функция которая получает существующие фикстуры и колбэк use.

Минимальный пример: фикстура которая логинится перед тестом.

// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('https://lab.becomeqa.com');
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill('admin@becomeqa.com');
    await page.getByLabel('Password').fill('testpass123');
    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByText('My Travel Items').waitFor();

    // передаём авторизованную страницу тесту
    await use(page);

    // teardown выполняется после use() — здесь ничего чистить не нужно,
    // страница закроется автоматически
  },
});

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

Теперь тест-файл импортирует test из файла фикстуры, а не напрямую из Playwright:

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

test('user can add a travel item', async ({ authenticatedPage }) => {
  const page = authenticatedPage;

  await page.getByRole('button', { name: 'Add Item' }).click();
  await page.getByLabel('Item name').fill('Passport');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('cell', { name: 'Passport' })).toBeVisible();
});

Тест стартует на дашборде. Логина нет. Намерение видно сразу.

Всегда ре-экспортируй expect из файла фикстуры: export { expect } from '@playwright/test'. Тест-файлам нужна одна строка импорта, и ты не рискуешь случайно использовать Playwright's expect вместо обёрнутой версии.

Фикстуры для page objects

Фикстуры и page objects решают разные задачи, но отлично работают вместе. Page object оборачивает как взаимодействовать со страницей. Фикстура отвечает за когда: создаёт объект и инжектирует его в тест.

Начнём с простых page objects:

// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';

export class DashboardPage {
  readonly page: Page;
  readonly addItemButton: Locator;
  readonly itemsTable: Locator;

  constructor(page: Page) {
    this.page = page;
    this.addItemButton = page.getByRole('button', { name: 'Add Item' });
    this.itemsTable = page.getByRole('table');
  }

  async isLoaded() {
    await this.page.getByText('My Travel Items').waitFor({ state: 'visible' });
  }

  async getRowCount() {
    const rows = this.page.getByRole('row');
    return (await rows.count()) - 1; // вычитаем строку заголовка
  }
}

// pages/AddItemModal.ts
import { Page, Locator } from '@playwright/test';

export class AddItemModal {
  readonly itemNameInput: Locator;
  readonly categorySelect: Locator;
  readonly saveButton: Locator;

  constructor(page: Page) {
    this.itemNameInput = page.getByLabel('Item name');
    this.categorySelect = page.getByLabel('Category');
    this.saveButton = page.getByRole('button', { name: 'Save' });
  }

  async fillAndSave(name: string, category: string) {
    await this.itemNameInput.fill(name);
    await this.categorySelect.selectOption(category);
    await this.saveButton.click();
  }
}

Подключаем их как фикстуры:

// fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';

type PageFixtures = {
  dashboardPage: DashboardPage;
  addItemModal: AddItemModal;
};

export const test = base.extend<PageFixtures>({
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
  addItemModal: async ({ page }, use) => {
    await use(new AddItemModal(page));
  },
});

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

Тесты объявляют ровно те объекты которые нужны:

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

test('dashboard shows existing items', async ({ page, dashboardPage }) => {
  await page.goto('https://lab.becomeqa.com');
  // ... шаги логина
  await dashboardPage.isLoaded();

  const count = await dashboardPage.getRowCount();
  expect(count).toBeGreaterThan(0);
});

Setup и teardown в фикстурах

Колбэк use делит фикстуру на две части. Код до await use(value) выполняется до теста. Код после него выполняется после завершения теста, прошёл он или упал.

Здесь фикстуры раскрываются для всего что требует очистки:

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

type DataFixtures = {
  testItemId: string;
};

export const test = base.extend<DataFixtures>({
  testItemId: async ({}, use) => {
    // Setup: создаём travel item через API до теста
    const apiContext = await request.newContext({
      baseURL: 'https://lab.becomeqa.com/api',
      extraHTTPHeaders: {
        Authorization: 'Bearer test-token-123',
      },
    });

    const response = await apiContext.post('/items', {
      data: { name: 'Fixture Item', category: 'Documents' },
    });
    const { id } = await response.json();

    // передаём ID тесту
    await use(id);

    // Teardown: удаляем item после теста
    await apiContext.delete(`/items/${id}`);
    await apiContext.dispose();
  },
});

Тест не управляет жизненным циклом item'а вообще:

test('user can delete a travel item', async ({ page, testItemId }) => {
  // item уже существует — просто навигируем и удаляем
  await page.goto(`https://lab.becomeqa.com/items`);
  await page.getByTestId(`item-row-${testItemId}`).getByRole('button', { name: 'Delete' }).click();
  await page.getByRole('button', { name: 'Confirm' }).click();

  await expect(page.getByTestId(`item-row-${testItemId}`)).not.toBeVisible();
});

Если тест падает на полпути, teardown всё равно выполняется. API-вызов удаляет item. В базе не накапливаются остатки тест-данных.

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

Scope фикстур: test vs worker

По умолчанию каждая фикстура создаётся заново для каждого теста. Это scope: 'test'. Безопасный дефолт: тесты изолированы, состояние между ними не утекает.

Но аутентификация дорогая. Навигация, клики, заполнение полей, ожидание: от 1 до 3 секунд на тест. Если у тебя 100 тестов и все они логинятся с нуля, это потенциально 3 минуты времени только на вход.

Worker scope запускает фикстуру один раз на воркер-процесс и шарит результат между всеми тестами этого воркера. Правильный подход для аутентификации: сохранить storage state один раз и переиспользовать.

// fixtures/worker-auth.fixture.ts
import { test as base, chromium, BrowserContext } from '@playwright/test';

type WorkerFixtures = {
  workerContext: BrowserContext;
};

export const test = base.extend<{}, WorkerFixtures>({
  workerContext: [
    async ({}, use) => {
      // выполняется один раз на воркер, не на каждый тест
      const browser = await chromium.launch();
      const context = await browser.newContext();
      const page = await context.newPage();

      // логинимся и сохраняем состояние
      await page.goto('https://lab.becomeqa.com');
      await page.getByRole('button', { name: 'Login' }).click();
      await page.getByLabel('Username').fill('admin@becomeqa.com');
      await page.getByLabel('Password').fill('testpass123');
      await page.getByRole('button', { name: 'Submit' }).click();
      await page.getByText('My Travel Items').waitFor();

      await use(context);

      await context.close();
      await browser.close();
    },
    { scope: 'worker' },
  ],
});

Второй аргумент { scope: 'worker' } подключает worker scope. Worker-scoped фикстуры объявляются во втором дженерик-параметре extend(), а не в первом.

Практическое следствие: worker-scoped фикстуры шарят состояние между тестами. Для read-only авторизованного контекста это нормально. Проблема возникает если тесты модифицируют общее состояние: один тест выходит из системы, следующий не находит сессию. Worker scope уместен для дорогостоящего в создании и безопасного для шаринга. Всё остальное держи в test scope.

Композиция фикстур

Фикстуры могут использовать другие фикстуры. Именно здесь паттерн масштабируется на реальные проекты.

Есть auth-фикстура для логина. Есть фикстуры page objects. Нужна фикстура которая выдаёт уже авторизованный дашборд, объединяя оба:

// fixtures/index.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { AddItemModal } from '../pages/AddItemModal';

type AppFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  addItemModal: AddItemModal;
  loggedInDashboard: DashboardPage;
};

export const test = base.extend<AppFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },

  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },

  addItemModal: async ({ page }, use) => {
    await use(new AddItemModal(page));
  },

  // эта фикстура использует loginPage и dashboardPage
  loggedInDashboard: async ({ loginPage, dashboardPage }, use) => {
    await loginPage.goto();
    await loginPage.login('admin@becomeqa.com', 'testpass123');
    await dashboardPage.isLoaded();

    await use(dashboardPage);
  },
});

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

Тестам которым нужен авторизованный дашборд достаточно одного слова:

// tests/items.spec.ts
import { test, expect } from '../fixtures';

test('dashboard shows at least one item', async ({ loggedInDashboard }) => {
  const count = await loggedInDashboard.getRowCount();
  expect(count).toBeGreaterThan(0);
});

test('user can open add item modal', async ({ loggedInDashboard, addItemModal }) => {
  await loggedInDashboard.addItemButton.click();
  await expect(addItemModal.itemNameInput).toBeVisible();
});

Playwright разрешает граф зависимостей фикстур автоматически. Когда тест запрашивает loggedInDashboard, Playwright видит что он зависит от loginPage и dashboardPage, создаёт их первыми, затем выполняет setup loggedInDashboard. Ты не управляешь этим вручную.

// LoginPage для примера с композицией
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly loginButton: Locator;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.loginButton = page.getByRole('button', { name: 'Login' });
    this.usernameInput = page.getByLabel('Username');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Submit' });
  }

  async goto() {
    await this.page.goto('https://lab.becomeqa.com');
  }

  async login(username: string, password: string) {
    await this.loginButton.click();
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Структура папок которая вырастает из такого подхода:

03_enterprise_pom/
  pages/
    LoginPage.ts
    DashboardPage.ts
    AddItemModal.ts
  fixtures/
    index.ts          ← все фикстуры экспортируются из одного места
  tests/
    items/
      items-list.spec.ts
      items-crud.spec.ts
    payments/
      payment-flow.spec.ts
  playwright.config.ts

Каждый тест-файл импортирует из ../fixtures и получает всё нужное без бойлерплейта.

Когда фикстуры не нужны

Фикстуры достаточно мощные, и команды иногда применяют их там где не надо. Фикстура которую использует один тест: это просто inline-setup с лишней церемонией. Прежде чем создавать фикстуру, спроси себя: будут ли её использовать хотя бы три теста, или этот setup действительно усложняет тест если он inline?

Несколько конкретных случаев где фикстуры добавляют трение:

Разовые сценарии. Если тест проверяет поведение после очень специфического необычного состояния (item с повреждённым полем, сессия на грани истечения), inline-setup понятнее. Необычность setup сама по себе документация. Тесты которые проверяют сам setup. Если тест о флоу входа, хочется видеть шаги входа в тесте. Скрывать их за фикстурой loggedInDashboard лишает смысла. Тесты об аутентификации должны использовать сырую фикстуру page и явно устанавливать состояние. Setup который существенно отличается между тестами. Если каждому тесту нужны немного другие начальные данные, фикстура которая пытается удовлетворить все вариации обрастёт списком параметров сложнее чем inline-setup. Функция-фабрика (обычная TypeScript-функция которую тест вызывает) часто чище параметризованной фикстуры.

// вместо сложной параметризованной фикстуры — функция-фабрика
// helpers/createItem.ts
import { APIRequestContext } from '@playwright/test';

export async function createItem(
  request: APIRequestContext,
  overrides: Partial<{ name: string; category: string }> = {}
) {
  const response = await request.post('https://lab.becomeqa.com/api/items', {
    data: {
      name: 'Default Item',
      category: 'Documents',
      ...overrides,
    },
  });
  return response.json();
}

// tests/items-edge-cases.spec.ts
import { test, expect } from '@playwright/test';
import { createItem } from '../helpers/createItem';

test('item with very long name is truncated in table', async ({ page, request }) => {
  const item = await createItem(request, { name: 'A'.repeat(256) });

  await page.goto('https://lab.becomeqa.com/items');
  // ... продолжение теста
});

Разграничение стоит обозначить чётко: фикстуры для инфраструктуры (авторизованное состояние, общий контекст, page objects). Бизнес-логика и подготовка тест-специфичных данных часто лучше живёт во вспомогательных функциях.

storageState в Playwright: практичная альтернатива login-фикстуре для некоторых проектов. Запускаешь auth.setup.ts один раз: он сохраняет залогиненное состояние браузера в JSON-файл, все тесты загружают его через playwright.config.ts. Быстрее чем фикстурный логин в каждом тесте, но требует setup-проекта в конфиге. Оба подхода рабочие. Выбирай тот что лучше вписывается в CI-пайплайн.

FAQ

Можно переопределить фикстуру для конкретного теста?

Да. Используй test.extend() снова чтобы создать более специфичную версию, или test.use() внутри блока describe для переопределения в этой группе. test.use() принимает объект со значениями фикстур и применяет их ко всем тестам в текущей области.

Все фикстуры в одном файле или по разным?

Разбивай по домену когда файл вырастает. В проекте могут быть auth.fixture.ts, data.fixture.ts и pages.fixture.ts, с ре-экспортом всего из index.ts. Тест-файлы импортируют из index и не знают в каком файле живёт конкретная фикстура.

Фикстуры работают с test.describe?

Да. Фикстуры доступны внутри любого describe-блока. Можно также использовать test.describe.configure({ mode: 'parallel' }) внутри describe. Фикстуры автоматически уважают настройку параллелизма.

Что происходит если setup фикстуры бросает исключение?

Playwright помечает тест как провальный и пытается выполнить teardown в фикстурах которые завершили фазу setup. Фикстуры которые не дошли до await use() свой teardown не выполняют.

Можно использовать фикстуры в beforeAll или beforeEach?

Напрямую нет: beforeAll и beforeEach не получают фикстуры как аргументы. Если нужен общий setup с использованием фикстур, конвертируй beforeEach в фикстуру с собственным scope. Это одна из чистых мотиваций для фикстур: они делают beforeAll/beforeEach практически ненужными.

→ See also: Фикстуры Playwright: от встроенных до кастомных | Page Object Model в Playwright: от хаоса к поддерживаемым тестам | Авторизация в Playwright через storageState (без логина в каждом тесте) | Переиспользуемые тестовые данные: фабрики, фикстуры и Faker.js в Playwright | Файл конфигурации Playwright: все опции, которые нужно знать