E2E тестирование Playwright: как писать стабильные тесты и бороться с flakiness

E2E тестирование Playwright: как писать стабильные тесты и бороться с flakiness

Тест прошёл локально. В CI упал. Перезапустили - зелёный. Снова упал через два коммита. Знакомая картина? Нестабильные автотесты - одна из главных причин, по которым команды теряют доверие к своей тестовой обвязке и начинают игнорировать красные билды.

Playwright даёт мощный фундамент для end-to-end проверок: авто-ожидания, изолированные контексты, встроенный трейсинг. Но даже с таким инструментом можно написать тесты, которые падают через раз. Причина почти всегда не в самом фреймворке, а в том, как именно написан тест.

В этой статье разберём конкретные причины нестабильности, покажем, как их устранять, и объясним, как выстроить архитектуру автотестов так, чтобы они работали стабильно и в CI, и локально.

Коротко:

  • Большинство нестабильных тестов падают из-за race conditions, хрупких локаторов или зависимости от состояния предыдущего теста.
  • Playwright имеет встроенные авто-ожидания, но они не спасают от неправильно выбранных селекторов и отсутствия явных проверок готовности.
  • Page Object Model помогает централизовать локаторы и снизить хрупкость при изменениях UI.
  • В CI тесты падают чаще из-за медленного окружения, нехватки ресурсов и отсутствия изоляции данных.
  • Трейсы, скриншоты и видео в Playwright позволяют понять причину падения без локального воспроизведения.
  • Ретрай - не решение проблемы, а способ скрыть её. Сначала найдите причину, потом решайте, нужен ли retry.

Почему тесты становятся нестабильными

Flaky-тест - это тест, который при одинаковом коде и данных иногда проходит, иногда падает. Причина почти никогда не бывает случайной: за каждым нестабильным тестом стоит конкретная техническая проблема.

Самые частые источники нестабильности в Playwright:

  • Race conditions. Тест кликает по элементу раньше, чем тот стал интерактивным. Или проверяет данные до того, как завершился сетевой запрос.
  • Хрупкие локаторы. Селекторы по XPath, порядковому индексу или динамическому CSS-классу ломаются при малейшем изменении вёрстки.
  • Зависимость между тестами. Один тест оставляет данные или состояние, на которое неявно рассчитывает следующий.
  • Разница в окружении. Локально тест работает на мощной машине, в CI - на контейнере с ограниченными ресурсами и другой скоростью сети.
  • Анимации и переходы. Элемент визуально присутствует, но ещё находится в процессе CSS-анимации и не реагирует на клик так, как ожидается.

Локаторы: где чаще всего закладывается хрупкость

Выбор локатора - это первое решение, которое определяет стабильность теста. Playwright рекомендует использовать семантические локаторы, которые отражают то, что видит и делает пользователь, а не внутреннюю структуру DOM.

Иерархия надёжности локаторов в Playwright (от лучшего к худшему):

Тип локатораПримерСтабильность
getByRolepage.getByRole('button', { name: 'Войти' })Высокая
getByLabelpage.getByLabel('Email')Высокая
getByTestIdpage.getByTestId('submit-btn')Высокая
getByTextpage.getByText('Сохранить')Средняя
CSS-селекторpage.locator('.btn-primary')Низкая
XPath по индексу//div[3]/button[1]Очень низкая

Атрибут data-testid - хороший компромисс между стабильностью и независимостью от визуального дизайна. Договоритесь с разработчиками добавлять его на ключевые интерактивные элементы. Это небольшие затраты с их стороны, но они кратно снижают количество сломанных тестов после рефакторинга вёрстки.

Избегайте локаторов, которые зависят от порядка элементов: .nth(0) или //li[2]. Если список изменится или отсортируется иначе, тест упадёт без видимой причины.

Race conditions и как с ними работать

Playwright имеет встроенные авто-ожидания: перед кликом он ждёт, пока элемент станет видимым и кликабельным. Но этого недостаточно в ситуациях, когда нужно дождаться конкретного сетевого запроса или изменения состояния приложения.

Типичная ошибка - проверять результат сразу после действия, не убедившись, что операция завершилась:

Хрупкий подход:

await page.getByRole('button', { name: 'Сохранить' }).click();
await expect(page.getByText('Данные сохранены')).toBeVisible();
// Тест может упасть, если сервер отвечает медленно

Надёжный подход - ждём завершения запроса:

const responsePromise = page.waitForResponse(
  response => response.url().includes('/api/save') && response.status() === 200
);
await page.getByRole('button', { name: 'Сохранить' }).click();
await responsePromise;
await expect(page.getByText('Данные сохранены')).toBeVisible();

