Skip to content

Fix: Playwright Not Working — Test Timeout, Selector Not Found, or CI Headless Fails

FixDevs ·

Quick Answer

How to fix Playwright test issues — locator strategies, auto-waiting, network mocking, flaky tests in CI, trace viewer debugging, and common headless browser setup problems.

The Problem

A test times out waiting for an element:

await page.click('button.submit');
// TimeoutError: Locator.click: Timeout 30000ms exceeded.
// waiting for locator('button.submit')

Or tests pass locally but fail in CI:

# Local: all tests pass
# CI: Error: browserType.launch: Failed to launch chromium
# Missing dependencies: libnss3 libnss3-tools libatk1.0-0 ...

Or network requests aren’t intercepted by mock handlers:

await page.route('**/api/users', route => {
  route.fulfill({ json: [{ id: 1, name: 'Alice' }] });
});

// Real network request still fires — mock ignored

Or a test is flaky — passes sometimes, fails sometimes:

Test: "should display user list"
Run 1: PASS
Run 2: FAIL — Expected "Alice" but found ""
Run 3: PASS

Why This Happens

Playwright’s auto-waiting and locator system have specific requirements:

  • Locators are lazypage.locator('button') doesn’t query the DOM immediately. The query happens when you perform an action. If the element isn’t in the DOM yet, Playwright waits up to the timeout.
  • Strict mode by default — if a locator matches multiple elements, Playwright throws a “strict mode violation” error. CSS selectors that match multiple elements fail; use more specific locators.
  • CI needs browser dependencies — Playwright downloads its own browser binaries, but those binaries require system libraries (libnss3, libatk, etc.). CI containers often don’t have these pre-installed.
  • Network route matching requires the full URLpage.route('**/api/users', ...) uses glob patterns. If the actual URL is https://api.example.com/v1/users, the pattern **/api/users won’t match.

Fix 1: Use Reliable Locators

Prefer semantic locators over CSS selectors and XPath:

// FRAGILE — breaks when class names change
await page.click('.btn-primary');
await page.click('#user-form > button:nth-child(2)');

// BETTER — role-based (matches accessibility tree)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
await page.getByRole('heading', { level: 1 }).waitFor();

// BETTER — test ID (stable, explicit)
// <button data-testid="submit-btn">Submit</button>
await page.getByTestId('submit-btn').click();

// BETTER — label text
await page.getByLabel('Email address').fill('[email protected]');

// BETTER — visible text
await page.getByText('Welcome back, Alice').waitFor();
await page.getByText('Submit order').click();

// BETTER — placeholder
await page.getByPlaceholder('Search users...').fill('alice');

// Combining locators for precision
await page.getByRole('listitem')
  .filter({ hasText: 'Alice' })
  .getByRole('button', { name: 'Delete' })
  .click();

Configure data-testid attribute name:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-cy',  // Use data-cy instead of data-testid
  },
});

Fix 2: Handle Auto-Waiting Correctly

Playwright auto-waits for elements to be visible, enabled, and stable before acting:

// NO need for explicit waits in most cases — Playwright handles it
await page.getByRole('button', { name: 'Load Data' }).click();
await page.getByText('Alice Johnson').waitFor();  // Auto-waits by default

// When you DO need explicit waits
await page.waitForLoadState('networkidle');  // Wait for all network activity to finish
await page.waitForLoadState('domcontentloaded');
await page.waitForURL('**/dashboard');  // Wait for navigation to complete

// Wait for a specific element state
await page.getByRole('progressbar').waitFor({ state: 'hidden' });  // Wait to disappear
await page.getByRole('dialog').waitFor({ state: 'visible' });

// Wait for a network request
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Refresh' }).click();
const response = await responsePromise;
const users = await response.json();

// Wait for an arbitrary condition
await page.waitForFunction(() => {
  return document.querySelectorAll('.user-card').length > 0;
});

// Increase timeout for slow operations
await page.getByRole('button', { name: 'Generate Report' }).click();
await page.getByText('Report ready').waitFor({ timeout: 60_000 });

Avoid these anti-patterns:

// AVOID — arbitrary sleep, makes tests slow and still flaky
await page.waitForTimeout(2000);

// AVOID — checking the DOM yourself before acting
const isVisible = await page.isVisible('.submit-btn');
if (isVisible) await page.click('.submit-btn');
// Just do: await page.getByRole('button', { name: 'Submit' }).click();

// AVOID — asserting before waiting
const text = await page.textContent('.result');  // May return empty string
expect(text).toBe('Success');  // Flaky!

// CORRECT — use expect with auto-retry
await expect(page.getByRole('status')).toHaveText('Success');
// expect.toHaveText automatically retries until the assertion passes

Fix 3: Install Browser Dependencies in CI

