Когда каждый тест напрямую импортирует page-объекты и самостоятельно организует вход в систему, одно изменение в фикстуре требует правки десятков файлов. Решение: индексный файл фикстур. Один файл объединяет все определения фикстур, тесты импортируют только из ../../fixtures и больше ниоткуда. Эта статья разбирает структуру папок которая это обеспечивает, класс BasePage для общей навигации, паттерн mergeTests для композиции файлов фикстур, конфигурацию окружения с обёрткой requireEnv() которая падает при старте если переменные отсутствуют, и паттерн strangler fig для постепенного внедрения структуры в существующий сьют без остановки на полный рерайт.

Структура папок которая реально масштабируется

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

my-app-tests/
  tests/
    auth/
      login.spec.ts
      logout.spec.ts
    items/
      items-crud.spec.ts
      items-search.spec.ts
  pages/
    BasePage.ts
    LoginPage.ts
    DashboardPage.ts
  fixtures/
    index.ts
    auth.fixture.ts
    pages.fixture.ts
    data.fixture.ts
  data/
    factories/
      userFactory.ts
      itemFactory.ts
    seeds/
      seedItems.ts
  helpers/
    waitHelpers.ts
    apiHelpers.ts
  utils/
    envConfig.ts
    logger.ts
  playwright.config.ts
  tsconfig.json
  .eslintrc.json
  .env.example

Ключевые правила которые это делают рабочим: tests/ содержит только spec-файлы без общей логики. pages/ содержит только классы page-объектов. fixtures/ связывает всё вместе. data/ отвечает за создание и сидирование тестовых данных. helpers/ хранит переиспользуемые функции не привязанные к конкретной странице. utils/ хранит инфраструктуру: конфиг, логирование, всё на уровне фреймворка.

Тесты импортируют только из fixtures/index.ts. Это единственное ограничение которое держит граф зависимостей чистым.

Базовый класс страницы

Каждый page-объект в растущем сьюте нуждается в одном и том же наборе возможностей: навигация, ожидание готовности страницы, стандартная обработка общих паттернов UI. Без базового класса эти паттерны копируются по страницам и со временем расходятся.

// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { envConfig } from '../utils/envConfig';

export abstract class BasePage {
  protected readonly page: Page;
  abstract readonly path: string;

  constructor(page: Page) {
    this.page = page;
  }

  async navigate(params?: Record<string, string>): Promise<void> {
    const url = new URL(this.path, envConfig.baseURL);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        url.searchParams.set(key, value);
      });
    }
    await this.page.goto(url.toString());
    await this.waitForPageLoad();
  }

  protected async waitForPageLoad(): Promise<void> {
    await this.page.waitForLoadState('domcontentloaded');
  }

  async waitForVisible(locator: Locator, timeout = 10_000): Promise<void> {
    await locator.waitFor({ state: 'visible', timeout });
  }

  async waitForHidden(locator: Locator, timeout = 10_000): Promise<void> {
    await locator.waitFor({ state: 'hidden', timeout });
  }

  async assertHeading(text: string): Promise<void> {
    await expect(this.page.getByRole('heading', { name: text })).toBeVisible();
  }

  async assertURL(expectedPath: string): Promise<void> {
    await expect(this.page).toHaveURL(new RegExp(expectedPath));
  }

  async dismissModal(): Promise<void> {
    const overlay = this.page.locator('[data-testid="modal-overlay"]');
    if (await overlay.isVisible()) {
      await this.page.keyboard.press('Escape');
      await this.waitForHidden(overlay);
    }
  }
}

Page-объекты расширяют базовый класс и описывают только то что специфично для конкретной страницы:

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

export class DashboardPage extends BasePage {
  readonly path = '/dashboard';
  readonly addItemButton: Locator;
  readonly itemsTable: Locator;
  readonly searchInput: Locator;

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

  protected override async waitForPageLoad(): Promise<void> {
    await super.waitForPageLoad();
    await this.itemsTable.waitFor({ state: 'visible' });
  }

