React_E2E_Playwright

E2E-тестирование с Playwright

Playwright — инструмент для сквозного тестирования веб-приложений в Chromium, Firefox и WebKit. E2E-тест имитирует действия пользователя: открывает страницу, вводит данные, нажимает кнопки и проверяет результат.

import { test, expect } from '@playwright/test';

test('открывается каталог', async ({ page }) => {
  await page.goto('/products');
  await expect(page.getByRole('heading', { name: 'Каталог' }))
    .toBeVisible();
});

E2E-тестами обычно покрывают критические потоки: вход, регистрацию, поиск, оформление заказа и изменение данных. Небольшие функции выгоднее проверять юнит-тестами, а отдельные компоненты — UI-тестами.


Установка Playwright

Создание конфигурации в новом или существующем проекте:

npm init playwright@latest

Ручная установка:

npm install --save-dev @playwright/test
npx playwright install

В Linux-среде CI вместе с системными зависимостями:

npx playwright install --with-deps

Скрипты package.json:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug",
    "test:e2e:report": "playwright show-report"
  }
}

Типичная структура:

project/
├── tests/
│   ├── auth/login.spec.ts
│   ├── products/catalog.spec.ts
│   ├── fixtures/
│   └── pages/
├── playwright.config.ts
└── package.json

Конфигурация

playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: Boolean(process.env.CI),
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: [['line'], ['html', { open: 'never' }]],

  use: {
    baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:4173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],

  webServer: {
    command: 'npm run preview -- --host 127.0.0.1',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

baseURL позволяет использовать относительные адреса:

await page.goto('/login');

projects запускает один набор сценариев в разных браузерах или режимах. Один проект запускается так:

npx playwright test --project=chromium

webServer запускает приложение перед тестами и ждёт доступности указанного URL.


Написание E2E-сценариев

import { test, expect } from '@playwright/test';

test.describe('Авторизация', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('вход с корректными данными', async ({ page }) => {
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Пароль').fill('secret123');
    await page.getByRole('button', { name: 'Войти' }).click();

    await expect(page).toHaveURL(/\/dashboard$/);
    await expect(page.getByRole('heading', { name: 'Панель' }))
      .toBeVisible();
  });

  test('ошибка при неверном пароле', async ({ page }) => {
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Пароль').fill('wrong');
    await page.getByRole('button', { name: 'Войти' }).click();

    await expect(page.getByRole('alert'))
      .toHaveText('Неверный email или пароль');
  });
});

Доступны хуки beforeAll, beforeEach, afterEach и afterAll. Тесты должны быть независимыми: каждый самостоятельно создаёт предусловия и не полагается на порядок выполнения.

Длинный сценарий удобно делить на шаги:

test('оформление заказа', async ({ page }) => {
  await test.step('Добавить товар', async () => {
    await page.goto('/products');
    await page.getByRole('button', { name: 'Добавить' }).first().click();
  });

  await test.step('Оформить заказ', async () => {
    await page.goto('/checkout');
    await page.getByLabel('Адрес').fill('ул. Примерная, 10');
    await page.getByRole('button', { name: 'Оформить' }).click();
  });

  await expect(page.getByRole('heading', { name: 'Заказ оформлен' }))
    .toBeVisible();
});

Основные действия:

await page.getByRole('button', { name: 'Сохранить' }).click();
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Согласие').check();
await page.getByLabel('Страна').selectOption('kz');
await page.getByPlaceholder('Поиск').press('Enter');
await page.getByLabel('Аватар').setInputFiles('tests/data/avatar.png');

Проверки

Playwright использует web-first assertions: проверка автоматически повторяется, пока условие не выполнится или не истечёт тайм-аут.

await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByTestId('cart-count')).toHaveText('2');
await expect(page.getByRole('article')).toHaveCount(3);
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle(/Панель управления/);

Предпочтительно:

await expect(page.getByText('Сохранено')).toBeVisible();

Не рекомендуется:

expect(await page.getByText('Сохранено').isVisible()).toBe(true);

Во втором случае нет автоматического повторения проверки.


Селекторы и локаторы

Locator повторно находит элемент в момент действия и поддерживает автоматическое ожидание.

Рекомендуемый приоритет:

  1. getByRole() — роль и доступное имя;
  2. getByLabel() — подпись поля;
  3. getByPlaceholder() — placeholder;
  4. getByText() — видимый текст;
  5. getByAltText() — альтернативный текст;
  6. getByTestId() — тестовый идентификатор;
  7. CSS/XPath — если семантического способа недостаточно.
await page.getByRole('button', { name: 'Создать проект' }).click();
await page.getByLabel('Название').fill('Интернет-магазин');
await expect(page.getByText('Проект создан')).toBeVisible();

