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)
13. Search results that lag the input
The box waits for you to stop typing, then fetches. In flaky mode responses come back out of order, so for a moment the list can show results for an older query. Counting rows right after typing counts the wrong list.
How to handle it
// ✗ fragile — reads the list before it caught up
await page.getByTestId('search-input').fill('clem');
expect(await page.getByTestId('search-results').locator('li').count()).toBe(2);
// ✓ assert the final content; the assertion retries until it holds
await page.getByTestId('search-input').fill('clem');
await expect(page.getByTestId('search-results').locator('li'))
.toHaveText(['Clementine Bauch', 'Clementina DuBuque']);
// ✓ or wait for the app to say which query it is showing
await expect(page.getByTestId('search-results')).toHaveAttribute('data-query', 'clem');
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.