← All posts
Best practices

Why your tests flake (and what actually fixes it)

A flaky test is one that passes and fails on the same code. It's worse than a failing test, because a failing test tells you something true. A flaky test trains your team to hit "re-run" until the bar turns green, and once that habit sets in, the suite stops meaning anything.

Flake is almost never random. It comes from a small set of root causes, and they're rankable by how often they bite. Here they are, worst first, with fixes that hold up.

1. Timing and implicit waits

This is the single largest source of flake in browser tests, and it's the one people reach for the worst fix on. The pattern looks like this:

await page.click('#checkout');
await page.waitForTimeout(2000); // "give it a second"
await expect(page.locator('.confirmation')).toBeVisible();

The waitForTimeout is a bet: two seconds is enough on my machine, today. On a loaded CI box, or after a backend deploy that added 300ms to the API, it isn't. So you bump it to 4000, the suite gets slower, and it still flakes under load. Fixed sleeps are simultaneously too long (in aggregate they add minutes) and too short (when it matters).

The fix is to wait for a condition, not a duration. Modern Playwright assertions already do this — expect(locator).toBeVisible() retries until the element is actually visible or the timeout expires. Delete the sleep entirely:

await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();

When you're waiting on something that isn't a DOM assertion — a network call to settle, a spinner to disappear — wait for that specific thing:

// Wait for the response you actually care about
const orderResponse = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Checkout' }).click();
await orderResponse;

// Or wait for the loading state to clear
await expect(page.getByRole('progressbar')).toBeHidden();

Rule of thumb: if you have a waitForTimeout anywhere in a test, treat it as a bug you haven't diagnosed yet.

2. Brittle selectors

The second big one is selectors that break when the markup shifts even though the behavior didn't. The usual offenders:

// Breaks when the DOM nests one level deeper
await page.click('div.container > div:nth-child(3) > button');
// Breaks when a CSS refactor renames the class
await page.click('.btn-primary-v2');
// Breaks when copy changes from "Submit" to "Place order"
await page.getByText('Submit').click();

The failure here is subtle because it looks like a real regression — the test goes red, someone investigates, and the app was fine all along. That's flake with extra steps, and it costs the same trust.

Anchor selectors to things that change only when the behavior changes. In order of preference: accessible roles and names, then explicit test IDs, then text as a last resort.

// Role + accessible name: survives layout changes, matches how users find things
await page.getByRole('button', { name: 'Place order' }).click();

// Explicit contract with the app, immune to copy and CSS churn
await page.getByTestId('checkout-submit').click();

Role-based locators have a second payoff: if getByRole('button', { name: 'Place order' }) can't find the button, that's often a real accessibility bug your users would hit too. The selector strategy and the correctness check are the same strategy.

3. Shared state between tests

Tests that pass alone and fail in a suite are almost always leaking state into each other. Test A creates a user and leaves them logged in; test B assumes a clean session and gets A's leftovers. Run them in a different order — or in parallel — and the result changes.

The fix is isolation by default. Each test gets its own browser context, and you never rely on ordering:

test.beforeEach(async ({ page }) => {
  // Fresh context per test; no cookies or storage carry over
  await page.goto('/');
});

test('guest can browse the catalog', async ({ page }) => {
  // Assumes nothing about login state
});

Playwright gives every test a fresh context already, which handles cookies and local storage. What it can't undo for you is server-side state: rows your test wrote to a shared database, a feature flag another test toggled, a queue it filled. If two tests mutate the same backend record, isolating the browser doesn't isolate the data. Which leads directly to the next cause.

4. Test data collisions

Two tests both create a user named test@example.com. The first passes. The second hits a uniqueness constraint, or worse, silently picks up the first test's record and asserts against stale data. In parallel runs this is nondeterministic — whichever test lands second loses.

Never hard-code data that has to be unique. Generate it per run:

const email = `user-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
await page.getByLabel('Email').fill(email);

For anything that must be globally unique across concurrent runs, a timestamp alone isn't enough — two parallel workers can hit the same millisecond. Combine a run identifier with a random suffix, or use a UUID. And clean up: create the data your test needs in setup, tear it down after, and don't assume a shared fixture is in the state you left it last time.

This is also where credentials belong out of the test body. Reference them by name so the same test runs against staging and production without edits — resolve secrets and config values at run time instead of baking literal values into the spec.

5. Environment drift

The quietest cause and the hardest to pin down: the test is fine, and the environment moved. A dependency updated, the CI browser version bumped, a seeded fixture changed, the API returned data in a new shape. The test that passed yesterday fails today and nobody touched it.

Drift fights back with reproducibility:

# CI: same versions every run
- run: npm ci
- run: npx playwright install --with-deps chromium

The broader move is to run tests in an environment you control end to end, rather than whatever the CI runner happens to have installed this week. Running each test in a clean, isolated session — nothing carried over from the last run, nothing installed by hand — removes a whole category of "works on my machine" drift. It's the same reason TestVibe runs every generated Playwright test in an isolated cloud session and attaches the screenshot, video, and Playwright trace for a failing scenario: when something does fail, you're debugging a real signal, not the runner's mood.

What auto-waiting does and doesn't solve

Playwright's auto-waiting is the reason cause #1 is fixable at all. Before every action it checks that the element is attached, visible, stable, enabled, and able to receive events — and web-first assertions like toBeVisible retry until they pass or time out. That eliminates the entire class of "clicked before it was ready" races without a single manual wait.

What it does not do:

Auto-waiting closes the biggest gap. The other four causes are yours to design out. Do that, and "re-run until green" stops being a workflow — a red bar means something again.

Want Playwright tests that are verified by actually running in a clean isolated session, with the full trace when they fail? Get early access, or read how generation works.

Early access

Ready for tests that write themselves?