Уточнение области поиска:

const product = page
  .getByRole('article')
  .filter({ hasText: 'Механическая клавиатура' });

await product.getByRole('button', { name: 'Добавить' }).click();

Для диалога:

const dialog = page.getByRole('dialog', { name: 'Новый проект' });
await dialog.getByLabel('Название').fill('Сайт');
await dialog.getByRole('button', { name: 'Создать' }).click();

data-testid полезен, когда у элемента нет устойчивой роли:

<span data-testid="cart-count">3</span>
await expect(page.getByTestId('cart-count')).toHaveText('3');

Хрупкий вариант:

await page.locator('main > div:nth-child(2) > button.primary').click();

Он зависит от структуры DOM и CSS-классов. Индексные методы first(), last() и nth() также следует использовать осторожно. Если найдено несколько кнопок «Удалить», лучше сначала выбрать нужную строку, а не нажимать первую.


Автоматическое ожидание

Перед действием Playwright проверяет, что элемент найден, виден, стабилен, доступен и не перекрыт. Поэтому фиксированные задержки обычно не нужны.

// Плохо
await page.waitForTimeout(2000);

// Хорошо
await expect(page.getByRole('status')).toHaveText('Данные загружены');

Ожидайте наблюдаемый результат: текст, URL, состояние элемента или конкретный сетевой ответ.


Работа с сетевыми запросами

Ожидание ответа

Ожидание регистрируется до действия:

const responsePromise = page.waitForResponse((response) =>
  response.url().endsWith('/api/orders') &&
  response.request().method() === 'POST' &&
  response.status() === 201,
);

await page.getByRole('button', { name: 'Оформить заказ' }).click();
const response = await responsePromise;
const order = await response.json();

expect(order.id).toBeTruthy();

Если вызвать waitForResponse() после клика, быстрый ответ может быть пропущен.

Проверка запроса

const requestPromise = page.waitForRequest((request) =>
  request.url().endsWith('/api/profile') &&
  request.method() === 'PATCH',
);

await page.getByRole('button', { name: 'Сохранить' }).click();
const request = await requestPromise;

expect(request.postDataJSON()).toEqual({ displayName: 'Анна' });

Подмена ответа

Маршрут нужно зарегистрировать до page.goto():

await page.route('**/api/products', async (route) => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Клавиатура' },
      { id: 2, name: 'Мышь' },
    ]),
  });
});

await page.goto('/products');
await expect(page.getByRole('article')).toHaveCount(2);

Ошибка сервера:

await page.route('**/api/products', async (route) => {
  await route.fulfill({
    status: 500,
    contentType: 'application/json',
    body: JSON.stringify({ message: 'Internal Server Error' }),
  });
});

await page.goto('/products');
await expect(page.getByRole('alert'))
  .toHaveText('Не удалось загрузить товары');

Изменение реального ответа:

await page.route('**/api/profile', async (route) => {
  const response = await route.fetch();
  const data = await response.json();
  await route.fulfill({ response, json: { ...data, plan: 'premium' } });
});

Продолжение запроса с дополнительным заголовком:

await page.route('**/api/**', async (route) => {
  await route.continue({
    headers: {
      ...route.request().headers(),
      'x-e2e-test': 'true',
    },
  });
});

Фикстура request позволяет создать тестовые данные напрямую через API:

test('редактирование проекта', async ({ page, request }) => {
  const response = await request.post('/api/projects', {
    data: { name: 'E2E project' },
  });

  expect(response.ok()).toBeTruthy();
  const project = await response.json();

  await page.goto(`/projects/${project.id}`);
  await expect(page.getByRole('heading')).toHaveText('E2E project');
});

Критические интеграционные потоки полезно проверять с реальным тестовым backend. Моки удобны для детерминированной проверки ошибок, пустых ответов и редких состояний.


Аутентификация через storageState

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

import { test as setup, expect } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.E2E_USER_EMAIL!);
  await page.getByLabel('Пароль').fill(process.env.E2E_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Войти' }).click();
  await expect(page).toHaveURL(/\/dashboard$/);
  await page.context().storageState({ path: authFile });
});

Подключение в проекте:

projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'playwright/.auth/user.json',
    },
    dependencies: ['setup'],
  },
]

Файл может содержать cookie и токены, поэтому его нельзя коммитить:

playwright/.auth/

Параллельный запуск

Тестовые файлы запускаются в worker-процессах. Полная параллельность включается так:

export default defineConfig({ fullyParallel: true });

Для группы:

test.describe.configure({ mode: 'parallel' });

Последовательный режим:

test.describe.configure({ mode: 'serial' });

serial нужен редко. Зависимые тесты лучше сделать независимыми или объединить в один сценарий.

