A flaky test passes sometimes and fails sometimes, with no code change. Flaky tests are the biggest enemy of test automation: the team stops trusting the suite, failures get ignored, and one day a real bug slips through with everyone assuming "it's just flaky again".
The good news: flakiness is not bad luck. It has a short list of real causes, and each cause has a known fix.
Cause 1: Timing. The test is faster than the app
This is the number one cause. The app needs 800ms to load data; the test checks the result after 200ms; the test fails. On a faster day, it passes.
The wrong fix is a sleep:
await page.waitForTimeout(3000); // do not do this
Sleeps are a bet. On a slow day 3 seconds is not enough; on a fast day you wasted 3 seconds. A suite full of sleeps is both slow and flaky.
The right fix is waiting for a condition:
// Playwright: assertions retry automatically
await expect(page.getByTestId('toast')).toBeVisible();
// Cypress: should() retries automatically
cy.get('[data-testid="toast"]').should('be.visible');
// Selenium: use an explicit wait
await driver.wait(until.elementLocated(By.css('[data-testid="toast"]')), 5000);
Rule: wait for what you expect, never for an amount of time.
Cause 2: Bad selectors
A selector like this breaks whenever the layout changes:
page.locator('div > div:nth-child(3) > span.text-sm');
And a selector that matches several elements may click a different one depending on rendering order.
Fix:
- Prefer stable, user-facing locators: roles, labels,
data-testid. - Make selectors match exactly one element. If a locator can match many, pick one explicitly with
.first()or a filter, and understand why there are many.
page.getByTestId('product-card')
.filter({ hasText: 'Alpine Tent' })
.getByRole('button', { name: 'Add to cart' });
Cause 3: Shared state between tests
Test A creates a contact. Test B counts contacts and expects 8. Run them together: 9. Run B alone: 8. Random failures depending on order and parallelism.
Fix:
- Every test prepares its own data and cleans up, or the suite resets state before each test.
- Never assert on global counts that other tests can change; assert on the thing the test itself created.
- Do not share logins, carts or records between tests unless they are read-only.
Cause 4: Test order dependence
Related to shared state: a test only passes because an earlier test left the app on the right page or created the right data. The day someone runs tests in parallel or filters to one test, everything breaks.
Fix: each test starts from a known point — usually a fresh page load and its own setup in beforeEach. A quick check: run any single test alone. It must pass.
Cause 5: Environment differences
The test passes locally and fails in CI. Common reasons:
- Different screen size. An element is hidden behind a menu in CI's smaller window.
- Slower CI machines, timing problems that never appear on your laptop.
- Different data. The staging database changed.
- Animations. An element moves while the test clicks it.
Fix:
- Set an explicit viewport size in your test config.
- Fix timing with condition-based waits (see cause 1), they absorb slow machines automatically.
- Make tests create the data they need instead of expecting it to exist.
- Disable or shorten animations in the test environment when possible.
How to debug a flaky test
- Reproduce it: run the single test 20-50 times in a loop. If it fails 3 times, you can debug it; a failure you cannot reproduce you cannot fix.
- Read the failure precisely: which step, which locator, what was on the page? Screenshots and traces answer this.
- Look for the race: what did the test assume was already true at that moment? That assumption is usually the bug.
- Fix the cause, not the symptom: adding a retry or a sleep hides the problem and it will return.
Should you use automatic retries?
Test runners can retry failed tests automatically. Retries are acceptable as a safety net for rare infrastructure noise, but treat every retried test as a bug to investigate. If a test needs retries to pass regularly, it is broken. A retried pass must be visible in reports, not silent.
Best practices summary
- Wait for conditions, never for time.
- One test, one behavior, own data.
- Selectors: stable, user-facing, matching exactly one element.
- Any single test must pass when run alone.
- Fix flaky tests immediately, or delete them. An ignored red suite is worse than a smaller green one.
Practice these patterns in the the practice editor: the template apps have real loading delays, so lazy assertions genuinely fail, and condition-based waits genuinely fix them.