Тест прошёл локально. В 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 (от лучшего к худшему):
| Тип локатора | Пример | Стабильность |
|---|---|---|
| getByRole | page.getByRole('button', { name: 'Войти' }) | Высокая |
| getByLabel | page.getByLabel('Email') | Высокая |
| getByTestId | page.getByTestId('submit-btn') | Высокая |
| getByText | page.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 --debugInspector позволяет шагать по тесту, видеть, какой локатор используется, и экспериментировать с селекторами прямо в браузере. Это быстрее, чем добавлять 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 хрупких, которые команда перестала воспринимать всерьёз. Пересматривайте покрытие после каждого инцидента в продакшне: если баг не был поймана автоматически, добавьте сценарий именно для этого случая.