How to fix Playwright tests that fail only in headless mode?
PlaywrightHeadless-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.