Управление workers:

npx playwright test --workers=4
npx playwright test --workers=1

Каждый тест получает отдельный BrowserContext, поэтому cookie и localStorage изолированы. Серверные данные автоматически не изолируются: параллельные тесты не должны редактировать одну запись или использовать один уникальный email.

Разделение набора между CI-машинами:

npx playwright test --shard=1/3
npx playwright test --shard=2/3
npx playwright test --shard=3/3

Повторы в CI:

retries: process.env.CI ? 2 : 0,

Retries помогают собрать диагностику, но не должны скрывать нестабильные тесты.


Отчёты и отладка

Несколько репортёров:

reporter: [
  ['line'],
  ['html', { open: 'never' }],
  ['junit', { outputFile: 'test-results/junit.xml' }],
],

Открытие HTML-отчёта:

npx playwright show-report

Диагностические артефакты:

use: {
  trace: 'on-first-retry',
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
}

Trace Viewer показывает шаги, DOM-снимки, сеть, консоль и исходный код теста:

npx playwright show-trace test-results/example/trace.zip

Команды отладки:

npx playwright test --ui
npx playwright test --debug
npx playwright test --headed
npx playwright test tests/auth/login.spec.ts
npx playwright test -g "оформление заказа"
npx playwright codegen http://localhost:5173

Временная остановка:

await page.pause();

page.pause() и test.only() нельзя оставлять в CI. Параметр forbidOnly: Boolean(process.env.CI) защищает от случайного запуска только одного теста.


CI-интеграция

Типичный pipeline:

  1. получить исходный код;
  2. установить Node.js и зависимости по lock-файлу;
  3. установить браузеры и системные зависимости;
  4. запустить приложение через webServer;
  5. выполнить тесты;
  6. сохранить отчёт, трассировки и видео.

Пример GitHub Actions:

name: E2E tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install browsers
        run: npx playwright install --with-deps

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          CI: true
          E2E_USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
          E2E_USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}

      - name: Upload HTML report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

      - name: Upload test results
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: test-results/
          retention-days: 14

if: always() сохраняет отчёт даже после падения тестов. Секреты должны храниться в CI secrets, а не в репозитории. Версию Node.js следует согласовать с проектом.

Для нескольких серверов:

webServer: [
  {
    command: 'npm run start:api',
    url: 'http://127.0.0.1:3000/health',
  },
  {
    command: 'npm run start:web',
    url: 'http://127.0.0.1:4173',
  },
]

Версия Docker-образа Playwright должна соответствовать версии @playwright/test, иначе библиотека и браузерные бинарные файлы могут быть несовместимы.


Комплексный пример

import { test, expect } from '@playwright/test';

test('пользователь оформляет заказ', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 'keyboard-1', name: 'Клавиатура', price: 7000 },
      ]),
    });
  });

  await page.route('**/api/orders', async (route) => {
    expect(route.request().method()).toBe('POST');
    expect(route.request().postDataJSON()).toMatchObject({
      customer: { email: 'anna@example.com' },
      items: [{ productId: 'keyboard-1', quantity: 1 }],
    });

    await route.fulfill({
      status: 201,
      contentType: 'application/json',
      body: JSON.stringify({ id: 'order-101' }),
    });
  });

  await page.goto('/products');

  const product = page
    .getByRole('article')
    .filter({ hasText: 'Клавиатура' });

  await product.getByRole('button', { name: 'Добавить' }).click();
  await expect(page.getByTestId('cart-count')).toHaveText('1');

  await page.getByRole('link', { name: 'Корзина' }).click();
  await page.getByRole('button', { name: 'Перейти к оформлению' }).click();
  await page.getByLabel('Email').fill('anna@example.com');

  const responsePromise = page.waitForResponse((response) =>
    response.url().endsWith('/api/orders') &&
    response.request().method() === 'POST',
  );

  await page.getByRole('button', { name: 'Оформить заказ' }).click();
  const response = await responsePromise;

  expect(response.status()).toBe(201);
  await expect(page).toHaveURL(/\/orders\/order-101$/);
  await expect(page.getByRole('heading', { name: 'Заказ оформлен' }))
    .toBeVisible();
});

Частые ошибки


Практические рекомендации


Краткая памятка

npm init playwright@latest
npx playwright install --with-deps
npx playwright test
npx playwright test --project=chromium
npx playwright test --headed
npx playwright test --ui
npx playwright test --debug
npx playwright test --workers=1
npx playwright show-report

Playwright объединяет управление браузерами, автоматические ожидания, сетевые инструменты, параллельный запуск и подробную диагностику. Надёжность E2E-набора зависит прежде всего от изоляции данных, устойчивых локаторов, контролируемого окружения и независимости сценариев.