Capture and compare UI screenshots to detect unintended visual changes, configure pixel-diff thresholds, mask dynamic content, and integrate visual testing into CI workflows.
CSS bugs are invisible in unit and integration tests. A misaligned button, a broken responsive layout, or an accidentally hidden section passes every logic test but fails the moment a user looks at the screen. Visual regression testing automates this check: take a screenshot, compare it pixel-by-pixel against a known-good baseline, and fail the test if anything unexpected changed.
Playwright's built-in screenshot comparison removes the need for third-party services for most projects. Baselines live in Git alongside your tests, diffs are shown in the HTML report, and updates require a deliberate command rather than an accidental merge. This lesson builds a complete visual testing workflow from first screenshot to CI integration.
1import { test, expect } from '@playwright/test';
2
3test('home page visual snapshot', async ({ page }) => {
4 await page.goto('/');
5 // Wait for all images, fonts, and animations to settle
6 await page.waitForLoadState('networkidle');
7
8 // Full-page screenshot — saves to __snapshots__/home-page-visual-snapshot-chromium.png
9 await expect(page).toHaveScreenshot('home-page.png', {
10 fullPage: true,
11 });
12});
13
14test('course card component snapshot', async ({ page }) => {
15 await page.goto('/courses');
16 const firstCard = page.getByRole('article').first();
17 await expect(firstCard).toBeVisible();
18
19 // Element-scoped screenshot — only captures the card element
20 await expect(firstCard).toHaveScreenshot('course-card.png');
21});
22
23test('navigation bar snapshot', async ({ page }) => {
24 await page.goto('/');
25 const nav = page.getByRole('navigation', { name: 'Main navigation' });
26 await expect(nav).toHaveScreenshot('nav-bar.png');
27});
28
29// First run — no baseline exists yet:
30// Playwright creates the snapshot file
31// Test PASSES on first run (baseline creation)
32
33// Subsequent runs — baseline exists:
34// Playwright compares current screenshot to baseline
35// Test FAILS if pixels differ beyond threshold
36
37// To update baselines after an intentional UI change:
38// npx playwright test --update-snapshots
39// OR for a specific file:
40// npx playwright test e2e/visual.spec.ts --update-snapshots
41
42// Custom snapshot name suffix:
43await expect(page).toHaveScreenshot({ name: ['home', 'hero-section'] });
44// Creates: home-hero-section-1-chromium.png1// playwright.config.ts — global screenshot settings
2export default defineConfig({
3 expect: {
4 toHaveScreenshot: {
5 // Maximum ratio of pixels that can differ (0-1)
6 // 0.01 = 1% of pixels can differ
7 maxDiffPixelRatio: 0.02,
8
9 // Per-pixel colour difference threshold (0-1)
10 // Higher = more tolerant of colour differences
11 threshold: 0.2,
12
13 // Maximum absolute count of differing pixels
14 // Use instead of ratio for small components
15 maxDiffPixels: 50,
16 },
17 },
18});
19
20// Per-test overrides:
21test('hero section snapshot (strict)', async ({ page }) => {
22 await page.goto('/');
23 // Very strict — critical brand element
24 await expect(page.getByRole('banner')).toHaveScreenshot('hero.png', {
25 maxDiffPixelRatio: 0, // zero tolerance
26 threshold: 0.1,
27 });
28});
29
30test('loading skeleton (lenient)', async ({ page }) => {
31 await page.route('**/api/**', (route) => new Promise(() => {})); // never resolves
32 await page.goto('/courses');
33 // Animation frames can differ — use lenient threshold
34 await expect(page).toHaveScreenshot('loading-state.png', {
35 maxDiffPixelRatio: 0.05,
36 threshold: 0.3,
37 });
38});
39
40// Debugging failed snapshots:
41// Open: npx playwright show-report
42// The HTML report shows:
43// - Expected screenshot (baseline)
44// - Received screenshot (current)
45// - Diff image (red = changed pixels)
46// The diff image makes it obvious what changed1test('user dashboard snapshot with masked dynamic fields', async ({ page }) => {
2 // Log in and go to dashboard
3 await page.goto('/dashboard');
4 await page.waitForLoadState('networkidle');
5
6 await expect(page).toHaveScreenshot('user-dashboard.png', {
7 fullPage: true,
8 mask: [
9 // Mask elements whose content changes between runs
10 page.getByTestId('user-avatar'), // different per user
11 page.getByTestId('last-login-time'), // always different
12 page.getByTestId('current-date'), // date changes daily
13 page.getByTestId('enrollment-count'), // changes as users enrol
14 page.getByTestId('analytics-chart'), // chart data varies
15 ],
16 // Masked areas are filled with magenta — visible in diff images
17 maskColor: '#FF00FF',
18 });
19});
20
21// Masking by role/text for dynamic labels:
22test('notification panel snapshot', async ({ page }) => {
23 await page.goto('/notifications');
24 await expect(page.getByRole('main')).toHaveScreenshot('notifications.png', {
25 mask: [
26 // Mask all relative time labels like '2 minutes ago'
27 ...await page.getByText(/\d+ (minutes?|hours?|days?) ago/).all(),
28 ],
29 });
30});
31
32// Waiting for animations to complete before snapshot:
33test('hero animation snapshot', async ({ page }) => {
34 await page.goto('/');
35
36 // Wait for CSS animations to finish
37 await page.waitForFunction(() =>
38 document.getAnimations().every((a) => a.playState !== 'running')
39 );
40
41 await expect(page).toHaveScreenshot('hero-after-animation.png');
42});
43
44// Disabling animations globally for stable snapshots:
45// playwright.config.ts:
46use: {
47 // Reduces animation jitter in screenshots
48 reducedMotion: 'reduce',
49}1# Snapshot file organisation
2e2e/
3├── visual.spec.ts
4├── home.spec.ts
5└── __snapshots__/ ← snapshot files live here
6 ├── visual.spec.ts-snapshots/
7 │ ├── home-page-chromium.png
8 │ ├── home-page-firefox.png
9 │ ├── home-page-webkit.png
10 │ └── course-card-chromium.png
11 └── home.spec.ts-snapshots/
12 └── nav-bar-chromium.png
13
14# .gitignore — commit snapshots, ignore temp files
15# DO commit:
16# e2e/__snapshots__/**/*.png
17
18# playwright.config.ts — customise snapshot location
19snapshotDir: './e2e/__snapshots__',
20snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}-{projectName}{ext}',1// Snapshot update workflow:
2// 1. Designer updates the hero section intentionally
3// 2. CI fails on visual diff
4// 3. Developer reviews the diff in the HTML report
5// 4. Developer approves the change:
6npx playwright test e2e/visual.spec.ts --update-snapshots --project=chromium
7// 5. Commit updated snapshot files with a descriptive message:
8// git add e2e/__snapshots__/
9// git commit -m 'chore(snapshots): update hero section after brand refresh'
10
11// Preventing accidental snapshot updates in CI:
12// playwright.config.ts:
13expect: {
14 toHaveScreenshot: {
15 // This makes missing snapshots fail (not auto-create)
16 // Prevents 'update' mode from running in CI
17 },
18},
19// Add to CI workflow:
20// - run: npx playwright test
21// env:
22// CI: 'true'
23// And in config:
24// updateSnapshots: process.env['CI'] ? 'none' : 'missing',1// Test both light and dark themes
2test.describe('Dark mode visual regression', () => {
3 test('courses page in light mode', async ({ page }) => {
4 await page.emulateMedia({ colorScheme: 'light' });
5 await page.goto('/courses');
6 await page.waitForLoadState('networkidle');
7 await expect(page).toHaveScreenshot('courses-light.png', { fullPage: true });
8 });
9
10 test('courses page in dark mode', async ({ page }) => {
11 await page.emulateMedia({ colorScheme: 'dark' });
12 await page.goto('/courses');
13 await page.waitForLoadState('networkidle');
14 await expect(page).toHaveScreenshot('courses-dark.png', { fullPage: true });
15 });
16});
17
18// Test theme toggle button persists preference:
19test('dark mode persists after page reload', async ({ page }) => {
20 await page.goto('/');
21 await page.getByRole('button', { name: 'Toggle dark mode' }).click();
22 // Snapshot shows dark theme active
23 await expect(page).toHaveScreenshot('home-dark-toggled.png');
24
25 await page.reload();
26 // Dark theme persists after reload (localStorage/cookie preserved)
27 await expect(page).toHaveScreenshot('home-dark-after-reload.png');
28});
29
30// Reduced motion for stable snapshots across runs:
31test.use({ reducedMotion: 'reduce' });
32
33// Print media for PDF/print testing:
34test('invoice print layout', async ({ page }) => {
35 await page.emulateMedia({ media: 'print' });
36 await page.goto('/invoices/inv-001');
37 await expect(page).toHaveScreenshot('invoice-print.png', { fullPage: true });
38});A visual regression test suite covering light/dark themes, multiple viewports, component snapshots, masked dynamic content, and animated state transitions.
1// e2e/visual-regression.spec.ts
2import { test, expect } from '@playwright/test';
3
4// Disable animations for stable snapshots
5test.use({
6 reducedMotion: 'reduce',
7});
8
9test.beforeEach(async ({ page }) => {
10 // Mock API to ensure stable, predictable content
11 await page.route('**/api/courses*', (route) =>
12 route.fulfill({
13 body: JSON.stringify({
14 data: [
15 { id: '1', title: 'TypeScript Fundamentals', level: 'BEGINNER', price: 0, instructor: 'Alice Chen', rating: 4.8, enrollments: 1240 },
16 { id: '2', title: 'React Advanced Patterns', level: 'ADVANCED', price: 99.99, instructor: 'Bob Smith', rating: 4.9, enrollments: 892 },
17 { id: '3', title: 'Node.js Production', level: 'INTERMEDIATE', price: 49.99, instructor: 'Carol Lee', rating: 4.7, enrollments: 2103 },
18 ],
19 total: 3,
20 page: 1,
21 }),
22 })
23 );
24});
25
26test.describe('Courses Page — Visual Regression', () => {
27 test('light mode full page', async ({ page }) => {
28 await page.emulateMedia({ colorScheme: 'light' });
29 await page.goto('/courses');
30 await page.waitForLoadState('networkidle');
31
32 await expect(page).toHaveScreenshot('courses-light-desktop.png', {
33 fullPage: true,
34 mask: [page.getByTestId('enrollment-count')], // number varies
35 });
36 });
37
38 test('dark mode full page', async ({ page }) => {
39 await page.emulateMedia({ colorScheme: 'dark' });
40 await page.goto('/courses');
41 await page.waitForLoadState('networkidle');
42
43 await expect(page).toHaveScreenshot('courses-dark-desktop.png', {
44 fullPage: true,
45 mask: [page.getByTestId('enrollment-count')],
46 });
47 });
48
49 test('mobile viewport (375px)', async ({ page }) => {
50 await page.setViewportSize({ width: 375, height: 812 });
51 await page.goto('/courses');
52 await page.waitForLoadState('networkidle');
53
54 await expect(page).toHaveScreenshot('courses-mobile.png', {
55 fullPage: true,
56 mask: [page.getByTestId('enrollment-count')],
57 });
58 });
59
60 test('first course card component', async ({ page }) => {
61 await page.goto('/courses');
62 const card = page.getByRole('article').first();
63 await expect(card).toBeVisible();
64
65 await expect(card).toHaveScreenshot('course-card-default.png');
66 });
67
68 test('course card hover state', async ({ page }) => {
69 await page.goto('/courses');
70 const card = page.getByRole('article').first();
71 await card.hover();
72 // Wait for hover transition to complete
73 await page.waitForTimeout(300);
74
75 await expect(card).toHaveScreenshot('course-card-hover.png');
76 });
77
78 test('empty state when no courses match filter', async ({ page }) => {
79 // Override with empty response
80 await page.route('**/api/courses*', (route) =>
81 route.fulfill({ body: JSON.stringify({ data: [], total: 0, page: 1 }) })
82 );
83
84 await page.goto('/courses?level=ADVANCED&maxPrice=0');
85 await page.waitForLoadState('networkidle');
86
87 await expect(page.getByRole('main')).toHaveScreenshot('courses-empty-state.png');
88 });
89
90 test('loading skeleton state', async ({ page }) => {
91 // Intercept and never respond — keeps loading state
92 await page.route('**/api/courses*', () => new Promise(() => {}));
93 await page.goto('/courses');
94
95 const skeleton = page.getByTestId('courses-skeleton');
96 await expect(skeleton).toBeVisible();
97 await expect(skeleton).toHaveScreenshot('courses-loading-skeleton.png', {
98 maxDiffPixelRatio: 0.03, // slightly lenient for skeleton animation
99 });
100 });
101});The API mock in beforeEach ensures consistent, deterministic data across every screenshot run — without it, enrollment counts and ratings would change and cause constant false-positive failures. reducedMotion: 'reduce' disables CSS transitions so hover states and loading animations capture cleanly. Each test captures a distinct visual state: light, dark, mobile, default card, hover card, empty state, and loading skeleton. The hover state test uses a 300ms wait after hover to let the CSS transition finish before snapshotting.
The next lesson covers running Playwright tests in GitHub Actions CI: parallel execution, trace attachment on failure, test sharding across multiple machines, and publishing HTML reports.
Your progress is saved in this browser, on this device only.