Привет, Хабр!
Flaky‑тесты — бич E2E‑автоматизации. Команда перезапускает пайплайн, пока не позеленеет. Доверие к тестам падает. В итоге CI‑статус игнорируется, и баг всё равно попадает в прод.
Playwright — фреймворк от Microsoft для E2E‑тестирования — был построен с нуля, чтобы решить именно эту проблемную. В нем есть автоматические ожидания, изоляция через Browser Contexts и встроенный тест‑раннер. Разберем, чем он отличается от Selenium и Cypress, и как писать тесты, которые не падают от ветра.
Почему тесты флакуют: корень проблемы
90% flaky‑тестов — это гонки между тестом и браузером. Тест говорит «кликни кнопку», а кнопка ещё не появилась в DOM. Или появилась, но ещё не стала кликабельной (перекрыта оверлеем). Или кликабельна, но обработчик click ещё не привязан — React не завершил гидрацию.
Тот же Selenium решает это через явные ожидания (WebDriverWait, Expected Conditions). Разработчик вручную пишет: «подожди, пока элемент станет видимым, максимум 10 секунд». Забыл написать — тест флакует. Написал слишком маленький таймаут — флакует на медленном CI. Написал слишком большой — тесты могут работать вечность.
Playwright подходит иначе: все действия автоматически ждут, пока элемент будет готов. page.click('#submit') внутри себя делает:
подождать появления элемента в DOM,
подождать видимости, подождать стабильности (элемент перестал двигаться),
подождать кликабельности (не перекрыт другим элементом),
подождать привязки обработчика, кликнуть.
Весь этот цикл — автоматический, без единой строки ожидания в тестовом коде.
Это фундаментальное архитектурное решение, которое устраняет главную причину нестабильности E2E‑тестов.
Установка и первый тест
npm init playwright@latest
Команда создаёт проект с конфигурацией, примером теста и настроенным playwright.config.ts. Браузеры (Chromium, Firefox, WebKit) скачиваются автоматически — не нужно ставить отдельно Chrome или geckodriver.
Первый тест (tests/login.spec.js):
const { test, expect } = require('@playwright/test'); test('пользователь может войти в систему', async ({ page }) => { await page.goto('https://app.example.com/login'); await page.fill('#email', 'user@example.com'); await page.fill('#password', 'secret123'); await page.click('button[type="submit"]'); // Проверяем, что попали на дашборд await expect(page).toHaveURL(/dashboard/); await expect(page.locator('h1')).toHaveText('Добро пожаловать'); });
page.fill('#email', ...) — автоматически ждёт появления инпута, очищает его и вводит текст. page.click('button...') — ждёт кликабельности. expect(page).toHaveURL(...) — ждёт, пока URL изменится. Ни одного sleep, waitFor или setTimeout.
Запуск:
npx playwright test # Все тесты npx playwright test login.spec # Конкретный файл npx playwright test --headed # С видимым браузером (для отладки) npx playwright test --ui # Интерактивный UI-режим
Локаторы: как находить элементы надёжно
Главная причина хрупких тестов после гонок — хрупкие селекторы. div.container > div:nth‑child(3) > button.primary сломается при любом изменении вёрстки. Playwright предлагает user‑facing локаторы — поиск элементов так, как их находит пользователь:
// По тексту кнопки (пользователь видит текст, а не CSS-класс) page.getByRole('button', { name: 'Отправить' }); // По label инпута page.getByLabel('Email'); // По placeholder page.getByPlaceholder('Введите имя'); // По тексту ссылки page.getByRole('link', { name: 'Личный кабинет' }); // По test-id (для элементов без видимого текста) page.getByTestId('cart-counter');
getByRole — самый надёжный. Он ищет по ARIA‑роли и accessible name, то есть по тому, как элемент воспринимается пользователем (и скринридером). Вёрстка может меняться, CSS‑классы переименовываться, а getByRole('button', { name: 'Отправить' }) продолжит работать, пока кнопка называется «Отправить».
getByTestId — для кейсов, где текстовый локатор невозможен (иконки, динамический контент). Атрибут data-testid добавляется специально для тестов и не меняется при рефакторинге.
test('добавление товара в корзину', async ({ page }) => { await page.goto('/catalog'); // Находим карточку товара по названию const productCard = page.locator('.product-card') .filter({ hasText: 'Ноутбук Pro 16' }); // Кликаем кнопку внутри карточки await productCard.getByRole('button', { name: 'В корзину' }).click(); // Проверяем счётчик await expect(page.getByTestId('cart-counter')).toHaveText('1'); });
locator.filter({ hasText: ... }) — фильтрация по содержимому. Позволяет найти конкретную карточку среди десятков одинаковых по структуре.
Автоматические ожидания: как это работает
Каждое действие Playwright проходит серию проверок перед выполнением. Для click:
Элемент присутствует в DOM (attached).
Элемент видим (visible) — не
display: none, неvisibility: hidden, не нулевой размер.Элемент стабилен — не анимируется, не двигается (два последовательных кадра в одной позиции).
Элемент принимает события — не перекрыт другим элементом (overlay, modal, tooltip).
Элемент не disabled.
Если любое условие не выполнено, Playwright ждёт. По умолчанию до 30 секунд (настраивается). Если за это время условие не выполнилось — тест падает с понятным сообщением: «Element is not visible» или «Element is covered by another element».
Для expect тоже работают автоматические ожидания:
// Ждёт, пока текст элемента станет 'Готово' (а не проверяет мгновенно) await expect(page.locator('#status')).toHaveText('Готово'); // Ждёт, пока элемент исчезнет await expect(page.locator('.spinner')).not.toBeVisible(); // Ждёт, пока количество элементов станет 3 await expect(page.locator('.item')).toHaveCount(3);
Каждый expect с await — это polling assertion. Playwright проверяет условие, если не выполнено — ждёт, проверяет снова. До таймаута. Это заменяет паттерн waitUntil + assert, который в Selenium пишется вручную.
Browser Contexts: изоляция без overhead
Каждый тест в Playwright запускается в своём Browser Context. Это как отдельный профиль браузера: свои cookies, свой localStorage, свои сессии. Но без запуска нового процесса браузера — контексты легковесные, создаются за миллисекунды.
test('первый пользователь видит свои данные', async ({ page }) => { // page уже в свежем контексте — нет cookies от других тестов await page.goto('/profile'); }); test('второй пользователь видит свои данные', async ({ page }) => { // другой контекст — полностью изолирован от первого теста await page.goto('/profile'); });
В Selenium изоляция — больная тема. Общий профиль браузера, cookies протекают между тестами, localStorage не чистится. Отсюда классические flaky: тест проходит поодиночке, но падает в пачке, потому что предыдущий тест оставил авторизационную cookie.
В Playwright это невозможно по конструкции. Каждый тест — чистый лист.
Параллелизм из коробки
npx playwright test --workers=4
Playwright запускает тесты параллельно. Каждый worker — отдельный процесс. Тесты изолированы через Browser Contexts, поэтому параллелизм безопасен без дополнительных усилий. На CI с 4 ядрами — ускорение в ~3.5 раза.
Для тестов, которые не должны параллелиться (общая база данных, общий стейт), есть режим serial:
test.describe.serial('checkout flow', () => { test('добавить товар', async ({ page }) => { /* ... */ }); test('оформить заказ', async ({ page }) => { /* ... */ }); test('проверить подтверждение', async ({ page }) => { /* ... */ }); });
Network interception: моки и перехват
Playwright может перехватывать и подменять сетевые запросы. Это устраняет зависимость тестов от внешних сервисов:
test('показывает ошибку при недоступном API', async ({ page }) => { // Перехватываем запрос к API и возвращаем ошибку await page.route('**/api/orders', route => route.fulfill({ status: 500, body: 'Internal Server Error' }) ); await page.goto('/orders'); await expect(page.locator('.error-message')) .toHaveText('Не удалось загрузить заказы'); }); test('показывает список заказов', async ({ page }) => { // Подменяем данные await page.route('**/api/orders', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, title: 'Заказ #1', amount: 15000 }, { id: 2, title: 'Заказ #2', amount: 23000 } ]) }) ); await page.goto('/orders'); await expect(page.locator('.order-card')).toHaveCount(2); });
Тесты детерминированы: не зависят от состояния бэкенда, не флакуют из‑за медленного API, не требуют seed‑данных в базе.
Trace и отладка: когда тест всё‑таки упал
// playwright.config.js module.exports = { use: { trace: 'on-first-retry', // Записывать trace при повторе упавшего теста screenshot: 'only-on-failure', video: 'retain-on-failure', }, retries: 1, // Один повтор для упавших тестов };
Trace — запись всех действий теста: скриншоты, DOM‑снапшоты, сетевые запросы, логи консоли, таймлайн. Открывается в Trace Viewer:
npx playwright show-trace trace.zip
Trace Viewer показывает пошаговый реплей теста: что было на экране на каждом шаге, какие запросы отправлялись, какой DOM был в момент клика.
screenshot: 'only-on-failure' — скриншот при падении теста. Прикладывается к отчёту CI. video: 'retain-on-failure' — видеозапись всего теста при падении.
Codegen: генерация тестов записью
npx playwright codegen https://app.example.com
Открывается браузер и инспектор. Вы кликаете, заполняете формы, навигируете. Playwright записывает действия и генерирует тестовый код. Результат не идеальный, но рабочий скелет теста, который останется отредактировать: заменить CSS‑селекторы на getByRole, добавить assertions, убрать лишние шаги.
Page Object Model: организация кода
Для проекта с 50+ тестами — Page Object обязателен:
// pages/login-page.js class LoginPage { constructor(page) { this.page = page; this.emailInput = page.getByLabel('Email'); this.passwordInput = page.getByLabel('Пароль'); this.submitButton = page.getByRole('button', { name: 'Войти' }); this.errorMessage = page.locator('.login-error'); } async goto() { await this.page.goto('/login'); } async login(email, password) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); } } module.exports = { LoginPage };
// tests/login.spec.js const { test, expect } = require('@playwright/test'); const { LoginPage } = require('../pages/login-page'); test('успешный логин', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'secret123'); await expect(page).toHaveURL(/dashboard/); }); test('ошибка при неверном пароле', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'wrong'); await expect(loginPage.errorMessage).toBeVisible(); await expect(loginPage.errorMessage).toHaveText('Неверный пароль'); });
Локаторы описаны в одном месте. Изменилась вёрстка страницы логина — меняете один файл, а не двадцать тестов.
CI/CD: GitHub Actions за 5 минут
# .github/workflows/e2e.yml name: E2E Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx playwright install --with-deps - run: npx playwright test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: playwright-report/
playwright install --with-deps — ставит браузеры и системные зависимости (шрифты, медиа‑кодеки). Артефакт playwright-report — HTML‑отчёт со скриншотами и trace‑файлами. Всё, что нужно для разбора падений, доступно прямо в CI.
Playwright не исключает flaky‑тесты полностью. Если тест зависит от внешнего API без мока, от стейта базы данных или от системного времени — он будет нестабильным в любом фреймворке. Но Playwright убирает целый класс проблем, которые в Selenium и Cypress создают ту самую базовую нестабильность: гонки с DOM, протечки между тестами, ненадёжные ожидания. И это делает E2E‑тесты тем, чем они должны были быть всегда — защитной сетью, а не источником ложных срабатываний.

Поделиться практическими подходами и показать, как уже сегодня применять искусственный интеллект в тестировании, мы планируем на открытом уроке «Искусственный интеллект для тестировщика: инструменты, которые уже меняют профессию», который пройдёт 16 апреля в 20:00 в рамках курса «Автоматизатор тестирования на JavaScript». Мы разберём реальные сценарии использования и посмотрим, как это влияет на повседневную работу тестировщика. Регистрируйтесь на странице курса.
Сейчас действует скидка 15% за прохождение тестирования — это хороший момент, чтобы оценить свой уровень в E2E‑тестировании на JavaScript и работе с Playwright и заодно получить более выгодные условия. ☛
[Пройти тест]