  async getRowCount(): Promise<number> {
    const rows = this.itemsTable.getByRole('row');
    return (await rows.count()) - 1;
  }

  async searchFor(term: string): Promise<void> {
    await this.searchInput.fill(term);
    await this.page.waitForResponse('**/api/items?search=**');
  }
}

Переопределение waitForPageLoad в DashboardPage устраняет флакующие тесты на масштабе. Каждая страница определяет собственное условие готовности, и навигация ждёт выполнения этого условия перед возвратом. Тестам не нужны ручные задержки.

Слой фикстур

Фикстуры в Playwright: система внедрения зависимостей фреймворка. Один файл экспортирует всё что нужно тестам: расширенный объект test, все page-объекты, хелперы данных и expect. Тестовые файлы импортируют ровно из одного места.

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

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto(envConfig.baseURL);
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill(envConfig.testUser.email);
    await page.getByLabel('Password').fill(envConfig.testUser.password);
    await page.getByRole('button', { name: 'Submit' }).click();
    await page.getByRole('heading', { name: 'Dashboard' }).waitFor();

    await use(page);
  },
});

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

type PageFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

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

// fixtures/index.ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './auth.fixture';
import { test as pagesTest } from './pages.fixture';
import { test as dataTest } from './data.fixture';

export const test = mergeTests(authTest, pagesTest, dataTest);
export { expect } from '@playwright/test';

mergeTests собирает фикстуры из нескольких файлов без потери типобезопасности. Каждый тест в проекте теперь использует один импорт:

import { test, expect } from '../../fixtures';

Этот один импорт даёт каждому тесту доступ к authenticatedPage, dashboardPage, loginPage и всем фикстурам данных. Добавить новую фикстуру означает отредактировать один файл в fixtures/ и она сразу доступна везде.

Держи индексный файл фикстур тонким: только вызовы mergeTests и реэкспорты. Как только туда попадает логика фикстуры, становится сложнее найти где конкретная фикстура определена. Один файл фикстур на домен (auth, pages, data) сохраняет навигируемость.

Управление конфигурацией в разных окружениях

Захардкоженные URL: самый быстрый способ сделать сьют неуправляемым. Конфигурация под каждое окружение нуждается в единственном источнике истины который остальная часть фреймворка читает.

// utils/envConfig.ts
import * as dotenv from 'dotenv';
import * as path from 'path';

const envFile = process.env.TEST_ENV
  ? `.env.${process.env.TEST_ENV}`
  : '.env';

