Ночь, конвейер красный. Сквозной тест оформления заказа упал с сообщением о невидимой кнопке, дежурный разработчик перезапускает прогон, и через пять минут набор зеленеет сам. Дефекта нет, правок не было, был просто неудачный момент анимации. К третьему такому ночи команда делит набор на правдивые проверки и лотерейные билеты, а вера в сквозное тестирование испаряется быстрее, чем возвращаются результаты.

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

Два инструмента с разной архитектурой исполнения

Cypress вышел в стабильную версию в 2017 году и исполняет тесты внутри браузера: проверки живут в одном цикле событий с приложением, снимки состояния хранятся для путешествия во времени, набор перезагружается при правках в реальном времени. Плата за погружение ощутима: язык только JavaScript и TypeScript, а междоменные сценарии долгие годы оставались узким местом, собственный механизм появился лишь в десятой версии.

Playwright от Microsoft оформился в 2020 году и устроен иначе: он управляет браузерами снаружи, поэтому один набор обслуживает Chromium, Firefox и WebKit, а на выбор даются TypeScript, JavaScript, Python, Java и .NET. Автоматическое ожидание элементов встроено в ядро, генератор кода сам снимает селекторы со страницы, отдавая приоритет роли, тексту и тестовому идентификатору элемента, как обещает собственная документация. Инструменты взрослые и заслуживают доверия, различия сказываются на деталях: тяжёлые мультидоменные покупки удобнее под Playwright, быстрый фронтендовый цикл с мгновенной перезагрузкой исторически сильнее у Cypress. Оба инструмента бесплатны и открыты, лицензионный вопрос сводится к облачным надстройкам для записи и параллелизма.

Одинаковая задача читается в обоих стилях по-разному:

// Cypress
it("adds item to cart", () => {
  cy.visit("/catalog");
  cy.get('[data-cy="add-to-cart"]').click();
  cy.get('[data-cy="cart-count"]').should("have.text", "1");
});
# Playwright, вариант на Python
def test_adds_item_to_cart(page):
    page.goto("/catalog")
    page.get_by_test_id("add-to-cart").click()
    expect(page.get_by_test_id("cart-count")).to_have_text("1")

Выбор легко спроектировать под контекст команды: репозиторий на Python с готовым API-набором получает pytest-playwright и единый стиль проверок, чисто фронтендовый проект остаётся в экосистеме Cypress без второго языка в сборке.

Селекторы как контракт между тестом и вёрсткой

Хрупкость начинается с карточных домино. Сборка фронтенда превращает имена классов в .css-1x2y3z через хеширование, утилитарные классы меняются при каждой правке вёрстки, цепочки nth-child ломаются от одного перенесённого блока. Тест, привязанный к такой косметике, переживает релиз как переправа по льду. Смотри контраст:

cy.get(".css-1x2y3z > div:nth-child(2) button").click()
cy.get('[data-cy="checkout-submit"]').click()

Вторая строка опирается на договор: тестовый идентификатор ставится на интерактивные элементы сознательно и не участвует в оформлении. Официальные рекомендации Cypress прямо просят выделенные атрибуты data-cy под тесты, а генератор Playwright приоритизирует роль, текст и тестовый идентификатор, потому что такие якоря устойчивы к косметике вёрстки.

Самый прочный якорь связан с деревом доступности:

await page.getByRole("button", { name: "Оплатить заказ" }).click();
await expect(page.getByTestId("order-status")).toHaveText("Оплачен");

Ролевой локатор переживёт редизайн, пока кнопка остаётся кнопкой с тем же доступным именем, и одновременно подталкивает интерфейс к читаемости для вспомогательных технологий. Селекторы по тексту удобны, но умирают при локализации, и их держат для семантических якорей, а не для служебных надписей.

Договор оформляют инженерно, а не на словах. Линтер ищет удаление атрибута в компонентах и блокирует такой коммит, ревью вёрстки содержит строку про идентификаторы, а крупный набор прячет локаторы в объекты страниц:

class CheckoutPage:
    def __init__(self, page):
        self.page = page
        self.submit = page.get_by_role("button", name="Оплатить")

    def pay(self):
        self.submit.click()

Правка якоря тогда занимает одно место в коде, а не двадцать файлов набора, и новичок читает сценарии как меню страницы, а не как кладезь строк-селекторов.

Ожидания состояния вместо задержек на секунды

Классическая причина флэйка выглядит безобидно:

cy.wait(3000)
cy.get("[data-cy=result]")

