הכל מתחיל בהודעת Slack. ה-build אדום. אתם גוללים דרך הכשלים, מכווצים את המצח, ומריצים שוב את אותו הטסט על הלפטופ שלכם. ירוק. אתם מנסים שוב את משימת ה-CI. אולי זו הייתה תקלה חולפת. הכשל חוזר, עקשן וניתן לשחזור בשרת, אך בלתי נראה עבורכם.
בדיקת דפדפן שנכשלת ב-CI אך עוברת מקומית היא יותר מסתם מטרד. היא מטמיעה חוסר אמון. צוותים מתחילים להאשים בעיות תזמון. הם דוחפים תיקונים זמניים שלעולם לא עוזבים את הקוד. setTimeout פה, .wait(5000) שם. סדרת הבדיקות מאטה. הכשלים ממשיכים לחזור. בדיקות ה-flakiness הללו הופכות לחלק קבוע מהמערכת, ובסופו של דבר כולם מתחילים להתייחס ל-pipeline אדום כאל רעש רקע.
זה מסוכן. אתם לא רוצים סט בדיקות שצועק "זאב!".
ה-CI לא שבור; הוא פשוט שונה
סביבות CI אינן אקראיות. הן דטרמיניסטיות. הבעיה היא שהן דטרמיניסטיות לגבי מערכת שאינה ה-MacBook או תחנת העבודה הלינוקס שלכם. ההגדרה המקומית שלכם מסתירה הבדלים ש-CI runner נקי חושף באופן מיידי.
חשבו על כמה מרכיבים משתנים. המכונה המקומית שלכם עשויה להריץ שרת פיתוח עם hot module reloading, בעוד שה-CI בונה production artifact עם tree shaking ו-minification. זה לבדו יכול להסיר נתיבי קוד או לשנות את סדר הביצוע. עצי התלות (dependency trees) משתנים. lockfile שנראה זהה לחלוטין יכול להיפתר אחרת אם גרסת ה-package manager משתנה בגרסה מינורית אחת בלבד. רצפי הרשת משתנים. ה-Wi-Fi במשרד שלכם עשוי לפתור staging API בזינוק אחד; ה-CI runner עשוי לפגוש cluster אחר מאחורי load balancer, מה שמכניס שיהוי (latency) שאתם לעולם לא רואים.
הדפדפנים עצמם מתנהגים אחרת בסביבות שונות. ה-Chrome המקומי שלכם נושא תוספים, פרטי אימות (credentials) שמורים במטמון, local storage קבוע, ו-GPU עם האצת חומרה. ה-CI מתחיל מתוך פרופיל נקי בכל הרצה. מחזורי החיים של הדפדפן משתנים. נתיבי הרינדור משתנים. גופנים הקיימים במערכת שלכם מוחלפים ב-CI. גודל ה-viewport ויחסי הפיקסלים של המכשיר (device pixel ratios) משתנים, מה שיכול להפוך breakpoints של רספונסיביות או לשנות התנהגות של lazy-loading.
הפערים הללו אמיתיים. הם מכניים. ההתחזות לכך שהם אקראיים לא תגרום להם להיעלם.
סביבות Preview משקרות
סביבות Preview מחריפות את הבעיה. הן שימושיות לבדיקה אנושית, אך הן אינן production. לעיתים קרובות הן מפנות ל-api-staging במקום ל-API host האמיתי. feature flags מחזירים true עבור כל ניסוי, מה שמסתיר לוגיקה מותנית שמתבצעת ב-production. אימות (Authentication) עשוי לדלג על שלב או להזריק mock token. עוגיות (Cookies) עשויות להשתמש במדיניות מקלה. מערך הנתונים עשוי להיות דגימה דקה, עשר שורות במקום עשרת אלפים, מה שאומר שלוגיקת pagination, דירוג חיפוש או virtualization לעולם לא תיבדק.
אם הבדיקה שלכם עוברת מול URL של preview אך נכשלת ב-production, או להיפך, הבדיקה היא לא הבאג. הסביבה היא.
תתעדו (Log) לפני שאתם מנחשים
כשכשל מופיע לראשונה, התנגדו ליצר להקליט שינויים קטנים בטסט ולקוות לטוב. הפסיקו לנחש. אתם צריכים להקפיא את ההקשר (context) כדי שתוכלו להשוות הרצה שעברה מול הרצה שנכשלה.
תעדו את החשודים המיידיים. רשמו את ה-URL של הדף ברגע הכשל, את ה-build ID ואת ה-commit SHA. ציינו את ה-feature flags הפעילים. לכדו את ה-API host, את גרסת הדפדפן המדויקת ואת גודל ה-viewport. הפרטים הללו הופכים כשל מסתורי למצב שניתן לשחזור.
אל תסתמכו רק על צילומי מסך. שני דפים יכולים להיראות זהים לחלוטין בפיקסלים בעוד שהם מריצים JavaScript שונה לחלוטין. צילום מסך לא יגיד לכם שה-bundle של ה-CI כלל polyfill נוסף או שה-bundle המקומי דילג על chunk מסוים כי הוא כבר היה ב-cache של הדפדפן שלכם.
זכרו גם שפתיחת DevTools משנה את התזמון. DevTools יכולים לדחות garbage collection, לשנות תיעדוף רשת ולבטל אופטימיזציות רינדור מסוימות. בדיקה שעוברת בזמן שאתם בוחנים את ה-DOM עשויה להיכשל ברגע שתסגרו את הפאנל ותריצו אותה במצב headless. ה-debugger הוא כלי שימושי, אך הוא אינו צופה ניטרלי.
שחזרו את זירת הפשע
אם אתם רוצים לשחזר את הכשל בצורה מדויקת, אתם לא יכולים פשוט להריץ את שרת הפיתוח המקומי שלכם ולקוות לטוב. אתם צריכים לשחזר את התנאים המדויקים של ה-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
