15 Days of Playwright - Day 12: Visual Testing and Screenshots
Catch regressions that functional tests miss. Learn how to use Playwright's built-in screenshot comparison to lock in your UI's appearance and get alerted the moment it drifts.
Jhonatas Matos

Functional tests verify that a button works. Visual tests verify that the button looks right. Both matter — and most teams only do one.
A functional test can pass even when a CSS change turns your checkout button invisible, a font migration breaks your typography, or a third-party widget overlaps your navigation. Visual testing catches exactly those regressions that functional assertions can't see.
Today we'll cover Playwright's built-in screenshot comparison engine — no extra dependencies required.
How Visual Testing Works in Playwright
Playwright's toHaveScreenshot() assertion works in two phases:
- Baseline generation — the first time the assertion runs, Playwright saves a reference image (
.pngfile) to your snapshot directory. - Comparison — on every subsequent run, Playwright takes a new screenshot and compares it pixel-by-pixel against the baseline. If the difference exceeds a configurable threshold, the test fails.
No external service needed. The baseline images live in your repository alongside your tests.
Your First Visual Assertion
import { test, expect } from '@playwright/test';
test('homepage looks correct', async ({ page }) => {
await page.goto('https://bugbank.netlify.app');
// Full-page screenshot comparison
await expect(page).toHaveScreenshot('homepage.png');
});
Run this once to generate the baseline:
npx playwright test --update-snapshots
Run it again to compare:
npx playwright test
The baseline is saved to __snapshots__/ (or your configured snapshotDir) and must be committed to version control.
Element-Level Screenshots
You don't have to capture the whole page. Narrow the comparison to a specific component:
test('login card appearance', async ({ page }) => {
await page.goto('https://bugbank.netlify.app');
const loginCard = page.locator('.login-card');
await expect(loginCard).toHaveScreenshot('login-card.png');
});
Element-scoped screenshots are more stable than full-page ones because they ignore changes in unrelated parts of the UI.
Configuring Thresholds
Pixel-perfect comparison is too strict for most applications — anti-aliasing, sub-pixel rendering, and font hinting cause tiny differences across OS and GPU combinations. Set a tolerance threshold:
test('hero section visual', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('hero.png', {
maxDiffPixels: 100, // Allow up to 100 pixels to differ
// OR
maxDiffPixelRatio: 0.01, // Allow up to 1% of pixels to differ
threshold: 0.2, // Per-pixel color difference (0–1)
});
});
Recommended starting point: maxDiffPixelRatio: 0.01 (1%) for full-page, maxDiffPixels: 50 for element-scoped.
Global Visual Configuration
Set defaults in playwright.config.js so you don't repeat them in every test:
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
},
},
snapshotDir: '__snapshots__',
});
Masking Dynamic Content
Visual tests fail on content that changes between runs: timestamps, ads, animated elements, user-specific data. Mask those regions before comparing:
test('dashboard with masked dynamic content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.locator('.last-login-timestamp'),
page.locator('.live-chart'),
page.locator('.user-avatar'),
],
});
});
Masked regions are replaced with a solid color in both the baseline and comparison, so they never cause failures.
Practical BugBank Examples
Scenario 1: Register Form Appearance
import { test, expect } from '@playwright/test';
test.describe('BugBank visual tests', () => {
test('register form renders correctly', async ({ page }) => {
await page.goto('https://bugbank.netlify.app');
await page.getByText('Registrar').click();
// Wait for the form to be fully loaded
await expect(page.getByRole('button', { name: 'Cadastrar' })).toBeVisible();
// Capture just the registration card
const registerCard = page.locator('.card__register');
await expect(registerCard).toHaveScreenshot('register-card.png');
});
test('success modal appearance', async ({ page }) => {
await page.goto('https://bugbank.netlify.app');
await page.getByText('Registrar').click();
await page.getByPlaceholder('Informe seu nome').fill('Visual Test User');
await page.getByPlaceholder('Informe seu e-mail').fill('visual@test.com');
await page.getByPlaceholder('Informe sua senha').fill('password123');
await page.getByPlaceholder('Informe a confirmação da senha').fill('password123');
await page.getByRole('button', { name: 'Cadastrar' }).click();
// Modal appears — capture it
const modal = page.locator('#modal');
await expect(modal).toBeVisible();
await expect(modal).toHaveScreenshot('success-modal.png', {
mask: [page.locator('#modalText')], // account number is dynamic
});
});
});
Scenario 2: Responsive Layout Testing
test.describe('Responsive visual tests', () => {
const viewports = [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'mobile', width: 390, height: 844 },
];
for (const viewport of viewports) {
test(`homepage at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('https://bugbank.netlify.app');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
});
}
});
Handling Baseline Updates
When a UI change is intentional (a redesign, a rebrand), update the baselines:
# Update all snapshots
npx playwright test --update-snapshots
# Update snapshots for a specific test file
npx playwright test tests/visual.spec.js --update-snapshots
After updating, commit the new baseline images. Reviewers can diff the .png files in GitHub to approve visual changes — making design reviews part of the code review workflow.
Cross-Browser Visual Testing
Baselines are per-browser by default because rendering differs between Chromium, Firefox, and WebKit. Configure multiple projects in playwright.config.js:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Playwright stores separate baselines for each project: homepage-chromium.png, homepage-firefox.png, etc.
Best Practices
1. Wait for Animations to Complete Before Capturing
test('animated hero', async ({ page }) => {
await page.goto('/');
// Disable CSS animations to get stable screenshots
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`
});
await expect(page).toHaveScreenshot('hero.png');
});
2. Use animations: 'disabled' in Config
export default defineConfig({
use: {
screenshot: { animations: 'disabled' },
},
});
This disables CSS animations globally for all screenshot captures — no inline style injection needed.
3. Commit Baselines to Version Control
Baselines are source of truth. They belong in the repo, not a CI artifact store. Reviewers should approve visual changes the same way they approve code changes.
4. Run Visual Tests Separately from Functional Tests
Visual tests are inherently environment-dependent. Tag them and run in a dedicated project:
export default defineConfig({
projects: [
{ name: 'functional', testMatch: '**/*.spec.js' },
{ name: 'visual', testMatch: '**/*.visual.js', use: { ...devices['Desktop Chrome'] } },
],
});
Link to GitHub projectConclusion
Visual testing fills the gap that functional tests leave behind. With Playwright's built-in screenshot comparison you get:
- Baseline-based comparison — no external service, baselines live in your repo
- Element-scoped captures — test components in isolation, not entire pages
- Dynamic content masking — silence the noise, catch the real regressions
- Cross-browser visual coverage — know exactly how your UI looks in every target browser
Start with one visual test per critical page. You'll catch your first unintended CSS regression within a week.
Thank you for reading and see you in the next lesson! ☕💜