dotenv.config({ path: path.resolve(process.cwd(), envFile) });

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}. ` +
      `Did you copy .env.example to ${envFile}?`
    );
  }
  return value;
}

export const envConfig = {
  baseURL: requireEnv('BASE_URL'),
  apiBaseURL: requireEnv('API_BASE_URL'),
  testUser: {
    email: requireEnv('TEST_USER_EMAIL'),
    password: requireEnv('TEST_USER_PASSWORD'),
  },
  apiToken: requireEnv('API_TOKEN'),
  environment: (process.env.TEST_ENV ?? 'local') as 'local' | 'staging' | 'prod',
} as const;

Три .env-файла лежат в корне проекта и коммитятся в репозиторий (секреты идут в переменные CI, не сюда):

# .env.example
BASE_URL=http://localhost:3000
API_BASE_URL=http://localhost:3001/api
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=replace_me
API_TOKEN=replace_me

# .env.staging
BASE_URL=https://staging.myapp.com
API_BASE_URL=https://staging.myapp.com/api
TEST_USER_EMAIL=staging-test@myapp.com
TEST_USER_PASSWORD=
API_TOKEN=

playwright.config.ts читает из envConfig, а не напрямую из process.env:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { envConfig } from './utils/envConfig';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: envConfig.environment !== 'local',
  retries: envConfig.environment === 'local' ? 0 : 2,
  workers: envConfig.environment === 'local' ? undefined : 4,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['./utils/slackReporter.ts'],
  ],
  use: {
    baseURL: envConfig.baseURL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Запустить тесты на стейджинге теперь одна переменная: TEST_ENV=staging npx playwright test.

Стратегия тестовых данных

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

Фабрики генерируют валидные объекты с разумными дефолтами и позволяют тестам переопределять только то что важно для конкретного сценария:

// data/factories/itemFactory.ts
import { faker } from '@faker-js/faker';

export interface ItemData {
  name: string;
  category: 'Documents' | 'Electronics' | 'Clothing' | 'Other';
  quantity: number;
  notes?: string;
}

export function buildItem(overrides: Partial<ItemData> = {}): ItemData {
  return {
    name: faker.commerce.productName(),
    category: 'Documents',
    quantity: faker.number.int({ min: 1, max: 10 }),
    ...overrides,
  };
}

export function buildItems(count: number, overrides: Partial<ItemData> = {}): ItemData[] {
  return Array.from({ length: count }, () => buildItem(overrides));
}

Для сложных объектов со многими зависимостями паттерн builder даёт тестам fluent API:

// data/factories/userFactory.ts
import { faker } from '@faker-js/faker';

export interface UserData {
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  role: 'admin' | 'member' | 'viewer';
}

export class UserBuilder {
  private data: UserData = {
    email: faker.internet.email(),
    password: 'TestPass123!',
    firstName: faker.person.firstName(),
    lastName: faker.person.lastName(),
    role: 'member',
  };

  withRole(role: UserData['role']): this {
    this.data.role = role;
    return this;
  }

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

  asAdmin(): this {
    this.data.role = 'admin';
    return this;
  }

  build(): UserData {
    return { ...this.data };
  }
}

API-сидирование нужно когда создание через UI слишком медленное или создаёт ненадёжное состояние. Фикстура данных связывает всё вместе и берёт на себя очистку:

// fixtures/data.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
import { buildItem, ItemData } from '../data/factories/itemFactory';
import { envConfig } from '../utils/envConfig';

type DataFixtures = {
  apiRequest: APIRequestContext;
  seededItem: ItemData & { id: string };
};

export const test = base.extend<DataFixtures>({
  apiRequest: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: envConfig.apiBaseURL,
      extraHTTPHeaders: {
        Authorization: `Bearer ${envConfig.apiToken}`,
        'Content-Type': 'application/json',
      },
    });
    await use(context);
    await context.dispose();
  },

  seededItem: async ({ apiRequest }, use) => {
    const itemData = buildItem();
    const response = await apiRequest.post('/items', { data: itemData });
    const created = await response.json() as ItemData & { id: string };

    await use(created);

    // Cleanup runs whether test passes or fails
    await apiRequest.delete(`/items/${created.id}`).catch(() => {
      console.warn(`Cleanup failed for item ${created.id} — may need manual removal`);
    });
  },
});

.catch() в teardown написан намеренно. Если очистка падает, результат теста не должен меняться. Логируй предупреждение и двигайся дальше.
Никогда не полагайся на порядок выполнения тестов для настройки общего состояния. По умолчанию тесты выполняются параллельно и порядок не гарантирован. Каждый тест создаёт и удаляет свои данные сам. Единственное исключение: справочные данные только для чтения (таблицы lookup, категории) которые никогда не меняются. Их сидируй один раз за прогон через скрипт глобальной настройки.

Репортёры: HTML и уведомления в Slack

Встроенный HTML-репортёр достаточен для локальной разработки. CI-пайплайнам нужно что-то что доставляет результаты туда куда команда реально смотрит. В большинстве случаев это Slack.

Кастомный репортёр реализует интерфейс Reporter из Playwright:

// utils/slackReporter.ts
import type {
  Reporter,
  FullConfig,
  Suite,
  TestCase,
  TestResult,
  FullResult,
} from '@playwright/test/reporter';
import * as https from 'https';

export default class SlackReporter implements Reporter {
  private passed = 0;
  private failed = 0;
  private skipped = 0;
  private failedTests: string[] = [];
  private startTime = Date.now();

  onBegin(_config: FullConfig, _suite: Suite): void {
    this.startTime = Date.now();
  }

  onTestEnd(test: TestCase, result: TestResult): void {
    if (result.status === 'passed') this.passed++;
    else if (result.status === 'skipped') this.skipped++;
    else {
      this.failed++;
      this.failedTests.push(test.titlePath().join(' > '));
    }
  }

  async onEnd(result: FullResult): Promise<void> {
    const webhookUrl = process.env.SLACK_WEBHOOK_URL;
    if (!webhookUrl) return;

    const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
    const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
    const total = this.passed + this.failed + this.skipped;

    const blocks = [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `${status} *Playwright Tests — ${process.env.TEST_ENV ?? 'local'}*\n${this.passed}/${total} passed in ${duration}s`,
        },
      },
    ];

    if (this.failedTests.length > 0) {
      blocks.push({
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*Failed tests:*\n${this.failedTests.map(t => `• ${t}`).join('\n')}`,
        },
      });
    }

    const payload = JSON.stringify({ blocks });
    await this.postToSlack(webhookUrl, payload);
  }

  private postToSlack(webhookUrl: string, payload: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const url = new URL(webhookUrl);
      const req = https.request(
        { hostname: url.hostname, path: url.pathname, method: 'POST',
          headers: { 'Content-Type': 'application/json' } },
        () => resolve()
      );
      req.on('error', reject);
      req.write(payload);
      req.end();
    });
  }
}

