Series Navigation
← Part 7: Authentication — Sessions, Storage State and Multi-User Testing
→ Part 9: Parallel Execution, Sharding and Reporting
What Visual Testing Catches
Functional tests verify behaviour. Visual tests verify appearance. They catch an entirely different class of regressions:
- A CSS refactor that shifted the layout by 4px
- A font-weight change that made headings lighter
- A z-index bug where a dropdown appears behind a modal
- A colour token update that changed button colours in the wrong theme
- A responsive breakpoint that broke the mobile nav
None of these break functional assertions. All of them are visible to users.
Built-in Screenshot Comparison
Playwright has screenshot comparison built in — no extra libraries needed.
test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
First run: Playwright generates the baseline screenshot and saves it to tests/screenshots/ (or wherever configured). The test passes.
Subsequent runs: Playwright compares the new screenshot against the baseline pixel by pixel. If they differ beyond a threshold, the test fails and shows you a diff image.
Element-Level Screenshots
Capture a specific component rather than the full page:
test('product card renders correctly', async ({ page }) => {
await page.goto('/shop');
const card = page.getByTestId('product-card').first();
await expect(card).toHaveScreenshot('product-card.png');
});
test('navigation menu is correct', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toHaveScreenshot('nav.png');
});
Element-level screenshots are more focused and less noisy than full-page captures.
Masking Dynamic Content
Dynamic content — dates, user names, ad banners, animated elements — causes false failures. Mask them:
test('dashboard with masked dynamic content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.getByTestId('current-date'), // date changes daily
page.getByTestId('user-greeting'), // user-specific text
page.getByTestId('live-price-ticker'), // live data
page.getByRole('img', { name: /avatar/ }), // profile photos
]
});
});
Masked areas are filled with a solid colour in the comparison, so dynamic content doesn't trigger false failures.
Configuring Tolerance
Pixel-perfect comparison is too strict for most teams — antialiasing and rendering differences across OS/GPU can cause minor pixel diffs. Set a threshold:
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
// Allow up to 0.2% of pixels to differ
maxDiffPixelRatio: 0.002,
// Or allow up to 100 pixels to differ
maxDiffPixels: 100,
// Threshold per-pixel colour difference (0–1)
threshold: 0.2,
}
}
});
Override per-assertion:
await expect(page).toHaveScreenshot('hero.png', {
maxDiffPixelRatio: 0.01 // 1% tolerance for this specific screenshot
});
Viewport and Responsive Testing
Test your layout at multiple screen sizes:
import { test, expect, devices } from '@playwright/test';
const viewports = [
{ name: 'mobile', width: 390, height: 844 }, // iPhone 14
{ name: 'tablet', width: 768, height: 1024 }, // iPad
{ name: 'desktop', width: 1440, height: 900 }, // standard desktop
];
for (const viewport of viewports) {
test(`homepage layout on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
});
}
Or use Playwright's built-in device emulation:
// playwright.config.ts
projects: [
{ name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
{ name: 'Tablet Android', use: { ...devices['Galaxy Tab S4'] } },
]
Each project runs all tests with that device's viewport, user agent and touch settings.
Waiting Before Capturing
Animations and lazy-loaded images can cause flaky screenshots. Wait for stability first:
test('animated hero section', async ({ page }) => {
await page.goto('/');
// Wait for CSS animations to finish
await page.waitForFunction(() =>
document.querySelectorAll('.animating').length === 0
);
// Or wait for all images to load
await page.waitForFunction(() => {
const images = document.querySelectorAll('img');
return Array.from(images).every(img => img.complete && img.naturalWidth > 0);
});
await expect(page).toHaveScreenshot('hero.png');
});
Disable animations entirely in your test environment for more stable captures:
/* styles for test environment */
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
Apply via CSS class or environment variable in your app:
// In global-setup or a beforeAll
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
`
});
Dark Mode Testing
Test both themes:
test.describe('dark mode', () => {
test.use({ colorScheme: 'dark' });
test('dashboard in dark mode', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard-dark.png');
});
});
test.describe('light mode', () => {
test.use({ colorScheme: 'light' });
test('dashboard in light mode', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard-light.png');
});
});
Updating Baselines
When a visual change is intentional (new design, approved redesign), update the baselines:
# Update all screenshots
npx playwright test --update-snapshots
# Update screenshots for a specific file
npx playwright test tests/homepage.spec.ts --update-snapshots
# Update a specific test
npx playwright test --update-snapshots --grep "homepage looks correct"
Always commit updated baseline images alongside the code change so the team can review the visual diff in the PR.
Storing Screenshots in CI
Configure where screenshots are stored:
// playwright.config.ts
export default defineConfig({
snapshotDir: './tests/screenshots', // baseline location
snapshotPathTemplate: '{snapshotDir}/{testFilePath}/{arg}{ext}',
});
In CI, always run with --update-snapshots disabled (the default). If baselines don't exist in the repository, the test will fail — treat this as a reminder to commit baselines.
Full-Page Screenshots (Beyond the Viewport)
test('full page capture', async ({ page }) => {
await page.goto('/long-landing-page');
await expect(page).toHaveScreenshot('full-page.png', {
fullPage: true // captures the entire scrollable page
});
});
What's Next
In Part 9 we cover parallel execution, test sharding and reporting — how to dramatically speed up your test suite across multiple CPU cores and multiple machines, and how to produce useful reports your team will actually read.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.