flaky.

A page built to break your UI tests.

Every element below misbehaves the way real apps do — it arrives late, moves, gets covered, re-renders or reshuffles. Point Playwright or Selenium at it, watch the naive test fail, then open the fix under each case.

Timing is seeded, so a failure reproduces: load /uiPlayground?seed=… and every case behaves the same way again. ?mode=stable takes every case at its fastest with no surprises — check your test is right there, then make it survive flaky.

1. An element that appears late

The greeting renders after a delay of up to four seconds, as if waiting on a session call. A test that looks for it once, straight after load, fails whenever the delay is long.

How to handle it
// ✗ fragile — checks once, at whatever moment the line runs
expect(await page.locator('[data-testid=late-message]').count()).toBe(1);

// ✓ Playwright — web-first assertions retry until the timeout
await expect(page.getByTestId('late-message')).toBeVisible();

# ✓ Selenium — an explicit wait, never time.sleep()
WebDriverWait(driver, 10).until(
    EC.visibility_of_element_located((By.CSS_SELECTOR, "[data-testid=late-message]")))

2. A button that is enabled late

Submit stays disabled until an async check finishes. Selenium clicks a disabled button without complaint — nothing happens, and the failure surfaces three steps later.

Checking the address…

Not submitted

How to handle it
// ✓ Playwright — click() already waits for enabled; assert it to make the intent visible
const submit = page.getByTestId('disabled-submit');
await expect(submit).toBeEnabled();
await submit.click();
await expect(page.getByTestId('disabled-result')).toHaveText('Submitted');

# ✓ Selenium — element_to_be_clickable checks visible AND enabled
WebDriverWait(driver, 10).until(
    EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-testid=disabled-submit]"))).click()

3. An overlay that swallows the click

The button is visible and enabled the whole time, but a loading veil sits on top of it for a while. Selenium throws ElementClickInterceptedException; a JavaScript click goes through and hides the bug from you.

Not paid

How to handle it
// ✓ Playwright — actionability checks wait until the button receives events
await page.getByTestId('overlay-pay').click();
// state it explicitly when the veil matters to the story
await expect(page.getByTestId('overlay-veil')).toBeHidden();

# ✓ Selenium — wait for the veil to go, then for the button
WebDriverWait(driver, 10).until(
    EC.invisibility_of_element_located((By.CSS_SELECTOR, "[data-testid=overlay-veil]")))
driver.find_element(By.CSS_SELECTOR, "[data-testid=overlay-pay]").click()

# ✗ never: driver.execute_script("arguments[0].click()", el) — no user could do that

4. A layout shift under the cursor

Just after the page settles, a second button is inserted above Confirm. A click at remembered coordinates — or on "the first button" — cancels the order instead.

Nothing chosen

How to handle it
// ✗ fragile — position and order are not identity
await page.locator('[data-testid=shift-buttons] button').first().click();
const box = await confirm.boundingBox(); /* …later… */ await page.mouse.click(box.x, box.y);

// ✓ find it by what the user reads, at the moment of the click
await page.getByRole('button', { name: 'Confirm order' }).click();
await expect(page.getByTestId('shift-result')).toHaveText('Order confirmed');

# ✓ Selenium
driver.find_element(By.XPATH, "//button[normalize-space()='Confirm order']").click()

5. A list that re-renders under you