Регистрируй репортёр в playwright.config.ts:

reporter: [
  ['html', { outputFolder: 'playwright-report', open: 'never' }],
  envConfig.environment !== 'local' ? ['./utils/slackReporter.ts'] : ['list'],
],

Slack-репортёр активируется только в не-локальных окружениях. На локальной машине никакого шума.

TypeScript strict mode и линтинг

Тестовый код такой же продакшн-код. Он запускается в CI, влияет на решения о релизе, а баги в тестах сложнее поймать чем в коде приложения: тесты некому тестировать. TypeScript strict mode и ESLint отсекают целые категории проблем до того как они доходят до команды.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "@fixtures": ["./fixtures/index.ts"],
      "@pages/*": ["./pages/*"],
      "@data/*": ["./data/*"],
      "@helpers/*": ["./helpers/*"],
      "@utils/*": ["./utils/*"]
    }
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

Конфигурация paths даёт тестовым файлам чистые импорты:

import { test, expect } from '@fixtures';
import { buildItem } from '@data/factories/itemFactory';

Для ESLint ключевые правила для качества тестов: те что предотвращают типичные ошибки специфичные для Playwright:

// .eslintrc.json
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "playwright"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/strict-type-checked",
    "plugin:playwright/recommended"
  ],
  "parserOptions": {
    "project": "./tsconfig.json"
  },
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/await-thenable": "error",
    "playwright/no-wait-for-timeout": "error",
    "playwright/no-conditional-in-test": "warn",
    "playwright/prefer-web-first-assertions": "error",
    "playwright/no-networkidle": "warn"
  }
}

no-floating-promises важнейшее правило в сьюте на Playwright. Пропущенный await перед вызовом Playwright распространённый источник ложных срабатываний: ассёрт выполняется до завершения действия, тест проходит, а UI оказывается в неожиданном состоянии для следующего шага. TypeScript это не поймает. Правило линтера поймает.

Рост рядом с существующим сьютом: паттерн strangler fig

Паттерн strangler fig описывает постепенную замену старой системы путём выращивания новой вокруг неё: трафик постепенно переходит со старой на новую пока старая не остаётся ни с чем и её не удаляют. Тот же подход работает для тестовых фреймворков.

Запустить «рерайт фреймворка» как параллельную задачу всегда значит провалиться. Новый фреймворк живёт в отдельной ветке, старый сьют продолжает меняться, ветка никогда не мержится. Подход strangler fig позволяет команде писать тесты в старой структуре пока новая постепенно поглощает их.

Практические шаги:

Шаг 1: Создай новую структуру папок рядом с существующими тестами. Ничего не перемещай.