# GitHub Actions — install Playwright with browsers
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      # Install Playwright browsers AND system dependencies
      - run: npx playwright install --with-deps chromium
      # For all browsers:
      # - run: npx playwright install --with-deps

      - run: npx playwright test

      # Upload test results and traces on failure
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7
# playwright.config.ts — CI-specific settings
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,   // Don't allow .only in CI
  retries: process.env.CI ? 2 : 0, // Retry flaky tests in CI
  workers: process.env.CI ? 1 : undefined,  // Single worker in CI
  reporter: process.env.CI
    ? [['github'], ['html', { open: 'never' }]]
    : [['html']],

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',  // Capture trace on retry (for debugging CI failures)
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    // Only run other browsers in CI or explicitly:
    ...(process.env.CI ? [
      { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
      { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    ] : []),
  ],

  // Start the app before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Fix 4: Mock Network Requests Correctly

import { test, expect } from '@playwright/test';

test('mocked API call', async ({ page }) => {
  // Route must be set up BEFORE navigation
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      json: [
        { id: 1, name: 'Alice', email: '[email protected]' },
        { id: 2, name: 'Bob', email: '[email protected]' },
      ],
    });
  });

  await page.goto('/users');
  await expect(page.getByText('Alice')).toBeVisible();
  await expect(page.getByText('Bob')).toBeVisible();
});

// Intercept and modify real response
test('modify API response', async ({ page }) => {
  await page.route('**/api/user/profile', async route => {
    const response = await route.fetch();  // Make the real request
    const json = await response.json();

    // Modify the response
    await route.fulfill({
      response,
      json: { ...json, name: 'Modified Name' },
    });
  });

  await page.goto('/profile');
  await expect(page.getByText('Modified Name')).toBeVisible();
});

// Simulate error
test('handles API error', async ({ page }) => {
  await page.route('**/api/users', route => {
    route.fulfill({ status: 500, body: 'Internal Server Error' });
  });

  await page.goto('/users');
  await expect(page.getByRole('alert')).toContainText('Failed to load users');
});

// Abort specific requests
test('blocks analytics', async ({ page }) => {
  await page.route('**/analytics/**', route => route.abort());
  await page.goto('/dashboard');
  // Analytics requests are blocked, page loads normally
});

// Pass through everything except specific endpoints
await page.route('**/*', route => route.continue());
await page.route('**/api/**', route => {
  // Handle only API calls
});

Fix 5: Debug Flaky Tests with Traces

// playwright.config.ts
export default defineConfig({
  use: {
    trace: 'on-first-retry',  // Capture on retry
    // trace: 'on',            // Always capture
    // trace: 'retain-on-failure',  // Keep only on failure
  },
});

// View trace:
// npx playwright show-trace trace.zip

Identify flakiness causes:

// Pattern 1: Race condition — element appears before data is loaded
// FLAKY:
await page.getByRole('button', { name: 'Load' }).click();
await expect(page.getByTestId('user-name')).toHaveText('Alice');  // May be empty briefly

// STABLE — wait for the loading indicator to disappear first
await page.getByRole('button', { name: 'Load' }).click();
await page.getByRole('progressbar').waitFor({ state: 'hidden' });
await expect(page.getByTestId('user-name')).toHaveText('Alice');

// Pattern 2: Animation causes click to miss
// FLAKY — clicking while element is animating in
await page.getByRole('button', { name: 'Menu' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();  // May miss if menu animating

// STABLE — wait for stable state
await page.getByRole('button', { name: 'Menu' }).click();
const menu = page.getByRole('menu');
await menu.waitFor();
await expect(menu).toBeVisible();  // Ensures stable
await page.getByRole('menuitem', { name: 'Settings' }).click();

// Pattern 3: Shared test state
// Each test gets a fresh browser context — no sharing needed
test('test A', async ({ page }) => { /* ... */ });  // Fresh page
test('test B', async ({ page }) => { /* ... */ });  // Another fresh page

Run tests with retries to identify flaky tests:

# Identify flaky tests
npx playwright test --retries=3 --reporter=html

# Run a specific test repeatedly
npx playwright test --repeat-each=10 tests/users.spec.ts

# Debug interactively
npx playwright test --debug tests/users.spec.ts
npx playwright codegen http://localhost:3000  # Record interactions as test code

Fix 6: Common Test Patterns

Page Object Model:

// tests/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('successful login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

test('invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'wrong');
  await expect(loginPage.errorMessage).toHaveText('Invalid email or password');
});

Authentication fixtures:

// tests/fixtures.ts
import { test as base } from '@playwright/test';

type Fixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<Fixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Log in once per test
    await page.goto('/login');
    await page.getByLabel('Email').fill('[email protected]');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');

    await use(page);  // Run the test

    // Cleanup after test
    await page.getByRole('button', { name: 'Sign out' }).click();
  },
});

// Faster: save auth state once and reuse
// npx playwright test --global-setup ./global-setup.ts
// global-setup.ts:
async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:3000/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await page.context().storageState({ path: 'auth.json' });
  await browser.close();
}

Still Not Working?

“Strict mode violation” — locator matches multiple elements — use .first(), .last(), .nth(n), or .filter() to narrow the locator, or make the selector more specific. page.getByRole('button') alone often matches multiple buttons; add { name: '...' } to target the right one.

Tests work in headed mode but fail headless — some sites detect headless browsers and behave differently. Try headless: false in CI to confirm, then use Playwright’s --headed flag. Also check for viewport-dependent behavior: the default headless viewport is 1280×720; some responsive layouts break at certain sizes. Set viewport: { width: 1920, height: 1080 } in the config.

page.waitForResponse times out — ensure the request is triggered after waitForResponse is set up. waitForResponse returns a Promise that resolves when the request matching the pattern completes. If the request fires before you call waitForResponse, you’ll miss it. Set it up before the action that triggers the request.

For related testing issues, see Fix: Jest Mock Not Working and Fix: React Testing Library Not Finding Element.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles