How to fix Playwright tests that fail only in headless mode?

Playwright

Headless-only failures are among the most confusing Playwright issues because the test is syntactically correct and passes with a visible browser window, yet consistently fails in CI headless mode. The root causes fall into a few predictable categories: focus and hover events behaving differently without a real display, CSS transitions and animations not completing before assertions, font rendering differences causing layout shifts, or application code that branches on user-agent strings that differ between headed and headless Chromium.

Common mistake

test('tooltip appears on hover', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');

  await page.getByRole('button', { name: 'Info' }).hover();

  // Tooltip uses CSS transition — completes in headed mode but
  // may not render at all in headless due to reduced-motion or missing GPU
  await expect(page.getByRole('tooltip')).toBeVisible();
});

Also common: tests that use focus() to trigger dropdown behavior that only works when a real window has focus.

The fix

Add explicit waits for animation completion and normalize viewport and motion settings:

import { defineConfig } from '@playwright/test';

// playwright.config.ts — normalize for headless
export default defineConfig({
  use: {
    viewport: { width: 1280, height: 720 },
    // Disable CSS animations for more predictable behavior
    launchOptions: {
      args: ['--force-prefers-reduced-motion'],
    },
  },
});

For individual tests that need to verify animated elements:

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

test('tooltip shows on hover', async ({ page }) => {
  await page.goto('https://app.example.com/dashboard');

  const infoButton = page.getByRole('button', { name: 'Info' });

  // Use explicit wait for visible state — handles animation delay
  await infoButton.hover();
  await expect(page.getByRole('tooltip')).toBeVisible({ timeout: 5000 });
});

For dropdown menus that require window focus:

test('dropdown opens on click', async ({ page }) => {
  await page.goto('https://app.example.com');

  // Click is more reliable than hover for opening menus in headless
  await page.getByRole('button', { name: 'Account' }).click();

  const dropdown = page.getByRole('menu');
  await expect(dropdown).toBeVisible();
  await dropdown.getByRole('menuitem', { name: 'Settings' }).click();
});

Compare headed and headless traces to pinpoint the divergence:

# Run once headless (default), once headed
PWHEADLESS=false npx playwright test failing-test.spec.ts --trace on
npx playwright test failing-test.spec.ts --trace on
# Compare the traces in the HTML reporter

Why it works

Headless Chromium renders pages without a GPU compositor in some configurations, which can affect CSS transition and transform animations — they may fire instantly or be skipped. --force-prefers-reduced-motion tells Chromium to report prefers-reduced-motion: reduce to CSS, which modern component libraries use to disable or shorten animations. This makes animation-dependent behavior consistent between environments. Using click() instead of hover() for interactive elements avoids focus model differences between headed and headless modes.

Tips

  • Check the Playwright trace for the headless failure — the DOM snapshot and screenshots show exactly what state the page was in when the assertion fired.
  • If the app's CSS uses prefers-reduced-motion: reduce to disable transitions, enabling this flag in Playwright config makes headless behavior match a typical accessibility-conscious user's experience.
  • Font rendering differences between headed and headless can cause slight layout changes that shift elements off-screen — use viewport settings consistent with your reference screenshots.
  • If the test passes reliably locally in headless but fails only in CI headless, the issue is more likely timing (CI is slower) than the headless mode itself — add timeout overrides for slow steps.