page.waitForTimeout(2000) перед ассертом почти всегда неверное решение: ассерты Playwright уже поллят до прохождения, поэтому sleep избыточен при быстрых прогонах и всё равно слишком короток при медленных. Правильный паттерн: дать ассерту делать ожидание, или использовать waitForResponse внутри Promise.all чтобы поймать конкретный API-вызов перед проверкой его результата. Эта статья разбирает условия auto-waiting в Playwright, когда их недостаточно, и пять явных API ожидания: какие использовать, а какие считать признаком проблемы в коде.

Как работает auto-waiting в Playwright

Когда пишешь await page.getByRole('button', { name: 'Submit' }).click(), Playwright не кликает немедленно. Он ждёт пока кнопка будет:

  • Присоединена к DOM
  • Видима (не скрыта, не display: none)
  • Стабильна (не анимируется)
  • Доступна (не отключена)
  • Принимает события указателя (не перекрыта другим элементом)

Всё это происходит автоматически, до истечения actionTimeout (по умолчанию 30 секунд). Никакого кода ожидания писать не нужно.

Именно поэтому Playwright быстрее в написании чем Selenium. В Selenium написал бы WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ROLE, 'button'))). В Playwright: просто клик.

Когда auto-waiting недостаточно

Auto-waiting работает для взаимодействий с элементами. Не всё он охватывает:

Навигация по страницам. После отправки формы или клика по ссылке URL меняется. Auto-waiting не будет ждать пока новая страница полностью загрузится перед следующим действием. Загрузка данных. Элемент существует и виден, но пока данные загружаются показывает спиннер. Playwright может кликнуть по нему до прихода данных. Несколько сетевых запросов. Загрузка страницы запускает три API-вызова. Playwright видит что DOM готов, но третий вызов ещё не завершён. Завершение анимации. Элемент технически виден, но в середине анимации. Auto-waiting справляется с простыми CSS-переходами, но не со всеми состояниями анимации.

expect как инструмент ожидания

Самый недооценённый паттерн ожидания в Playwright: ассерты ждут.

// Ждёт до таймаута пока URL не совпадёт
await expect(page).toHaveURL('/dashboard');

// Ждёт пока текст не появится
await expect(page.getByRole('heading')).toHaveText('Order confirmed');

// Ждёт пока количество элементов не достигнет 5
await expect(page.getByRole('listitem')).toHaveCount(5);

Каждый expect-ассерт в Playwright поллит до прохождения или таймаута. Это делает ассерты самым чистым способом ждать состояния приложения.

Антипаттерн

await page.waitForTimeout(2000); // никогда так не делай
await expect(page.getByRole('heading')).toHaveText('Order confirmed');

Правильно

await expect(page.getByRole('heading')).toHaveText('Order confirmed');

expect делает ожидание. waitForTimeout: признак того что непонятно чего именно ждать.

Явные инструменты ожидания

Когда явное ожидание всё же нужно, Playwright предоставляет точечные варианты:

waitForURL

await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('/dashboard');
// Теперь можно безопасно проверять содержимое дашборда

waitForResponse: ожидание конкретного API-вызова

const [response] = await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.status() === 200),
  page.getByRole('button', { name: 'Place order' }).click(),
]);
const orderData = await response.json();
expect(orderData.status).toBe('created');

Начинай ожидание до действия которое запускает запрос. Promise.all гарантирует что быстрый ответ не будет пропущен.

waitForRequest: проверка что запрос был отправлен

const [request] = await Promise.all([
  page.waitForRequest(req => req.url().includes('/api/track') && req.method() === 'POST'),
  page.getByRole('button', { name: 'Purchase' }).click(),
]);
// Проверяем что аналитическое событие сработало
expect(request.postDataJSON()).toMatchObject({ event: 'purchase' });

waitForSelector: ожидание состояния элемента

// Ждём пока спиннер загрузки не исчезнет
await page.waitForSelector('.spinner', { state: 'detached' });

// Ждём пока элемент не станет видимым
await page.waitForSelector('[data-testid="results-table"]', { state: 'visible' });

Опции state: 'attached', 'detached', 'visible', 'hidden'.

Предпочитай expect(locator).toBeVisible() вместо waitForSelector: ассертный подход читабельнее.

waitForLoadState

await page.goto('/heavy-page');
await page.waitForLoadState('networkidle'); // Ждём пока 500мс не будет сетевой активности

Опции loadState: 'load' (событие window.load сработало, по умолчанию для goto), 'domcontentloaded' (DOM разобран, до загрузки изображений и скриптов) и 'networkidle' (нет сетевых запросов 500мс).

'networkidle' медленный и ненадёжный. Избегай его если страница не сигнализирует о готовности другим способом. Лучше ждать конкретный элемент.

Настройка таймаутов

Таймауты настраиваются на трёх уровнях:

// playwright.config.ts — применяется ко всем тестам
export default defineConfig({
  timeout: 30000,          // Таймаут теста (весь тест)
  expect: {
    timeout: 5000,         // Таймаут ассерта
  },
  use: {
    actionTimeout: 15000,  // Таймаут отдельного действия (клик, fill и т.д.)
    navigationTimeout: 30000,
  },
});

Переопределение для конкретного теста:

test('slow data load', async ({ page }) => {
  test.setTimeout(60000); // Этот тест получает 60 секунд
  // ...
});

Переопределение для конкретного ассерта:

await expect(page.getByText('Report generated')).toBeVisible({ timeout: 30000 });

Правило waitForTimeout

page.waitForTimeout(ms) это sleep. Иногда он нужен как последнее средство (например, ожидание стороннего скрипта который нельзя наблюдать). Но каждое его появление стоит считать TODO: чего именно здесь нужно ждать?

Если await page.waitForTimeout(1000) встречается больше чем в одном-двух тестах, у твоего сьюта структурная проблема с ожиданиями. Стоит разобраться.

→ See also: Отладка нестабильных тестов: практическое руководство | Нестабильные тесты: почему они возникают и как их устранить | Assertions в Playwright: полное руководство