Skip to main content
Selenium intermediate Lesson 7 of 10

Flaky Tests: Staleness, Retries, and CI

Fix StaleElementReferenceException properly, find the five causes of tests that only fail in CI, and use retries as a signal rather than a cure.

A flaky test is one whose result depends on timing rather than on the code. Selenium suites attract them because everything is asynchronous and nothing waits by default. This lesson is the five causes worth knowing and what to do about each.

Cause 1: stale elements

with webdriver.Chrome(options=opts) as driver:
    driver.get("https://demo.playwright.dev/todomvc")
    box = driver.find_element(By.CLASS_NAME, "new-todo")
    box.send_keys("buy milk", Keys.ENTER)

    rows = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
    box.send_keys("walk the dog", Keys.ENTER)          # React re-renders the list
    print(rows[0].text)
selenium.common.exceptions.StaleElementReferenceException: Message: stale element
reference: stale element not found in the current frame
  (Session info: chrome=133.0.6943.16)

The handle pointed at a DOM node React threw away. Three fixes, in order of preference.

Re-find at the point of use. Store the locator, not the element:

ITEMS = (By.CSS_SELECTOR, ".todo-list li")

    box.send_keys("walk the dog", Keys.ENTER)
    print(driver.find_elements(*ITEMS)[0].text)
buy milk

Ignore staleness in the wait, so a mid-poll re-render is retried rather than fatal:

wait = WebDriverWait(driver, 10,
                     ignored_exceptions=(StaleElementReferenceException,
                                         NoSuchElementException))
print(wait.until(lambda d: d.find_element(By.XPATH, "//label[text()='buy milk']")).text)
buy milk

Retry the operation when even that is not enough — a click that triggers the re-render that invalidates the element you clicked:

from functools import wraps

