Привет, Хабр!

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:

  1. Элемент присутствует в DOM (attached).

  2. Элемент видим (visible) — не display: none, не visibility: hidden, не нулевой размер.

  3. Элемент стабилен — не анимируется, не двигается (два последовательных кадра в одной позиции).

  4. Элемент принимает события — не перекрыт другим элементом (overlay, modal, tooltip).

  5. Элемент не 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 и заодно получить более выгодные условия.[Пройти тест]