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=chromiumwebServer запускает приложение перед тестами и ждёт доступности указанного 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 повторно находит элемент в момент действия и поддерживает автоматическое ожидание.
Рекомендуемый приоритет:
getByRole()— роль и доступное имя;getByLabel()— подпись поля;getByPlaceholder()— placeholder;getByText()— видимый текст;getByAltText()— альтернативный текст;getByTestId()— тестовый идентификатор;- 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:
- получить исходный код;
- установить Node.js и зависимости по lock-файлу;
- установить браузеры и системные зависимости;
- запустить приложение через
webServer; - выполнить тесты;
- сохранить отчёт, трассировки и видео.
Пример 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: 14if: 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();
});Частые ошибки
waitForTimeout()вместо ожидания конкретного состояния.- Хрупкие CSS-цепочки и XPath вместо семантических локаторов.
- Ожидание ответа, зарегистрированное после действия.
- Зависимость одного теста от результата другого.
- Общие серверные данные при параллельном запуске.
- Проверка внутренних деталей React вместо поведения пользователя.
- Слишком длинные сценарии, объединяющие несвязанные процессы.
- Секреты и
storageStateв репозитории. - Использование retries вместо исправления flaky-теста.
- Несоответствие версий пакета, браузеров и Docker-образа.
Практические рекомендации
- Покрывайте E2E-тестами критические пользовательские потоки.
- Используйте
getByRole()иgetByLabel()как основной способ поиска. - Применяйте web-first assertions.
- Создавайте предусловия через API или фикстуры.
- Делайте данные уникальными для каждого worker.
- Сохраняйте HTML-отчёты, trace, скриншоты и видео падений.
- Проверяйте основной набор в Chromium, а необходимую кросс-браузерность — отдельными проектами.
- Используйте отдельное тестовое окружение и не запускайте разрушающие сценарии против production.
- Держите тесты короткими, независимыми и понятными с точки зрения пользователя.
Краткая памятка
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-reportPlaywright объединяет управление браузерами, автоматические ожидания, сетевые инструменты, параллельный запуск и подробную диагностику. Надёжность E2E-набора зависит прежде всего от изоляции данных, устойчивых локаторов, контролируемого окружения и независимости сценариев.