Метод waitForResponse позволяет явно синхронизировать тест с сетевым слоем. Аналогично работает waitForURL для навигации и waitForLoadState('networkidle') для страниц с множеством параллельных запросов.

Важный нюанс: networkidle ждёт, пока не останется активных сетевых соединений в течение 500 мс. На страницах с polling или WebSocket это может зависнуть навсегда. Используйте его осторожно и только там, где это действительно нужно.

Изоляция тестов: почему порядок запуска не должен иметь значения

Зависимость между тестами - скрытая причина многих нестабильных прогонов. Если тест B рассчитывает на данные, созданные тестом A, то при параллельном запуске или изменении порядка всё сломается.

Каждый тест должен создавать своё состояние и убирать за собой. В Playwright для этого используют хуки beforeEach и afterEach, а также фикстуры.

Пример изолированного теста с фикстурой:

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  // Каждый тест начинается с чистого состояния
  await page.goto('/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Пароль').fill('secret');
  await page.getByRole('button', { name: 'Войти' }).click();
  await page.waitForURL('/dashboard');
});

test('пользователь видит список задач', async ({ page }) => {
  await expect(page.getByRole('heading', { name: 'Мои задачи' })).toBeVisible();
});

Если тест требует данных в базе, создавайте их через API, а не через UI. Это быстрее и надёжнее: вы не зависите от стабильности интерфейса создания данных.

Page Object Model: как организовать код, чтобы не переписывать тесты после каждого релиза

Page Object Model (POM) - это паттерн, при котором каждая страница или компонент приложения описывается отдельным классом. Локаторы и действия живут в этом классе, а сами тесты работают только с его методами.

Главная польза не в красоте кода, а в практике: когда разработчики переименовывают кнопку или меняют атрибут, вы правите одно место, а не двадцать тестов.

Простой Page Object для страницы входа:

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Пароль');
    this.submitButton = page.getByRole('button', { name: 'Войти' });
  }

  async login(email: string, password: string) {
    await this.page.goto('/login');
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
    await this.page.waitForURL('/dashboard');
  }
}

// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('успешный вход', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('user@example.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

Не пытайтесь сделать универсальный базовый класс для всех страниц сразу. Начните с простых объектов для самых часто используемых страниц и расширяйте по мере необходимости.

Автотесты в CI: почему локальный зелёный не гарантирует зелёный в пайплайне

CI-окружение отличается от локальной машины по нескольким параметрам: меньше CPU и RAM, другая скорость диска, сеть с задержками, отсутствие GPU для рендеринга. Всё это влияет на поведение браузера и скорость загрузки страниц.

Несколько практик, которые снижают количество специфичных для CI падений:

  • Запускайте браузер в headless-режиме. Он потребляет меньше ресурсов и работает стабильнее на серверах без дисплея.
  • Ограничивайте параллелизм. По умолчанию Playwright запускает тесты параллельно. На слабом CI-агенте это может приводить к конкуренции за ресурсы. Настройте workers в конфиге под возможности машины.
  • Увеличьте таймауты для CI. Используйте переменную окружения или условие в конфиге: timeout: process.env.CI ? 60000 : 30000.
  • Сохраняйте артефакты при падении. Скриншоты, видео и трейсы помогут понять причину без локального воспроизведения.

Фрагмент playwright.config.ts для CI:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: process.env.CI ? 60000 : 30000,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 2 : undefined,
  use: {
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'on-first-retry',
  },
});

Обратите внимание на retries: 1 в CI. Один ретрай допустим как страховка от случайных сетевых сбоев, но не должен маскировать системную проблему. Если тест стабильно проходит только со второй попытки, это сигнал к разбору причины.

Отладка падений: трейсы, скриншоты и Playwright Inspector

Когда тест упал в CI и вы не можете воспроизвести проблему локально, трейс - ваш главный инструмент. Он записывает все действия, сетевые запросы, снимки DOM и скриншоты в один файл, который можно открыть в браузере.

Включить запись трейса при первом ретрае:

use: {
  trace: 'on-first-retry'
}

Открыть трейс локально:

npx playwright show-trace trace.zip

Для интерактивной отладки используйте Playwright Inspector. Запустите тест с флагом --debug:

npx playwright test auth.spec.ts --debug

Inspector позволяет шагать по тесту, видеть, какой локатор используется, и экспериментировать с селекторами прямо в браузере. Это быстрее, чем добавлять console.log и перезапускать тест.

Ещё один полезный инструмент - page.pause(). Вставьте его в нужное место теста, и выполнение остановится, открыв Inspector. Не забудьте убрать перед коммитом.

Анимации и визуальные переходы

