Playwright Test (@playwright/test 1.63)
Config
Two projects, one dev server. Mobile uses a device descriptor.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [['github'], ['html']] : 'list',
use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry' },
expect: { toHaveScreenshot: { maxDiffPixels: 100, animations: 'disabled' } },
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stderr: 'pipe',
},
});
webServer.url must answer 2xx to 4xx before tests start. port is deprecated, use url. The first next dev compile is slow, so keep the timeout at 120 s or more.
Specs
import { test, expect } from '@playwright/test';
test.describe('home', () => {
test('renders the hero heading', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
});
- Prefer
getByRole,getByLabel,getByTextover CSS selectors. - Use web-first assertions (
await expect(locator)...). They retry.expect(await locator.isVisible())does not. - Never
waitForTimeout. Wait on a locator state or a response. - Scope by project with
test.skip(({ isMobile }) => isMobile, 'desktop only'). Skip by platform withtest.skip(process.platform !== 'linux', 'snapshots are Linux only').
Snapshots
test('home matches', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png', { fullPage: true });
});
- First run writes the baseline and fails. Review it, then rerun.
- Update with
npx playwright test --update-snapshots. - Files get the browser, project and platform suffix, for example
home-desktop-linux.png. Rendering differs by OS, fonts and GPU, so a baseline made on macOS fails on Linux. - Options used here:
maxDiffPixels,animations: 'disabled',mask,maskColor,stylePath,fullPage. Set shared defaults underexpect.toHaveScreenshotin the config.
Linux-only baselines
Generate and update baselines only in the CI image, or the matching Playwright Docker image locally. Commit only *-linux.png. Gate the visual specs so other platforms skip instead of failing:
test.skip(process.platform !== 'linux', 'visual baselines are Linux only');
Clock and time
Freeze dates before navigation so relative dates and "year" copy stay stable.
test.beforeEach(async ({ page }) => {
await page.clock.setFixedTime(new Date('2026-01-15T12:00:00Z'));
});
setFixedTimefreezesDate.now()andnew Date()while timers keep running.install()fakes timers and rAF. ThenrunFor(ms)fires callbacks,fastForward(ms)jumps,pauseAt(date)freezes,resume()restores flow.page.clockapplies to the whole browser context.
Reduced motion
test.use({ reducedMotion: 'reduce' });
// or inside a test, before goto
await page.emulateMedia({ reducedMotion: 'reduce' });
Use it for two things. Assert the reduced path (no transform animation, content visible). Stabilize screenshots of pages that gate animation on prefers-reduced-motion.
Masking
await expect(page).toHaveScreenshot({
mask: [page.locator('canvas'), page.getByTestId('live-clock')],
maskColor: '#ff00ff',
});
Mask anything non-deterministic: canvas, video, avatars, counters, embeds. Masked areas paint a solid box in both baseline and actual.
WebGL canvas stability
Pixel-diffing a WebGL scene is flaky because of GPU, driver and frame timing. In order of preference:
- Mask the canvas and assert on the DOM around it. Separately assert the canvas exists and has nonzero size.
- Add a query flag or
reducedMotionbranch in the app that renders a static frame, then screenshot after the ready signal. - Software GL (untested, confirm on your CI before relying on it):
use: { launchOptions: { args: ['--use-gl=angle', '--use-angle=swiftshader'] } }. Output stays Linux only.
Wait for an explicit ready marker (data-ready attribute set after the first frame), not a timeout. Fix the viewport via project use, and keep deviceScaleFactor constant.
CI notes
retries: 2,workers: 1(or a small number),forbidOnly: true,trace: 'on-first-retry'.- Install browsers with
npx playwright install --with-deps chromium. - Upload
playwright-report/andtest-results/on failure. Diff images live intest-results. - Run against
next build && next startin CI when dev compile time causes timeouts. Local runs can keepnext devviareuseExistingServer. - Retries hide flaky screenshots. Fix the nondeterminism first.
Author: Ender Puentes (with Claude Code) Source: https://playwright.dev/docs/intro