The rows look identical, but in flaky mode they are thrown away and rebuilt every second or two, like a poll-and-replace table. A stored reference points at a node that is no longer in the page.

    No one selected

    Rendered 0 times

    How to handle it
    # ✗ Selenium — the handle is found once and reused after a re-render
    row = driver.find_element(By.XPATH, "//li[contains(., 'Ervin')]")
    time.sleep(2)
    row.find_element(By.TAG_NAME, "button").click()   # StaleElementReferenceException
    
    # ✓ Selenium — look it up inside the wait, so a stale lookup is simply retried
    WebDriverWait(driver, 10, ignored_exceptions=[StaleElementReferenceException]).until(
        lambda d: d.find_element(By.XPATH, "//li[contains(., 'Ervin')]//button").click() or True)
    
    // ✓ Playwright — a Locator re-resolves on every action; an ElementHandle does not
    await page.getByRole('listitem').filter({ hasText: 'Ervin' }).getByRole('button').click();
    // ✗ const handle = await page.$('li:has-text("Ervin") button');  — goes stale too

    6. Text that is already true too early

    The status reads "All changes saved" before you click Save — left over from last time. After the click it keeps saying so for a moment, then "Saving…", then "All changes saved" again. Asserting the text passes before anything was saved.

    All changes saved

    Saved 0 times

    How to handle it
    // ✗ passes immediately — the text was already there
    await page.getByTestId('text-save').click();
    await expect(page.getByTestId('text-status')).toHaveText('All changes saved');
    
    // ✓ wait for something only the new save can produce
    await page.getByTestId('text-save').click();
    await expect(page.getByTestId('text-count')).toHaveText('Saved 1 time');
    // or watch the transition: "Saving…" must appear, then disappear
    await expect(page.getByTestId('text-status')).toHaveText('Saving…');
    await expect(page.getByTestId('text-status')).toHaveText('All changes saved');

    7. A toast that is gone before you look

    The confirmation shows up after a short delay and stays for about a second and a half. Sleep two seconds "to be safe" and you have slept straight past it.

    How to handle it
    // ✗ fragile — a fixed sleep is both too long and too short
    await page.waitForTimeout(2000);
    expect(await page.getByTestId('toast').isVisible()).toBe(true);
    
    // ✓ start polling straight away; the assertion catches it while it is up
    await page.getByTestId('toast-copy').click();
    await expect(page.getByTestId('toast')).toHaveText('Link copied');
    await expect(page.getByTestId('toast')).toBeHidden();   // and that it goes away
    
    # ✓ Selenium — a short poll interval so a brief element is not missed
    WebDriverWait(driver, 5, poll_frequency=0.1).until(
        EC.text_to_be_present_in_element((By.CSS_SELECTOR, "[data-testid=toast]"), "Link copied"))

    8. A list in a different order every time

    Recommendations, search results, anything sorted by a score: the same items, never the same order. nth(2) is the keyboard on your machine and the monitor in CI.

      Cart is empty

      How to handle it
      // ✗ fragile — index is not identity
      await page.getByRole('listitem').nth(2).getByRole('button').click();
      
      // ✓ filter the row by its content, then act inside it
      await page.getByTestId('shuffle-list').getByRole('listitem')
        .filter({ hasText: 'Keyboard' }).getByRole('button', { name: 'Add' }).click();
      await expect(page.getByTestId('shuffle-result')).toHaveText('Cart: Keyboard');
      
      # ✓ Selenium
      driver.find_element(By.XPATH,
          "//*[@data-testid='shuffle-list']/li[contains(., 'Keyboard')]//button").click()

      9. IDs and classes that change every build

      CSS-in-JS and component libraries generate these. The recorder happily writes #btn-8f3a2 into your test, and the next deploy renames it.

      Not downloaded

      How to handle it
      // ✗ fragile — generated names
      await page.locator('#btn-8f3a2').click();
      await page.locator('.css-1x9kq2 > button').click();
      
      // ✓ in order of preference: role + name, a test id you own, then label/text
      await page.getByRole('button', { name: 'Download invoice' }).click();
      await page.getByTestId('dynid-button').click();
      
      # ✓ Selenium — a prefix match if the stable part is all you have
      driver.find_element(By.CSS_SELECTOR, "[id^='btn-']")      # last resort
      driver.find_element(By.CSS_SELECTOR, "[data-testid=dynid-button]")

      10. A target that is still moving

      The panel slides in over a second and a bit. Clicking the moment it exists hits the point where the button was when the click was computed.

      Unsaved changes

      Nothing discarded

      How to handle it
      // ✓ Playwright — click() waits for the element to be stable (same box two frames running)
      await page.getByTestId('animate-discard').click();
      
      // when the app exposes it, wait for the state it ends in
      await expect(page.getByTestId('animate-drawer')).toHaveAttribute('data-state', 'open');
      
      # ✓ Selenium — nothing waits for motion for you; wait for the end state
      WebDriverWait(driver, 5).until(lambda d: d.find_element(
          By.CSS_SELECTOR, "[data-testid=animate-drawer]").get_attribute("data-state") == "open")
      
      // ✓ or turn animation off for the whole run
      await page.emulateMedia({ reducedMotion: 'reduce' });   // this page honours it

      11. Content behind a flaky API

      This one makes a real call to /v1/users/1. In flaky mode it asks the API for up to two seconds of latency and, four times in ten, a 503 — so sometimes you get the user and sometimes the error state. A test that only knows about one of them is a coin toss.

      How to handle it
      // ✗ fragile — assumes the happy path
      await expect(page.getByTestId('network-name')).toHaveText('Leanne Graham');
      
      // ✓ decide the outcome yourself: the page forwards chaos controls to the API
      await page.goto('/uiPlayground?_status=503#network');
      await expect(page.getByTestId('network-error')).toBeVisible();
      await page.goto('/uiPlayground?_delay=3000#network');
      await expect(page.getByTestId('network-skeleton')).toBeVisible();
      
      // ✓ test the retry: fail twice, then recover (create the id with POST /v1/scenario)
      await page.goto('/uiPlayground?_scenario=' + id + '#network');
      await page.getByRole('button', { name: 'Retry' }).click();   // until it recovers
      
      // ✓ or intercept it in the test
      await page.route('**/v1/users/1*', r => r.fulfill({ status: 500, body: '{}' }));

      12. An item that only exists after you scroll

      Ten rows are loaded; ten more arrive after a delay whenever you reach the bottom. Row 27 is not hidden — it is not in the DOM at all until the third page loads, so no amount of waiting finds it.

        0 rows loaded

        How to handle it
        // ✓ Playwright — scroll, wait for the next page, repeat until it exists
        const feed = page.getByTestId('scroll-feed');
        const target = feed.getByText('Row 27', { exact: true });
        while (await target.count() === 0) {
          const before = await feed.locator('li').count();
          await feed.evaluate(el => el.scrollTo(0, el.scrollHeight));
          await expect(feed.locator('li')).not.toHaveCount(before);   // the next page arrived
        }
        await target.scrollIntoViewIfNeeded();
        
        # ✓ Selenium — the same loop
        feed = driver.find_element(By.CSS_SELECTOR, "[data-testid=scroll-feed]")
        while not driver.find_elements(By.XPATH, "//li[.='Row 27']"):
            n = len(feed.find_elements(By.TAG_NAME, "li"))
            driver.execute_script("arguments[0].scrollTo(0, arguments[0].scrollHeight)", feed)
            WebDriverWait(driver, 10).until(lambda d: len(feed.find_elements(By.TAG_NAME, "li")) > n)

        14. A button inside a shadow root

        Web components keep their markup in a shadow root. It shows up late here too, because the component registers after a delay. XPath cannot see into it at all.

        Not subscribed

        How to handle it
        // ✓ Playwright — CSS, role and text locators pierce open shadow roots already
        await page.getByRole('button', { name: 'Subscribe' }).click();
        // ✗ XPath does not: page.locator('//button[.="Subscribe"]') never matches
        
        # ✓ Selenium 4 — go through the host's shadow_root, inside a wait
        def subscribe_button(d):
            host = d.find_element(By.CSS_SELECTOR, "[data-testid=shadow-host]")
            return host.shadow_root.find_element(By.CSS_SELECTOR, "button")
        WebDriverWait(driver, 10, ignored_exceptions=[NoSuchShadowRootException]).until(subscribe_button).click()

        The rules underneath all fourteen

        Wait for a condition, never for a duration. A sleep is too short on a slow CI runner and wasted time everywhere else.

        Find elements by what the user perceives — role, label, text — or by a test id you own. Not position, not generated names.

        Look elements up at the moment you use them. A stored reference is a snapshot of a page that keeps changing.

        Wait for evidence that is new. If the thing you are waiting for could already be true, you are not waiting for anything.

        Choose the outcome instead of hoping for one. A test that depends on the network going one way is two tests pretending to be one — force each branch. That is what the flaky API is for.