Debugging

Diagnosing Flaky Locators in CI

A locator that passes locally and fails in the pipeline is almost never random. The six causes, how to tell them apart from the failure message alone, and the XPath changes that fix each.

debuggingciflaky-teststroubleshooting

Start from the message

MessageMost likely cause
NoSuchElementException / Timeout waiting for locator on the first action of a testtiming, viewport or environment difference
Same, but mid-test after an actionre-render race or navigation not awaited
StaleElementReferenceExceptionelement re-rendered between find and act
ElementNotInteractableException / element is not visiblehidden duplicate matched first, or animation
ElementClickInterceptedExceptionoverlay, toast or cookie banner covering the target
Playwright strict mode violationduplicate that only exists in CI’s data or viewport
Test passes on retrytiming or data, not the locator

Cause 1: viewport differences

Locally you run a 1920-pixel window; CI runs headless at 800x600. Responsive layouts render a different DOM: a hamburger menu instead of a nav bar, a card list instead of a table, a hidden desktop nav plus a visible mobile nav.

Tell-tale: the locator matches two elements in CI (strict mode violation) or matches a hidden one (not interactable).

Fix: set the viewport explicitly in CI to match local, and anchor locators to the variant you mean:

//nav[@aria-label='Main']//a[normalize-space()='Pricing']
//header//a[normalize-space()='Sign in'][not(ancestor::*[@data-testid='mobile-menu'])]

Cause 2: timing and re-renders

Frameworks render a skeleton, then data, then re-render on state change. A find that runs between two renders gets a node that is about to be replaced.

Tell-tale: StaleElementReference, or an action that “did nothing” because it hit the old node.

Fix: find immediately before acting; in Selenium re-find inside a retry, in Playwright use locators (which re-resolve) and avoid holding ElementHandles. Also anchor to something that renders once. Rows re-render; the table does not.

Cause 3: data differences

CI seeds a database with different data than your local environment: more rows, different names, another user with the same first name.

Tell-tale: duplicate matches, or text-based locators that fail because the text is different.

Fix: make the test own its data (create the entity it will click), and match on the created entity’s unique attributes rather than shared text:

//tr[@data-testid='user-row'][.//td[normalize-space()='qa-user-8f2a@example.test']]//button[@aria-label='Edit']

Cause 4: overlays and animations

Cookie banners, onboarding tours, toasts and loading spinners appear in fresh CI browsers and not in your logged-in local session.

Tell-tale: ElementClickIntercepted, or a click that lands on the overlay.

Fix: dismiss known overlays at the start, wait for spinners to disappear, and in Playwright let auto-wait handle animations. XPath for the wait:

//*[@role='progressbar']                  wait until count is 0
//*[@data-testid='cookie-banner']//button[normalize-space()='Accept']

Cause 5: locale and copy

CI runs with a different Accept-Language, or the app’s copy changed and your local build is stale.

Tell-tale: text locators fail; attribute locators pass.

Fix: pin the locale in the browser context, prefer attribute and role locators for controls, and keep text for assertions rather than navigation where it varies.

Cause 6: the locator was always fragile

An index or absolute path that happened to work locally. CI renders one extra banner, and //div[3]/button now points at something else.

Tell-tale: consistent failure after a deploy, not intermittent.

Fix: rewrite with an anchor. The robustness improver and the rules on the cheat sheet cover this.

A CI-specific debugging routine

  1. Capture a screenshot and the DOM (page.content() / driver.getPageSource()) on failure. Attach both to the report.
  2. Load the saved DOM into the browser locally and test the locator with Ctrl+F in DevTools. If it matches there, the cause is timing or interaction, not the expression.
  3. If it does not match, diff the saved DOM against a local one around the target. The difference is the cause.
  4. Run the test alone, then in the suite. Suite-only failures point to shared state or leftover overlays from a previous test.
  5. Fix the root cause, not the retry count.

What retries hide

Retrying a test that fails one time in ten makes the suite green and leaves the flake in place. Use retries to keep pipelines moving, but track which tests needed them and treat a retry as a bug report. A flaky locator is usually a fragile locator that has not failed consistently yet.