C.W.K.
Stream
Lesson 04 of 04 · published

Anti-Flake Patterns — Trust Your Suite

~16 min · playwright-locators, flakiness, anti-pattern

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Flake isn't a Playwright problem. It's a design problem your test is making visible."

The Five Common Causes of Flake

  1. Selectors that match multiple elements when you assume one. Two buttons named 'Save,' two list items with the same text — your locator silently matches the first one until layout changes and it matches a different first one.
  2. Test order dependencies. Test A creates a user; test B asserts the user count is 1. Run B alone and it fails; run A then B and it passes; run them in parallel and it's a coin flip.
  3. Sleeping instead of asserting on state. Covered in the last lesson. A guessed duration is a race waiting to happen.
  4. Time / date dependence. A test that filters 'today's items' breaks at midnight UTC. Same as your code — if the test uses real time, you'll see midnight bugs.
  5. Real network calls. A test that hits a real API depends on that API being up, fast, and deterministic. None of those are usually true.

The Antidotes — Test Isolation

Every Playwright test gets its own browser context by default — separate cookies, separate localStorage, separate cache. That's the foundation of isolation. But isolation only goes as deep as the test makes it: if your test creates a database row, that row stays unless something cleans it up.

The clean pattern: every test sets up its own state and tears it down (or works against a database snapshot that resets between tests). Don't test against a 'fixture' that other tests also write to.

The Antidotes — Deterministic Time and Data

Pin time and data when the test depends on them. Playwright has page.clock.install() for fixing wall-clock time inside the page (similar to Vitest's useFakeTimers). MSW (next track) covers the network side — every API response is yours to control.

The Antidotes — Specific Locators

When a locator matches more than one element, your test is implicitly choosing 'the first' — which is whatever the DOM happens to render first today. Make the selector specific enough that there's exactly one match:

  • Add the parent scope: page.getByRole('navigation').getByRole('link', { name: 'Home' }) not just page.getByRole('link', { name: 'Home' }).
  • Use filter({ hasText }) for content-based disambiguation inside lists.
  • Use the { exact: true } option when text-matching: getByText('Save', { exact: true }) doesn't match 'Save and continue.'
Treat every flake as a design signal, not a transient annoyance. The instinct is to bump retries from 2 to 5 and move on. The discipline is to ask: what was racing? What state was shared? What selector was ambiguous? Each flake fixed properly removes a class of bug; each one bandaged with retries breeds more flakes.

The Antidotes — Investigation Tools

When a test does flake, the trace viewer is your first stop. It shows:

  • The DOM at the moment the locator was queried — was the element there at all?
  • The network requests in flight — did the API respond differently?
  • The action timeline — did the click land on the right element?

Enable trace: 'on-first-retry' so the trace is captured automatically on the retry, then debug from there.

The Flake Triage

When you see a flake, ask in order:

  1. Does the locator match multiple elements? (Open the trace, look at the DOM.)
  2. Is the test depending on state from another test? (Run in isolation; if it passes, you've found it.)
  3. Is there a waitForTimeout or a missing assertion before an interaction? (See lesson 7.3.)
  4. Does the test depend on time or real network? (Pin time, mock network.)

Code

Disambiguating an ambiguous locator·typescript
// Flake source #1: ambiguous selector
// ❌ This might match either Sign-in button, depending on render order.
await page.getByRole('button', { name: 'Sign in' }).click();

// ✅ Scope it to the specific region.
await page
  .getByRole('navigation')
  .getByRole('button', { name: 'Sign in' })
  .click();

// ✅ Or use exact text + filter.
await page.getByRole('button', { name: 'Sign in', exact: true })
          .filter({ hasNotText: 'with Google' })
          .click();
Test isolation — reset + seed per test·typescript
// Flake source #2: test order dependence
import { test, expect } from '@playwright/test';

// ❌ This test assumes there are exactly N users — depends on what other tests left behind
test('lists three users', async ({ page }) => {
  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

// ✅ Reset state at test start
test('lists exactly the users we created', async ({ page, request }) => {
  // Reset the database via a test-only endpoint
  await request.post('/api/test/reset');

  // Seed exactly what this test needs
  await request.post('/api/test/seed', {
    data: { users: [{ name: 'Alice' }, { name: 'Bob' }] },
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(2);
});
Pin time with page.clock·typescript
// Flake source #4: time dependence
import { test, expect } from '@playwright/test';

test('shows today\'s items', async ({ page }) => {
  // Pin the wall clock so 'today' is deterministic.
  await page.clock.install({ time: new Date('2026-05-25T12:00:00Z') });

  await page.goto('/items?filter=today');
  await expect(
    page.getByRole('heading', { name: /items for may 25, 2026/i })
  ).toBeVisible();
});

// Advancing the clock during the test
test('reminder fires after one hour', async ({ page }) => {
  await page.clock.install({ time: new Date('2026-05-25T12:00:00Z') });
  await page.goto('/reminders');
  await page.getByRole('button', { name: 'Set 1-hour reminder' }).click();

  // Jump forward 1 hour
  await page.clock.fastForward('1:00:00');

  await expect(page.getByRole('alert', { name: /reminder/i })).toBeVisible();
});
Mock the network — deterministic responses·typescript
// Flake source #5: real network
// (Full network mocking is the next track. Quick preview:)
import { test, expect } from '@playwright/test';

test('handles the API response shape', async ({ page }) => {
  // Intercept the API call and return a deterministic response
  await page.route('**/api/users', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Pippa' },
        { id: 2, name: 'Dad' },
      ]),
    });
  });

  await page.goto('/users');
  await expect(page.getByRole('listitem')).toHaveCount(2);
  // The test no longer depends on a real backend.
});

External links

Exercise

Pick a Playwright test in your project that has flaked even once. Open its trace from a failed run (or rerun it 50 times with --repeat-each=50 to reproduce). Walk through the trace and answer: was the locator ambiguous, was state shared, was a wait missing, was time / network involved? Fix the actual cause — don't add a retry — and re-run 50 more times to confirm. Note what category of cause it was; that's the one you'll see again.
Hint
If you can't reproduce the flake locally even with 50 runs, the cause might be CI-specific (slower CPU, different network). Run in CI with --repeat-each and trace enabled — the trace from the CI failure is your only debug input.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.