def retry_on_stale(attempts=3, delay=0.2):
    def decorate(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            for attempt in range(1, attempts + 1):
                try:
                    return fn(*args, **kwargs)
                except StaleElementReferenceException:
                    if attempt == attempts:
                        raise
                    print(f"  stale on attempt {attempt}, retrying")
                    time.sleep(delay)
        return wrapper
    return decorate


@retry_on_stale()
def complete(driver, title):
    row = driver.find_element(By.XPATH, f"//label[text()='{title}']/ancestor::li")
    row.find_element(By.CSS_SELECTOR, ".toggle").click()

complete(driver, "buy milk")
print("completed")
  stale on attempt 1, retrying
completed

The retry re-runs the whole function, including the lookup — retrying only the click would reuse the same dead handle.

Cause 2: waiting for the wrong thing

    driver.find_element(By.LINK_TEXT, "Active").click()
    print(driver.find_elements(By.CSS_SELECTOR, ".todo-list li")[0].text)
buy milk

That is the old list — the click returned before the filter applied, and the assertion read the pre-filter DOM. It passes about half the time.

Wait for the change, not for an element that was already there:

    old_rows = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
    driver.find_element(By.LINK_TEXT, "Active").click()
    wait.until(EC.staleness_of(old_rows[0]))
    print([e.text for e in driver.find_elements(By.CSS_SELECTOR, ".todo-list li label")])
['walk the dog']

EC.staleness_of is the underused condition here: it waits for the old DOM to be discarded, which is the only reliable signal that a re-render happened. EC.url_contains and waiting for a specific expected value work too.

Cause 3: animations

    driver.find_element(By.ID, "open-modal").click()
    driver.find_element(By.ID, "confirm").click()
selenium.common.exceptions.ElementClickInterceptedException: Message: element click
intercepted: Element <button id="confirm">...</button> is not clickable at point (640, 400).
Other element would receive the click: <div class="modal-backdrop fade">...</div>

The modal is mid-fade — present, not yet in place. element_to_be_clickable is not always enough, because an element can be visible and enabled while still moving.

    wait.until(EC.element_to_be_clickable((By.ID, "confirm"))).click()

If that still flakes, wait for the animation to finish:

def animation_done(locator):
    def predicate(driver):
        el = driver.find_element(*locator)
        first = el.rect
        time.sleep(0.1)
        return first == el.rect and el.is_displayed()
    return predicate

    wait.until(animation_done((By.ID, "confirm")))
    driver.find_element(By.ID, "confirm").click()
confirmed

Or remove the variable entirely in the test environment:

driver.execute_script("""
  const style = document.createElement('style');
  style.textContent = '*, *::before, *::after { transition: none !important;
                        animation: none !important; }';
  document.head.appendChild(style);
""")
animations disabled for this page

Worth doing suite-wide. Animations are never what the test is about, and they are a large share of intermittent click failures.

Cause 4: tests that share state

def test_a_adds_todo(driver):
    page = TodoPage(driver).open().add("buy milk")
    assert page.titles == ["buy milk"]

def test_b_counts_todos(driver):
    page = TodoPage(driver).open()
    assert page.titles == []          # passes alone, fails after test_a
test_flaky.py::test_a_adds_todo PASSED                          [ 50%]
test_flaky.py::test_b_counts_todos FAILED                       [100%]

E   AssertionError: assert ['buy milk'] == []

TodoMVC persists to localStorage, so a shared driver carries state between tests. Run them in a different order and the failure moves — the classic signature of shared state.

A fresh driver per test fixes it, at the cost of startup time. Cheaper, if the driver is shared:

@pytest.fixture(autouse=True)
def clean_state(driver):
    yield
    driver.execute_script("window.localStorage.clear(); window.sessionStorage.clear();")
    driver.delete_all_cookies()
test_flaky.py::test_a_adds_todo PASSED                          [ 50%]
test_flaky.py::test_b_counts_todos PASSED                       [100%]

========================== 2 passed in 4.12s ==========================

Prove independence rather than assuming it:

pytest -p no:randomly test_flaky.py   # fixed order
pytest test_flaky.py --random-order   # shuffled, via pytest-random-order
Using --random-order-bucket=module
Using --random-order-seed=784201

test_flaky.py::test_b_counts_todos PASSED                       [ 50%]
test_flaky.py::test_a_adds_todo PASSED                          [100%]

Running the suite shuffled in CI turns “order-dependent” from a mystery into a failing build with a reproducible seed.

Cause 5: the environment

def test_shows_todays_date(driver):
    driver.get(APP)
    assert driver.find_element(By.ID, "today").text == date.today().isoformat()

Passes in London, fails on a CI runner in UTC-8 for four hours each evening. The same class of bug covers locale (£25.50 vs 25,50 €), viewport (a headless default of 800×600 hides elements behind a hamburger menu), and machine speed.

Pin all of it:

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1440,900")
options.add_argument("--lang=en-GB")
options.add_experimental_option("prefs", {"intl.accept_languages": "en-GB,en"})

driver = webdriver.Chrome(options=options)
driver.execute_cdp_cmd("Emulation.setTimezoneOverride", {"timezoneId": "Europe/London"})
driver.execute_cdp_cmd("Emulation.setLocaleOverride", {"locale": "en-GB"})
timezone and locale pinned; viewport 1440x900

A test that depends on the environment should say so explicitly rather than inheriting it.

Capture evidence on failure

A CI-only failure is unfixable without artefacts. Fifteen lines gets you all of them:

# conftest.py
import pytest, pathlib, datetime

@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    item.stash.setdefault("reports", {})[report.when] = report


@pytest.fixture
def driver(request):
    drv = webdriver.Chrome(options=opts)
    yield drv
    failed = any(r.failed for r in request.node.stash.get("reports", {}).values())
    if failed:
        out = pathlib.Path("artifacts") / request.node.name
        out.mkdir(parents=True, exist_ok=True)
        drv.save_screenshot(str(out / "screenshot.png"))
        (out / "page.html").write_text(drv.page_source, encoding="utf-8")
        (out / "url.txt").write_text(drv.current_url)
        try:
            logs = drv.get_log("browser")
            (out / "console.log").write_text("\n".join(f"{e['level']}: {e['message']}" for e in logs))
        except Exception:
            pass
        print(f"\nartifacts written to {out}")
    drv.quit()
test_todos.py::test_completes_a_todo FAILED

artifacts written to artifacts/test_completes_a_todo

E   selenium.common.exceptions.TimeoutException: Message: text never became
    '0 items left' in ('class name', 'todo-count')
cat artifacts/test_completes_a_todo/console.log
SEVERE: https://demo.playwright.dev/todomvc/main.js 412:19 Uncaught TypeError:
Cannot read properties of undefined (reading 'id')

The console log answers it — the application threw, so the counter never updated. The test was not flaky; the app was broken, and without the artefact this would have been filed as a wait problem and “fixed” with a longer timeout.

Upload the directory as a CI artifact and every failure comes with its own evidence.

Retries, and what they are for

pip install pytest-rerunfailures
pytest --reruns 2 --reruns-delay 1 -v
test_todos.py::test_adds_a_todo PASSED                          [ 33%]
test_todos.py::test_completes_a_todo RERUN                      [ 66%]
test_todos.py::test_completes_a_todo RERUN                      [ 66%]
test_todos.py::test_completes_a_todo PASSED                     [ 66%]
test_todos.py::test_filters_to_active PASSED                    [100%]

==================== 3 passed, 2 rerun in 12.44s =====================

Green, and test_completes_a_todo needed two attempts. That RERUN line is the value — treat it as a bug report, not a result.

Track the rate:

# conftest.py
def pytest_terminal_summary(terminalreporter):
    rerun = len(terminalreporter.stats.get("rerun", []))
    passed = len(terminalreporter.stats.get("passed", []))
    if rerun:
        print(f"\nFLAKE RATE: {rerun} rerun(s) across {passed} passing tests "
              f"({rerun / max(passed, 1):.1%})")
FLAKE RATE: 2 rerun(s) across 3 passing tests (66.7%)

Chart that number per run. A suite whose flake rate is climbing is degrading even while every build is green, and the day it crosses the retry count you get a “sudden” outbreak of failures that has actually been developing for months.

Quarantine rather than delete a test you cannot fix today:

@pytest.mark.flaky_quarantine
def test_intermittent_checkout(driver):
    ...
pytest -m "not flaky_quarantine"          # the blocking suite
pytest -m flaky_quarantine --reruns 3     # reported, not blocking
1 passed, 0 failed  (blocking suite)
1 failed             (quarantine — visible, not blocking the deploy)

Quarantine with an owner and a date. Without those it is deletion with extra steps.

Practice

1. Store an element, trigger a re-render, then use it.
selenium.common.exceptions.StaleElementReferenceException: Message: stale element
reference

Store the locator instead and it passes. “Find at the point of use” removes most staleness without any retry machinery.

2. Click a filter and assert immediately.
E   AssertionError: assert ['buy milk', 'walk the dog'] == ['walk the dog']

The assertion read the pre-filter DOM. EC.staleness_of(old_row) or waiting for the expected value fixes it; a sleep masks it until CI is busy.

3. Run two order-dependent tests in reverse.
Using --random-order-seed=784201
test_b_counts_todos PASSED
test_a_adds_todo PASSED

Passing in one order and failing in the other is the signature of shared state. Add the storage-clearing fixture and both orders pass.

4. Capture the browser console on failure.
SEVERE: main.js 412:19 Uncaught TypeError: Cannot read properties of undefined

The application threw — the test was reporting a real bug. Without the artefact this gets misdiagnosed as flakiness and “fixed” with a longer timeout.

Next: pytest integration — fixtures, parametrisation, parallel runs and reports.

Frequently Asked Questions

What causes StaleElementReferenceException?
You are holding a reference to a DOM node the page has since replaced. Frameworks re-render on every state change, so an element found before an update points at a detached node. Re-find the element at the moment of use rather than storing it.
Should I add retries to flaky Selenium tests?
Add them so one flake does not block a deploy, but treat every retry as a bug report. A suite whose flake rate is growing is degrading even while it stays green, and retries hide that until the failure rate crosses the retry count.
Why do tests pass locally but fail in CI?
CI machines are slower and more contended, so steps that happened to finish in time locally do not. Other common causes are a different viewport in headless mode, a different timezone or locale, and tests that share state and only fail in a different order.
How do I debug a failure I cannot reproduce locally?
Capture evidence automatically on failure: a screenshot, the page source, the browser console log and the current URL. A failure you can only see in CI is unfixable without artefacts, and the hook to save them is about fifteen lines.