Модальные окна, тосты, выпадающие меню с анимацией - частый источник проблем. Элемент уже присутствует в DOM, Playwright считает его видимым, но CSS-анимация ещё не завершилась, и клик попадает не туда или игнорируется.

Решение: отключайте анимации в тестовом окружении. Это можно сделать через CSS:

*, *::before, *::after {
  animation-duration: 0s !important;
  transition-duration: 0s !important;
}

Либо через Playwright, добавив стиль перед каждым тестом:

await page.addStyleTag({
  content: `*, *::before, *::after {
    animation-duration: 0s !important;
    transition-duration: 0s !important;
  }`
});

Если отключить анимации нельзя по техническим причинам, используйте waitForSelector с опцией state: 'stable' или явно ждите исчезновения класса анимации.

Типичные ошибки, которые закладывают нестабильность

ОшибкаПоследствиеКак исправить
Жёсткий page.waitForTimeout(3000)Тест медленный и всё равно падает при задержке сетиЗаменить на waitForResponse или waitForSelector
Локатор по CSS-классу с хешемЛомается при каждой сборке фронтендаИспользовать data-testid или getByRole
Общее состояние между тестамиТест падает только при определённом порядке запускаИзолировать данные через beforeEach и API
Проверка текста без учёта пробеловПадает из-за неразрывного пробела или лишнего символаИспользовать toContainText вместо toHaveText
Клик по элементу вне viewportЭлемент не получает событиеДобавить scrollIntoViewIfNeeded() или использовать force: true осознанно

Чеклист стабильного теста на Playwright

  • Локаторы используют getByRole, getByLabel или data-testid, а не CSS-классы с хешами или XPath по индексу.
  • После каждого действия, которое инициирует запрос, есть явное ожидание ответа или изменения состояния.
  • Тест не зависит от данных, созданных другим тестом.
  • Тестовые данные создаются через API, а не через UI (там, где это возможно).
  • Нет жёстких waitForTimeout - только семантические ожидания.
  • Анимации отключены в тестовом окружении.
  • Конфиг для CI использует увеличенные таймауты и сохраняет артефакты при падении.
  • Page Object Model используется для страниц, которые затрагивают более двух тестов.
  • Тест проходит при параллельном запуске с другими тестами.
  • Трейс включён хотя бы для первого ретрая.

Параллельный запуск: как настроить без конфликтов

Playwright по умолчанию запускает сценарии параллельно. Это ускоряет прогон, но создаёт новый класс проблем: несколько сценариев одновременно обращаются к одному пользователю, одной записи в базе или одному разделу приложения.

Самый частый симптом: сценарий проходит в одиночном запуске, но падает в общем прогоне. Причина почти всегда в конкуренции за данные.

Несколько правил, которые помогают избежать конфликтов при параллельном запуске:

  • Создавайте уникальные данные для каждого сценария. Генерируйте email через Date.now() или uuid, а не используйте фиксированный test@example.com.
  • Не меняйте глобальные настройки приложения в середине прогона. Если сценарий переключает язык или тему, делайте это в изолированном контексте браузера.
  • Разделяйте тяжёлые сценарии по файлам. Playwright запускает файлы параллельно, а сценарии внутри одного файла - последовательно. Это позволяет управлять уровнем параллелизма без дополнительных настроек.
  • Если несколько сценариев должны использовать одного авторизованного пользователя, сохраняйте состояние сессии через storageState и переиспользуйте его, не проходя авторизацию каждый раз.

Сохранение и переиспользование сессии:

// Один раз сохраняем состояние авторизации
await page.context().storageState({ path: 'auth.json' });

// В playwright.config.ts указываем для проекта
projects: [
  {
    name: 'authenticated',
    use: { storageState: 'auth.json' },
  }
]

Это убирает необходимость проходить UI-авторизацию в каждом сценарии и снижает нагрузку на CI-агент.

Моки и перехват сети: когда это помогает стабильности

Часть нестабильности приходит не из кода сценариев, а из внешних зависимостей: медленный бэкенд, нестабильный стейджинг, сторонние сервисы с rate limiting. Если сценарий проверяет поведение UI, а не интеграцию с реальным API, имеет смысл перехватить сетевые запросы и вернуть предсказуемый ответ.

Playwright позволяет делать это через page.route():

await page.route('**/api/products', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Товар А', price: 999 }]),
  });
});

Когда моки оправданы, а когда нет:

СитуацияМок подходитМок не подходит
Проверка отображения данных в UIДа 
Проверка поведения при ошибке сервера (500, 503)Да 
Проверка реальной бизнес-логики бэкенда Нет
Интеграционный сценарий «от UI до базы» Нет
Сторонний сервис с нестабильным стейджингомДа 

