Learnly

Как Playwright работает со страницами

Архитектура Playwright: Browser, Context, Page, Locators, Auto-wait

В этом уроке, как Playwright устроен внутри: модель Browser → Context → Page, как работает auto-wait, какие locator-стратегии правильные, и как перехватывать сеть. С реальным TypeScript-кодом.

Иерархия объектов

Playwright (singleton, per process)
   │
   ├── chromium / firefox / webkit  (BrowserType)
   │     │
   │     └── Browser  (один процесс реального браузера)
   │           │
   │           ├── BrowserContext #1  (incognito-like, своя cookie-jar/storage/perms)
   │           │     ├── Page #1      (одна вкладка)
   │           │     │     ├── Frame  (iframes)
   │           │     │     └── Worker (web workers, service workers)
   │           │     └── Page #2
   │           │
   │           └── BrowserContext #2  (полностью изолирован от #1)
   │                 └── Page #1

Browser

Один процесс реального браузера (Chromium / Firefox / WebKit). Тяжёлый. Запускается один раз и переиспользуется через все тесты файла.

import { chromium } from '@playwright/test';
const browser = await chromium.launch({ headless: true });

BrowserContext

Главная единица изоляции. Лёгкий: создаётся за миллисекунды. Внутри, своя cookie-jar, storage, permissions, geolocation, viewport, timezone, locale. Не делит ничего с другими контекстами того же Browser.

const context = await browser.newContext({
  viewport: { width: 1280, height: 720 },
  locale: 'ru-RU',
  timezoneId: 'Europe/Moscow',
  geolocation: { latitude: 55.75, longitude: 37.61 },
  permissions: ['geolocation', 'notifications'],
  userAgent: 'MyTestBot/1.0',
  storageState: 'auth.json',           // подгрузить сохранённую сессию
  recordVideo: { dir: 'videos/' },
  recordHar:   { path: 'network.har' },
});

В @playwright/test-раннере каждый тест получает свой context автоматически, изоляция включена по умолчанию.

Page

Одна вкладка / окно. На неё навешиваются все действия и ассерты.

const page = await context.newPage();
await page.goto('https://example.com');

Frame

iframes доступны как объекты типа Frame. Поиск через page.frameLocator('iframe[name=payment]').getByRole(...).

Auto-wait, что и до каких пор Playwright ждёт

Любое action (click, fill, hover, press, dragTo) автоматически ждёт, пока target-элемент станет actionable:

  1. Attached, присутствует в DOM.
  2. Visible, display ≠ none, visibility ≠ hidden, размеры > 0.
  3. Stable, не меняет позицию/размер 2 кадра подряд (анимация закончилась).
  4. Enabled, нет атрибута disabled.
  5. Receives events, нет других элементов сверху, которые перехватят клик (overlay/modal).

Любое expect-ассерт с локатором (toBeVisible, toHaveText, toContainText, toHaveAttribute и др.) автоматически повторяется до timeout (default 5s), пока условие не станет истинным.

await page.click('button.submit');                 // ждёт всех 5 условий
await expect(page.locator('.toast')).toBeVisible(); // ретраит ассерт до 5s

Это убирает 90% flakiness старых Selenium-тестов.

Default timeouts (можно поменять в config):

  • Action timeout: 30s.
  • Expect timeout: 5s.
  • Navigation timeout: 30s.

Locators, главная абстракция

Locator, это lazy ссылка на элемент(ы), которая каждый раз заново ищет элемент в DOM. Это значит, что один и тот же locator можно применять снова и снова, он не «протухнет» при перерендере.

const button = page.getByRole('button', { name: 'Submit' });
await button.scrollIntoViewIfNeeded();
await button.click();                              // ищет элемент в этот момент
await expect(button).toBeDisabled();               // снова ищет

Иерархия предпочтений (рекомендация Playwright team)

Приоритет Метод Что ищет
1. getByRole(role, { name }) ARIA-роль (button, link, dialog, listbox, ...), самый стабильный
2. getByLabel(text) label у формы
3. getByPlaceholder(text) placeholder у input
4. getByText(text) видимый текст
5. getByAltText(text) alt у img
6. getByTitle(text) title-атрибут
7. getByTestId(id) data-testid (custom), для случаев без accessible name
последний page.locator('css или xpath') CSS-селектор / XPath
// Хорошо
page.getByRole('button', { name: 'Войти' })
page.getByLabel('Email')
page.getByText('Корзина пуста')
page.getByTestId('checkout-button')

// Плохо (хрупко)
page.locator('.btn-primary.large')
page.locator('//div[3]/button[2]')
page.locator('#__next > div > div.flex > div:nth-child(3) > button')

Цепочка локаторов

Локаторы можно комбинировать через .locator(), .filter(), .first(), .nth():

const card = page.getByRole('listitem').filter({ hasText: 'iPhone 15' });
await card.getByRole('button', { name: 'Add to cart' }).click();

