Представьте: тест падает. Вы запускаете снова — проходит. Код не менялся. Это нестабильный тест, и он опаснее, чем его отсутствие.
Команда быстро привыкает перезапускать «красное» и не воспринимать его всерьёз. В какой-то момент среди ложных срабатываний теряется настоящая поломка. Один такой тест подрывает доверие ко всему набору — если нельзя доверять красному, зачем вообще смотреть на результаты?
Поэтому бороться с нестабильностью — не наведение порядка, а условие, при котором e2e вообще имеет смысл.
Тайминги — причина номер один
Тест проверил элемент раньше, чем тот появился на экране. Или нажал кнопку до того, как страница завершила загрузку. Результат — иногда успевает, иногда нет.
Это самая частая причина нестабильности, и она почти полностью снимается одним приёмом: вместо фиксированных задержек (sleep) используют ожидание условия. В Playwright это называется web-first assertions — фреймворк сам ждёт, пока элемент появится, прежде чем проверять.
// Плохо: ждём две секунды и надеемся
await page.waitForTimeout(2000);
await expect(page.locator('.result')).toBeVisible();
// Хорошо: Playwright сам ждёт, пока элемент не появится
await expect(page.locator('.result')).toBeVisible();
Подробнее об ожиданиях — в статье Assertions и ожидания.
Гонки — действие и ожидание разъехались
Тест кликает кнопку и сразу проверяет результат. Но запрос к серверу ещё не завершился. Иногда сервер отвечает быстро и тест проходит, иногда чуть медленнее — и уже нет.
Правило: если действие запускает сетевой запрос, ожидание ответа и само действие должны быть связаны, а не идти независимо.
// Плохо: клик и ожидание независимы — может проскочить мимо
await page.click('#save');
await page.waitForResponse('/api/orders');
// Хорошо: ожидание «обёрнуто» вокруг клика
const [response] = await Promise.all([
page.waitForResponse('/api/orders'),
page.click('#save'),
]);
О перехвате сети и ожидании ответов — в статье Контроль сети.
Общее состояние — тесты мешают друг другу
Тест оставил в базе запись. Следующий тест наткнулся на неё и упал — хотя должен был работать с чистыми данными. При параллельном запуске это случается постоянно.
Решение — изоляция: каждый тест создаёт своё, а после чистит. Не «до» или «после всего набора», а именно каждый — своё.
Если тест зависит от данных, он должен сам их создать. Если он что-то изменяет — откатить. Тогда порядок запуска и параллельность перестают иметь значение.
Подробнее о стратегии данных — в статье Тестовые данные и авторизация.
Внешние зависимости — сеть и сторонние сервисы
Тест обращается к стороннему API. Тот медленно отвечает или вовсе недоступен. Тест падает — не из-за вашего кода, а из-за чужого.
Внешние зависимости мокируют: Playwright умеет перехватывать запросы и возвращать заготовленный ответ, не обращаясь к реальному серверу. Это делает тест быстрым и стабильным независимо от состояния внешнего мира.
await page.route('https://api.external.com/data', route =>
route.fulfill({ json: { status: 'ok' } })
);
Retries — обезболивающее, а не лечение
Playwright умеет перезапускать упавший тест (retries). В CI это полезно: иногда инфраструктура подводит сама по себе — нестабильная сеть в контейнере, перегруженный раннер. Один лишний запуск отсеивает такой случайный сбой.
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});
Но retries не устраняет нестабильность — он её скрывает. Тест «зелёный со второго раза» выглядит рабочим, а внутри по-прежнему проблема. Если тест регулярно проходит только с повтора — это сигнал: нужно разобраться в причине, а не радоваться зелёному.
Ноль повторов локально помогает быстрее замечать нестабильные тесты и чинить их до того, как они попадут в CI.
Детерминизм — один код, один результат
Тест должен давать одинаковый результат каждый раз при одном и том же коде. Это и называется детерминизм.
Враги детерминизма — всё «плавающее»:
- Текущее время и даты. Тест, проверяющий «создан сегодня», даст разный результат в разные дни. Подставляйте фиксированное время.
- Случайные данные. Если тест создаёт пользователя с
Math.random()в имени, имя каждый раз разное. Используйте контролируемые уникальные ключи. - Порядок элементов. Если список отсортирован по полю, которое может совпасть, порядок непредсказуем. Не опирайтесь на сортировку по умолчанию, если в ней нет жёсткой гарантии.
- Часовой пояс. Запуск на разных машинах может давать разное «местное время» — явно указывайте зону.
Детерминированный тест либо всегда зелёный, либо всегда красный. Только тогда красному можно верить.
Карантин — не игнор
Нашли нестабильный тест. Что делать?
Отключить и забыть — плохой ответ. Проверка пропадёт, и никто не узнает, что она больше не работает.
Правильный ответ — карантин: пометить тест отдельной меткой, зафиксировать задачу на починку и починить причину. Пока тест в карантине, он запускается отдельно и не блокирует CI, но о нём знают и работают над ним.
Молча закомментированный тест — это потерянная проверка, о которой никто не помнит.
Коротко
- Нестабильный тест опаснее отсутствующего: команда перестаёт доверять результатам и пропускает настоящие поломки.
- Главные причины: тайминги, гонки, общее состояние между тестами, внешние зависимости.
- Тайминги лечат web-first assertions — Playwright сам ждёт, а не вы жёстко задаёте паузу.
- Гонки лечат связыванием действия и ожидания через
Promise.all. - Общее состояние лечат изоляцией: каждый тест создаёт и чистит своё.
- Внешние зависимости мокируют через
page.route, чтобы не зависеть от чужого сервера. retriesв CI снижают шум от случайных сбоев инфраструктуры, но не лечат нестабильность кода.- Детерминизм: один код → один результат. Фиксируйте время, зону, ключи — всё «плавающее».
- Нестабильный тест переводят в карантин и чинят причину, а не замалчивают.
Что почитать дальше
- Assertions и ожидания — web-first assertions и правильные ожидания в Playwright.
- Контроль сети — перехват запросов, моки внешних зависимостей.
- Тестовые данные и авторизация — изоляция данных между тестами.
- E2E в CI — как запускать тесты в пайплайне и читать отчёты.