Skip to main content
This guide covers best practices for selectors, waits, test isolation, and environment consistency. Apply these patterns to avoid flaky tests from the start.

Quick Reference

ProblemSolution
Element timingWait on UI or API state with expect()
Unstable selectorsUse data-testid or getByRole
Shared stateCreate and clean up data per test
AnimationsDisable via CSS or prefers-reduced-motion
External APIsMock responses with page.route()
Test order dependenciesRun tests independently with fullyParallel

Wait for Elements Properly

Playwright auto-waits for elements, but some scenarios require explicit handling.
// Wait for loading indicator to disappear
await expect(page.locator('.loading')).toBeHidden();
await page.click('.submit-button');

// Wait for network idle after navigation
await page.goto('/dashboard', { waitUntil: 'networkidle' });

// Wait for specific API response
await Promise.all([
  page.waitForResponse(resp => resp.url().includes('/api/data')),
  page.click('.load-data')
]);
Avoid networkidle for apps with long polling or analytics. Prefer UI based waits.
Avoid fixed timeouts. They cause slow tests and still fail intermittently:
// Bad: arbitrary delay
await page.waitForTimeout(2000);

// Good: wait for specific condition
await expect(page.locator('.data-loaded')).toBeVisible();

Use stable selectors

Fragile selectors are the most common cause of flaky tests. Prefer selectors that survive UI changes:
// Good: test IDs are stable
page.getByTestId('submit-button')

// Good: accessible roles
page.getByRole('button', { name: 'Submit' })

// Good: text content
page.getByText('Submit Order', { exact: true })

// Avoid: CSS classes that may change
page.locator('.btn-primary-v2')

// Avoid: complex CSS paths that break with layout changes
page.locator('div > div:nth-child(2) > button')
Test IDs give you control over selectors. They do not break when CSS or DOM structure changes.

Isolate test data

Each test creates its own data and cleans up after:
test.beforeEach(async ({ request }) => {
  // Create test user with unique email
  const response = await request.post('/api/test/users', {
    data: { email: `test-${Date.now()}@example.com` }
  });
  testUser = await response.json();
});

test.afterEach(async ({ request }) => {
  // Clean up test data
  await request.delete(`/api/test/users/${testUser.id}`);
});
Avoid relying on shared database state or data from other tests.

Handle animations

Animations cause tests to interact with elements mid-transition. Disable them in test environments:
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    launchOptions: {
      args: ['--force-prefers-reduced-motion']
    }
  }
});
Works only if your app respects the prefers-reduced-motion media query.

Mock external dependencies

External APIs introduce variability. Mock them for consistent results:
await page.route('**/api/products', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Widget' }])
  });
});
Mocking gives you consistent data, faster tests, and no dependency on external service health.

Run tests in isolation

Avoid test order dependencies:
// playwright.config.js
export default {
  // Randomize test order to catch dependencies
  fullyParallel: true,
  
  // Allow tests to run independently in parallel
  workers: process.env.CI ? 4 : undefined
}

Handle Dynamic Content

For timestamps, random IDs, or changing data:
// Use regex for dynamic values
await expect(page.locator('.order-id')).toHaveText(/ORD-\d+/);

Configure Retries

Retries mask flakiness but improve CI stability:
// playwright.config.js
export default {
  retries: process.env.CI ? 2 : 0,
  
  // Record traces on retry for debugging
  use: {
    trace: 'on-first-retry'
  }
}
Use TestDino to track tests that need retries. High retry rates indicate tests that need fixing.

Fail on flaky tests

Starting with Playwright v1.52, you can configure the runner to exit with an error if any test is marked flaky.
// playwright.config.ts
export default defineConfig({
  failOnFlakyTests: Boolean(process.env.CI),
});
Or use the CLI flag:
npx playwright test --fail-on-flaky-tests
This ensures flaky tests block your CI pipeline instead of passing silently.

Use consistent environments

use: {
  viewport: { width: 1280, height: 720 }
}
jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.56.0-jammy
Web fonts load asynchronously. Wait for them or use system fonts in test environments.
await page.evaluate(async () => {
  await document.fonts.ready;
});

Monitor flaky rate

Use TestDino Analytics to track flaky rate over time. Set a team goal and review it regularly. When flaky rate spikes after a deployment, check what changed.

Create tickets

When you find a flaky test in TestDino, create a ticket:
1

Open the test case

Navigate to the flaky test case in TestDino
2

Raise an issue

Click Raise Bug or Raise Issue
3

Select your tool

Select Jira, Linear, Asana, or Monday
Tickets create accountability. They get assigned and fixed.
Need help? Reach out on Discord or email [email protected].