Архитектура 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:
- Attached, присутствует в DOM.
- Visible,
display ≠ none,visibility ≠ hidden, размеры > 0. - Stable, не меняет позицию/размер 2 кадра подряд (анимация закончилась).
- Enabled, нет атрибута
disabled. - 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, фикстура авторизации.