Важно не переусердствовать: если замокать слишком много, сценарии перестают проверять реальное взаимодействие компонентов. Оптимальный подход - мокировать только то, что находится за пределами зоны ответственности данного сценария.

Метрики качества покрытия: как понять, что сценариев достаточно

Количество сценариев само по себе не говорит о качестве покрытия. Можно написать сто проверок, которые дублируют happy path, и пропустить весь класс граничных состояний. Несколько ориентиров, которые помогают оценить реальное покрытие:

  • Критические пользовательские пути покрыты полностью. Регистрация, вход, ключевые действия, выход - эти сценарии должны работать стабильно при каждом прогоне.
  • Есть сценарии для ошибочных состояний. Неверный пароль, недоступный сервис, пустой список, превышение лимита - UI должен корректно обрабатывать каждый из этих случаев.
  • Покрытие разных ролей. Если приложение разграничивает права, у каждой роли должны быть собственные сценарии, а не только проверка под администратором.
  • Соотношение стабильных и нестабильных прогонов. Если более 5% прогонов требуют ретрая, это сигнал к аудиту, а не норма.

Практический ориентир: Хороший набор end-to-end сценариев невелик по объёму, но покрывает все критические пути и ключевые ошибочные состояния. Лучше 30 стабильных и осмысленных сценариев, чем 200 хрупких, которые команда перестала воспринимать всерьёз. Пересматривайте покрытие после каждого инцидента в продакшне: если баг не был поймана автоматически, добавьте сценарий именно для этого случая.

FAQ

Что такое flaky-тест и почему это проблема?

Flaky-тест - это тест, который при одинаковых условиях иногда проходит, иногда падает. Проблема не только в ложных тревогах: команда начинает игнорировать красные билды, и реальные баги проходят незамеченными. Нестабильный тест хуже отсутствующего.

Playwright сам ждёт элементы, зачем тогда явные ожидания?

Авто-ожидания Playwright проверяют видимость и кликабельность элемента, но не знают о бизнес-логике вашего приложения. Если после клика должен завершиться сетевой запрос или обновиться состояние Redux - это нужно ждать явно. Иначе тест проверит промежуточное состояние.

Стоит ли использовать ретраи для борьбы с нестабильностью?

Один ретрай в CI - разумная страховка от случайных сетевых сбоев. Но если тест проходит только со второй попытки регулярно, это симптом реальной проблемы. Ретрай её скрывает, но не решает. Найдите причину и устраните её.

Как быстро найти причину падения в CI без локального воспроизведения?

Включите запись трейса при первом ретрае (trace: 'on-first-retry') и сохранение видео при падении (video: 'retain-on-failure'). Скачайте артефакты из CI и откройте трейс командой npx playwright show-trace trace.zip. Вы увидите каждый шаг, DOM-снимок и сетевые запросы в момент падения.

Нужен ли Page Object Model для небольшого проекта?

Если у вас меньше 20 тестов и один разработчик - можно обойтись без него. Но как только несколько тестов используют одни и те же локаторы, POM окупается при первом же рефакторинге UI. Лучше ввести его раньше, чем переписывать тесты после каждого спринта.

Почему тест падает только при параллельном запуске?

Скорее всего, тесты конкурируют за общий ресурс: одну учётную запись, одну запись в базе или один URL. Каждый тест должен работать с изолированными данными. Создавайте уникальных пользователей и тестовые объекты в beforeEach, а не переиспользуйте общие.

Как отлаживать тест в интерактивном режиме?

Запустите тест с флагом --debug: npx playwright test имя_файла.spec.ts --debug. Откроется Playwright Inspector, где можно шагать по тесту, видеть подсвеченные локаторы и экспериментировать с селекторами. Также можно вставить await page.pause() в нужное место теста.

Итог

Нестабильные тесты - это не неизбежность, а симптом конкретных технических решений. Хрупкие локаторы, отсутствие явных ожиданий, общее состояние между тестами и игнорирование разницы между локальной машиной и CI - вот четыре причины, которые объясняют большинство падений.

Playwright даёт хороший набор инструментов для написания надёжных проверок: семантические локаторы, трейсинг, изолированные контексты и гибкую систему ожиданий. Но инструмент работает только тогда, когда тест написан с пониманием того, что именно он проверяет и в каком состоянии должно находиться приложение в каждый момент.

Начните с аудита самых нестабильных тестов: посмотрите на локаторы, найдите места без явных ожиданий и проверьте, нет ли неявных зависимостей между сценариями. Обычно 20% тестов дают 80% нестабильных прогонов - и именно с них стоит начинать.