Все начинается с сообщения в Slack. Сборка «красная». Вы просматриваете ошибки, хмуритесь и запускаете тот же тест на своем ноутбуке. Зеленый. Вы перезапускаете CI-задачу. Возможно, это был разовый сбой. Но ошибка возвращается — упрямая и воспроизводимая на сервере, но невидимая для вас.

Браузерный тест, который падает в CI, но проходит локально — это не просто досадная помеха. Он порождает недоверие. Команды начинают винить тайминги. Они внедряют временные исправления, которые остаются навсегда. setTimeout здесь, .wait(5000) там. Набор тестов замедляется. Ошибки продолжают возвращаться. Эти нестабильные тесты превращаются в постоянную проблему, и в конечном итоге все начинают воспринимать «красный» пайплайн как фоновый шум.

Это опасно. Вам не нужен набор тестов, который постоянно «кричит: волки!».

CI не сломан; он просто другой

Среды CI не случайны. Они детерминированы. Проблема в том, что они детерминированы относительно системы, которая не является вашим MacBook или рабочей станцией на Linux. Ваша локальная настройка скрывает различия, которые чистый CI-раннер выявляет мгновенно.

Подумайте о том, как много компонентов различается. Ваша локальная машина может запускать сервер разработки с hot module reloading, в то время как CI собирает продакшн-артефакт с tree shaking и минификацией. Одно это может привести к удалению путей выполнения кода или изменению порядка выполнения. Деревья зависимостей смещаются. Lock-файл, который кажется идентичным, может разрешиться иначе, если версия пакетного менеджера отличается хотя бы на одно минорное обновление. Меняются сетевые последовательности. Ваш офисный Wi-Fi может разрешить запрос к staging API за один прыжок; CI-раннер может попасть на другой кластер за балансировщиком нагрузки, что создаст задержки, которых вы никогда не видите.

Сами браузеры ведут себя по-разному в разных средах. Ваш локальный Chrome содержит расширения, кэшированные учетные данные, постоянное локальное хранилище и GPU с аппаратным ускорением. CI при каждом запуске стартует с чистого профиля. Жизненные циклы браузера различаются. Пути рендеринга различаются. Шрифты, которые есть в вашей системе, заменяются в CI. Размеры вьюпорта и коэффициенты пикселей устройства варьируются, что может изменить точки перелома (breakpoints) адаптивной верстки или поведение ленивой загрузки.

Эти различия реальны. Они механистичны. Притворство, что они случайны, не заставит их исчезнуть.

Preview-среды лгут

Preview-среды усугубляют проблему. Они полезны для ручной проверки, но это не продакшн. Они часто указывают на api-staging вместо реального хоста API. Feature flags всегда возвращают true для каждого эксперимента, скрывая условную логику, которую выполняет продакшн. Аутентификация может пропускать шаг или внедрять мок-токен. Куки могут использовать упрощенные политики. Набор данных может быть лишь малой частью — десять строк вместо десяти тысяч, а это значит, что логика пагинации, ранжирования поиска или виртуализации никогда не будет протестирована.

Если ваш тест проходит по preview-URL, но падает в продакшне (или наоборот), проблема не в тесте. Проблема в среде.

Сначала логируйте, потом гадайте

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

Логируйте очевидных «подозреваемых». Записывайте URL страницы в момент сбоя, ID сборки и commit SHA. Отмечайте активные feature flags. Фиксируйте хост API, точную версию браузера и размер вьюпорта. Эти детали превращают таинственную ошибку в воспроизводимое условие.

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

Также помните, что открытие DevTools меняет тайминги. DevTools может откладывать сборку мусора (garbage collection), изменять приоритезацию сети и отключать определенные оптимизации рендеринга. Тест, который проходит, пока вы инспектируете DOM, может упасть в тот момент, когда вы закроете панель и запустите его в headless-режиме. Отладчик — полезный инструмент, но он не является нейтральным наблюдателем.

Воссоздайте место преступления

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

Build the exact artifact that CI produced. Download it if you must. Serve that artifact locally with a simple static file server, not with Vite or Webpack dev middleware. Use the same environment variables that CI injected. Match the browser version precisely. Run it in the same mode, headed or headless, because focus events, media queries, and autoplay policies still diverge between the two in subtle ways. If your CI uses a Docker container, run the same image locally. Remove your personal browser profile entirely.

When the local reproduction finally fails, you have a real debugging session. Until then, you are chasing shadows.

Stop Sleeping, Start Waiting

The most common response to a flaky browser test is to add delay. Wait five seconds. Wait ten. This is not a fix. It is a surrender. Arbitrary delays slow your suite, create false confidence, and still fail under load when the network hiccups.

Instead, wait for evidence of state. If a notification should appear after a form submission, do not wait for time to pass. Wait for a specific notification ID to exist in the DOM. If a counter should increment, wait for the text to change values. If a loading state blocks interaction, wait for the loading marker to disappear. If you are working with a WebSocket or server-sent events, wait for the network stream to produce a specific event.

Explicit waits turn your test from a guessing game into a contract. The test says: "I will proceed once the application confirms it is ready." That is far stronger than saying, "I will proceed once enough seconds have passed."

Hydration and the Disappearing Button

In modern React applications, hydration causes a specific class of failures that local dev servers often mask. The server sends HTML. React boots up in the browser and attaches event listeners. During that window, your test might click a button. React then replaces or restructures that DOM node during hydration. The element handle your test framework was holding now points to a detached node, and you get an error about interacting with a removed element.

The fix is not to write a more complex selector that digs deeper into the component tree. The fix is to look for readiness signals. Wait until a root element gains a hydrated attribute or a known data property. Wait for a skeleton loader to disappear. Wait for a client-side event handler to become active. Let the application announce that it is stable before you fire clicks.

Hidden Culprits: Dependencies and Third-Party Scripts

Sometimes the environment changes even though your application code did not. A transitive update in a small utility library, three levels deep in your node_modules, can alter browser behavior. It might change how promises resolve, how styles get injected, or how mocks intercept requests. When tests begin failing after a routine dependency update, record your package manager version and the lockfile checksum. You need to know whether you are looking at the same tree you were last week.

Third-party scripts are another frequent saboteur. Analytics trackers, payment SDKs, and chat widgets load asynchronously. They inject iframes, shift layout, or steal focus at moments your test does not expect. In CI, these scripts might load more slowly, or they might fail to load entirely because of network restrictions, causing your application to follow a different error-handling path. Log which third-party resources loaded and their HTTP status. If a payment iframe takes three seconds to mount in CI but loads instantly on your fast local connection, your "element not clickable" error suddenly has a clear cause.

And "element not clickable" is never a diagnosis. It is a symptom. Treat the cause.

Build an Evidence Kit

Every CI failure should be actionable. A stack trace alone is not enough. You need an evidence kit that lets another engineer, or yourself next month, reconstruct what happened.

Keep screenshots and video recordings from the failing run. Capture the full browser console output, not just errors but warnings too. Log network failures, including 404s, CORS rejections, and dropped connections. Preserve the build IDs and feature flags that were active. Take a DOM snapshot at the exact moment the assertion failed. A snapshot lets you inspect the HTML structure after the fact, rather