// Вторая строка таблицы
await page.getByRole('row').nth(1).click();

// Все кнопки удаления
const removeBtns = page.getByRole('button', { name: 'Remove' });
await expect(removeBtns).toHaveCount(3);

getByTestId, рекомендация для собственного кода

Добавляйте data-testid в критические интерактивные элементы. Это формальный контракт между фронтом и тестами, рефакторинг CSS не сломает тесты.

<button data-testid="checkout-submit">Оформить заказ</button>
await page.getByTestId('checkout-submit').click();

Поменять имя атрибута глобально:

// playwright.config.ts
use: { testIdAttribute: 'data-qa' }

Web-first assertions

expect() с локатором умеет retry-полнятся до timeout:

await expect(page.getByRole('alert')).toHaveText('Saved');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('My App');
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeChecked();
await expect(locator).toHaveCount(5);
await expect(locator).toContainText('Total: $100');
await expect(locator).toHaveAttribute('aria-expanded', 'true');
await expect(locator).toHaveCSS('background-color', 'rgb(255, 0, 0)');
await expect(locator).toHaveValue('hello');
await expect(locator).toMatchAriaSnapshot(`
  - heading "Welcome" [level=1]
  - button "Sign in"
`);

toMatchAriaSnapshot (новое в 1.50+), мощный способ ассертить структуру UI без хрупких CSS-селекторов.

Network interception

Перехватываем любой запрос, для моков, проверок, манипуляций.

// Замокать API на ответ
await page.route('**/api/users', async route => {
  const json = [{ id: 1, name: 'Alice' }];
  await route.fulfill({ json });
});

// Вернуть 500 для проверки error UI
await page.route('**/api/orders', route => route.fulfill({ status: 500 }));

// Симулировать медленную сеть
await page.route('**/*', async route => {
  await new Promise(r => setTimeout(r, 1000));
  await route.continue();
});

// Заблокировать аналитику
await page.route(/google-analytics|gtag|hotjar/, r => r.abort());

// Изменить тело ответа
await page.route('**/api/feature-flags', async route => {
  const original = await route.fetch();
  const json = await original.json();
  json.newCheckout = true;
  await route.fulfill({ response: original, json });
});

Дождаться запроса/ответа:

const responsePromise = page.waitForResponse('**/api/checkout');
await page.click('text=Pay');
const response = await responsePromise;
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ orderId: expect.any(String) });

Доступ к JS-контексту страницы

// Вернуть значение из браузера
const title = await page.evaluate(() => document.title);

// С аргументом
const text = await page.evaluate(sel => document.querySelector(sel)?.textContent, '#status');

// Установить cookie / localStorage
await page.context().addCookies([{ name: 'theme', value: 'dark', url: 'https://app.com' }]);
await page.evaluate(() => localStorage.setItem('flag', 'true'));

Эмуляция

// Девайсы
import { devices } from '@playwright/test';
const context = await browser.newContext(devices['iPhone 14']);

// Цвет схема
await page.emulateMedia({ colorScheme: 'dark' });

// Гео
await context.setGeolocation({ latitude: 41.89, longitude: 12.49 });

// Throttle CPU/network через CDP
const cdp = await context.newCDPSession(page);
await cdp.send('Network.emulateNetworkConditions', {
  offline: false,
  downloadThroughput: 1024 * 1024 * 1.5,
  uploadThroughput: 1024 * 1024 * 0.75,
  latency: 100,
});

Trace, Screenshot, Video

// playwright.config.ts
use: {
  trace: 'on-first-retry',          // или 'on', 'off', 'retain-on-failure'
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
},

После прогона:

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

В Trace Viewer вы видите: timeline кликов и assertов, network calls, console messages, DOM snapshot на каждом шаге, source code теста, scrubber для перемотки. Это самый мощный debug-инструмент в e2e.

API testing, без браузера

Playwright умеет делать чистые HTTP-запросы (для smoke-проверок API):

test('API health', async ({ request }) => {
  const res = await request.get('https://api.example.com/health');
  expect(res.ok()).toBeTruthy();
  expect(await res.json()).toMatchObject({ status: 'ok' });
});

Можно комбинировать в одном тесте: setup через API (создать пользователя), действия в UI, проверка через API.

Главное

Playwright построен на трёх абстракциях: Browser (тяжёлый, переиспользуемый), Context (изоляция, лёгкий), Page (вкладка). Действия и ассерты работают через Locators с auto-wait и retry; рекомендуется getByRole/Label/TestId как самые стабильные. Сеть перехватывается через route(). Trace viewer, главный debug-инструмент.

В следующем уроке, как организовать кодовую базу тестов: Page Object Model, fixtures, параметризация, sharding, фикстура авторизации.

AI-тест
1 / 5

Что необходимо для корректной обработки страницы Playwright?