Три секунды хватает девять раз из десяти, на десятый машина сборщика занята, страница грузится четыре секунды, тест краснеет. Пауза наудачу проигрывает по обоим параметрам сразу: набор тормозит и всё равно падает. Оба инструмента учат обратному. Cypress называет это повторяемостью, базовой концепцией: проверки и команды автоматически перезапрашивают состояние до истечения тайм-аута. Playwright опрашивает условия в ожидании до лимита:

await expect(page.getByTestId("result")).toBeVisible({ timeout: 10000 });

Правило простое и революционное по эффекту: жди состояние, а не часы. Тест с ожиданиями состояния завершается так быстро, как только условие выполнено, и переносится между машинами без подгонки задержек под конкретный процессор. Заводские тайм-ауты у обоих инструментов измеряются секундами, их расширяют точечно на тяжёлых шагах вроде оплаты, а глобальный лимит прогона держат коротким, чтобы шум не прятался в запасе времени.

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

Откуда берутся падения без дефекта в коде

Флэйк редко приходит один, у него понятная экология. Анимации скрывают кнопку в момент клика, debounce после ввода задерживает запрос, сторонний виджет чата перехватывает фокус, ленивые изображения сдвигают вёрстку, шрифты догружаются позже текста. Сюда же относится грязное состояние: куки и localStorage перетекают из теста в тест, и сценарий зелёный в одиночном прогоне и красный в общем, потому что предыдущий тест оставил оплаченную корзину.

Лекарства входят в оба инструмента. Перехват сети стабилизирует внешние зависимости, cy.intercept и page.route подменяют медленные или капризные сервисы управляемыми ответами. Подменные таймеры останавливают анимации и спорные задержки времени. Изоляция сценария, когда тест сам готовит своё состояние и не доверяет соседям, документирована как базовое требование Cypress. Отдельная глава боли, мультидоменные цепочки вроде внешней оплаты: команды, переехавшие на Playwright ради таких сценариев, объясняют переезд именно этой главой.

Перехват сети выглядит лаконично:

cy.intercept("POST", "/api/orders", { fixture: "order-ok.json" }).as("createOrder")

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

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

Конвейер с изоляцией параллелизмом и диагностикой падений

Стабильность в конвейере собирается из конфигурации. У Playwright она лежит в одном файле:

export default defineConfig({
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  trace: "retain-on-failure",
  webServer: { command: "npm run dev", port: 3000, reuseExistingServer: !process.env.CI },
});

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

Красный набор в конвейере обязан быть диагностичным, иначе повторы маскируют настоящие дефекты. Повтор считается сигналом для разбора, а не рутиной: сценарий, краснеющий чаще одного раза в неделю, попадает в отдельный список и чинится как дефект, а не игнорируется. У Cypress повторяемость по режимам настраивается парой строк:

e2e: {
  retries: { runMode: 2, openMode: 0 },
}

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

Правила набора, который живёт через десятки релизов

Перечень привычек, отделяющих набор от лотереи, выглядит так:
1. договор о тестовых идентификаторах на интерактивных элементах, зафиксированный в ревью вёрстки;
2. ожидания состояния вместо жёстких задержек, запрет cy.wait наудачу;
3. ролевые локаторы как основные якоря, идентификаторы как запасной слой;
4. изоляция сценария: свой прогон, своё состояние, никаких завязок на соседей;
5. перехват сети для внешних зависимостей, настоящими остаются только свои узлы;
6. повтор прогона и трассировка при падении, разбор каждого повтора как сигнала.

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

Вывод о терпимости набора и цене правды интерфейса

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

Итоговая арифметика команды проста. Селекторы по договору и ожидания состояния дают устойчивость, изоляция и перехват сети дают воспроизводимость, повторы с трассировкой дают диагностику вместо лотереи. Набор, собранный по этим правилам, краснеет осмысленно и редкость этого события возвращает ему доверие. Правды интерфейса, которую проверяют сквозные сценарии, не заменит ни один юнит-тест, и платить за неё стоит не ночными разборами, а дневной дисциплиной якорей. На частый вопрос, сколько сценариев держать сквозными, отвечает цена минуты конвейера: сценариев столько, сколько критичных пользовательских путей, и ни одного лишнего. Каждый лишний сценарий покупается устойчивыми якорями, иначе он превращается в источник шума, который обесценивает весь набор. Оправданность сквозного слоя подтверждает только счёт пойманных до релиза дефектов. Пустой счёт за квартал сигнал к пересборке якорей и ожиданий, а не к отказу от слоя.