Усе починається з повідомлення у Slack. Білд червоний. Ви гортаєте список помилок, хмуритеся і запускаєте той самий тест на своєму ноутбуці. Зелений. Ви пробуєте ще раз запустити CI-завдання. Можливо, це був просто збій. Але помилка повертається — вона вперта і стабільно відтворюється на сервері, але залишається непомітною для вас.
Тест браузера, який падає в CI, але проходить локально, — це більше, ніж просто роздратування. Він породжує недовіру. Команди починають звинувачувати таймінги. Вони впроваджують тимчасові виправлення, які залишаються назавжди. setTimeout тут, .wait(5000) там. Набір тестів уповільнюється. Помилки продовжують повертатися. Ці нестабільні тести стають постійною частиною системи, і зрештою всі починають сприймати червоний пайплайн як фоновий шум.
Це небезпечно. Вам не потрібен набір тестів, який «кричить про вовка».
CI не зламаний; він просто інший
Середовища CI не є випадковими. Вони детерміновані. Проблема в тому, що вони детерміновані щодо системи, яка не є вашим MacBook чи робочою станцією на Linux. Ваше локальне налаштування приховує відмінності, які чистий CI-раннер виявляє миттєво.
Подумайте про те, скільки рухомих частин розходяться. Ваша локальна машина може запускати сервер розробки з hot module reloading, тоді як CI збирає production-артефакт із tree shaking та мініфікацією. Це саме по собі може видалити шляхи коду або змінити порядок виконання. Дерева залежностей змінюються. Lock-файл, який виглядає ідентично, може розв'язатися інакше, якщо версія менеджера пакетів відрізняється хоча б на один мінорний реліз. Мережеві послідовності змінюються. Ваш офісний Wi-Fi може звернутися до staging API за один крок; CI-раннер може потрапити на інший кластер за балансувальником навантаження, що створює затримку, якої ви ніколи не бачите.
Самі браузери поводяться по-різному в різних середовищах. Ваш локальний Chrome має розширення, кешовані облікові дані, постійне локальне сховище та GPU з апаратним прискоренням. CI щоразу запускається з порожнім профілем. Життєві цикли браузера розходяться. Шляхи рендерингу розходяться. Шрифти, які є у вашій системі, замінюються в CI. Розміри viewport та коефіцієнти пікселів пристрою (device pixel ratios) різняться, що може змінити адаптивні брейкпоінти або змінити поведінку лінивого завантаження (lazy-loading).
Ці розриви реальні. Вони механічні. Прикидання, що вони випадкові, не змусить їх зникнути.
Preview-середовища брешуть
Preview-середовища посилюють проблему. Вони корисні для перевірки людиною, але вони не є production-середовищем. Вони часто вказують на api-staging замість справжнього хоста API. Feature flags повертають true для кожного експерименту, приховуючи умовну логіку, яку виконує production. Аутентифікація може пропускати крок або вводити mock-токен. Куки (cookies) можуть використовувати спрощені політики. Набір даних може бути лише невеликим зрізом — десять рядків замість десяти тисяч, а це означає, що логіка пагінації, ранжування пошуку або віртуалізації ніколи не перевіряється.
Якщо ваш тест проходить за preview-URL, але падає в production, або навпаки — помилка не в тесті. Вона в середовищі.
Логуйте, перш ніж гадати
Коли вперше з'являється помилка, стримайте інстинкт підправити тест і сподіватися на краще. Припиніть гадати. Вам потрібно зафіксувати контекст, щоб ви могли порівняти успішний запуск із невдалим.
Логуйте очевидних «підозрюваних». Записуйте URL сторінки в момент помилки, build ID та commit SHA. Відзначайте активні feature flags. Фіксуйте хост API, точну версію браузера та розмір viewport. Ці деталі перетворюють таємничу помилку на відтворювану умову.
Не покладайтеся лише на скриншоти. Дві сторінки можуть виглядати ідентично до пікселя, виконуючи при цьому зовсім інший JavaScript. Скриншот не підкаже вам, що CI-бандл містив додатковий polyfill або що локальний бандл пропустив чанк, бо він уже був у кеші вашого браузера.
Також пам'ятайте, що відкриття 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