tests/          ← существующая плоская структура, нетронутая
framework/      ← новая структура, начинает пустой
  tests/
  pages/
  fixtures/
  ...
playwright.config.ts  ← обновлён чтобы запускать оба

Обнови playwright.config.ts чтобы включить обе директории тестов:

export default defineConfig({
  projects: [
    {
      name: 'legacy',
      testDir: './tests',
      use: { baseURL: envConfig.baseURL },
    },
    {
      name: 'framework',
      testDir: './framework/tests',
      use: { baseURL: envConfig.baseURL },
    },
  ],
});

Шаг 2: При написании нового теста всегда пиши его в новой структуре. Никогда не добавляй в старую плоскую папку. Это останавливает рост старой структуры. Шаг 3: При изменении существующего теста (исправление или обновление из-за изменившейся фичи) перемести его в новую структуру в том же PR. Тест улучшается и мигрирует одним изменением.

// framework/tests/items/items-search.spec.ts
// Migrated from tests/items-search.spec.ts
// Migration: extracted LoginPage, wired to fixtures, removed hardcoded URL

import { test, expect } from '../../fixtures';

test('search filters items by name', async ({ authenticatedPage, dashboardPage }) => {
  await dashboardPage.navigate();
  await dashboardPage.searchFor('Passport');

  await expect(dashboardPage.itemsTable.getByRole('row')).toHaveCount(2); // header + 1 result
});

Шаг 4: Добавь правило линтера или простую проверку в CI которая падает если директория tests/ получает новые файлы:

// package.json
"scripts": {
  "check:no-new-legacy-tests": "node scripts/checkLegacyTests.js",
  "test": "playwright test",
  "test:framework": "playwright test --project=framework",
  "test:legacy": "playwright test --project=legacy",
  "lint": "eslint . --ext .ts",
  "typecheck": "tsc --noEmit"
}

Скрипт проверки читает git diff и падает если в tests/ появляются новые .spec.ts-файлы. Команда перестаёт добавлять в старую структуру не из-за правила, а потому что новая структура явно лучше. Проверка страхует тех кто ещё не заметил паттерн.

Через несколько месяцев в legacy-директории останутся только старые тесты которых никто не трогал. Тогда выделенный спринт миграции конвертирует остаток и legacy-директория удаляется. Миграция происходила постепенно, команда шипила фичи всё это время, фреймворк в продакшне с первого дня.

FAQ

Сколько страниц должен покрывать один класс?

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

Могут ли фикстуры содержать ассёрты?

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

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

Создай отдельные фикстуры аутентификации, по одной на роль: adminPage, memberPage, viewerPage. Каждая фикстура логинится под разным пользователем и передаёт аутентифицированную страницу тесту. Если ролей становится много, рассмотри паттерн фабрики: authenticatedAs('admin') возвращает нужную фикстуру по параметру.

Сколько воркеров правильно для параллельного выполнения?

Начни с workers: '50%' в playwright.config.ts (половина доступных ядер CPU). Наблюдай за использованием ресурсов CI-раннера в течение нескольких прогонов. Если тесты начинают флакать из-за конкуренции за ресурсы, уменьши воркеры. Если у раннера есть запас, увеличь. Правильное число зависит от характеристик раннера и ресурсоёмкости каждого теста, а не от универсальной формулы.

Когда использовать test.describe, а когда отдельные spec-файлы?

Отдельные spec-файлы для отдельных фич. test.describe для логических групп внутри фичи: счастливый путь и граничные случаи, или операции чтения и операции записи. Практическое правило: если двум группам тестов нужна разная конфигурация test.use() (разные переопределения фикстур), они принадлежат разным describe-блокам или разным файлам. Если настройка одинакова, группировка только вопрос стиля.

→ See also: Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми | Page Object Model в Playwright: от хаоса к поддерживаемым тестам | Параллельное выполнение в Playwright: workers, шарды и шардирование для ускорения | Изоляция тестов: почему каждый тест Playwright должен быть stateless