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 хрупких, которые команда перестала воспринимать всерьёз. Пересматривайте покрытие после каждого инцидента в продакшне: если баг не был поймана автоматически, добавьте сценарий именно для этого случая.

Часто задаваемые вопросы

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

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

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

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

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

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

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

Итог

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

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

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