Visual Regression Testing with Playwright
Catch unintended UI changes automatically by comparing screenshots with Playwright's built-in visual comparison tools.
What you'll learn
- ✓What visual regression testing is and when you need it
- ✓Using Playwright toHaveScreenshot for pixel-level comparisons
- ✓Handling dynamic content, animations, and flakiness
- ✓Setting up visual tests in CI with consistent rendering
Prerequisites
- •Basic Playwright knowledge
- •A web application with UI components
What Visual Regression Testing Solves
You refactor a CSS file. All your unit tests pass. All your end-to-end tests pass. You deploy. Then a customer reports that the checkout button is now invisible because it has white text on a white background.
Functional tests verify behavior — clicking the button submits the form. Visual regression tests verify appearance — the button looks the way it should. They work by taking screenshots and comparing them against known-good baselines. If the screenshots differ beyond a threshold, the test fails.
Playwright’s Built-in Visual Comparisons
Playwright includes visual comparison out of the box. No third-party tools needed.
Setup
If you do not have Playwright installed:
npm init playwright@latest
Your First Visual Test
// tests/homepage.spec.ts
import { test, expect } from "@playwright/test";
test("homepage looks correct", async ({ page }) => {
await page.goto("http://localhost:3000");
await expect(page).toHaveScreenshot("homepage.png");
});
The first time you run this test, Playwright creates a baseline screenshot at tests/homepage.spec.ts-snapshots/homepage.png. On subsequent runs, it takes a new screenshot and compares it pixel by pixel against the baseline.
Run the test:
npx playwright test
On the first run, it will fail because no baseline exists. Generate baselines with:
npx playwright test --update-snapshots
Comparing Elements, Not Full Pages
Full-page screenshots are useful but can be noisy. For more focused tests, screenshot specific elements:
test("navigation bar appearance", async ({ page }) => {
await page.goto("http://localhost:3000");
const navbar = page.locator("nav");
await expect(navbar).toHaveScreenshot("navbar.png");
});
test("pricing card renders correctly", async ({ page }) => {
await page.goto("http://localhost:3000/pricing");
const card = page.locator(".pricing-card").first();
await expect(card).toHaveScreenshot("pricing-card.png");
});
Element screenshots isolate the component you care about, reducing false positives from unrelated changes elsewhere on the page.
Configuring Comparison Thresholds
Pixel-perfect comparison is often too strict. Anti-aliasing differences between operating systems, font rendering variations, and sub-pixel shifts cause false failures. Configure a threshold:
test("dashboard layout", async ({ page }) => {
await page.goto("http://localhost:3000/dashboard");
await expect(page).toHaveScreenshot("dashboard.png", {
maxDiffPixels: 100, // allow up to 100 different pixels
});
});
Alternatively, use a percentage threshold:
await expect(page).toHaveScreenshot("dashboard.png", {
maxDiffPixelRatio: 0.01, // allow 1% of pixels to differ
});
You can also set these globally in playwright.config.ts:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
},
},
// ...
});
Handling Dynamic Content
Dynamic content like timestamps, avatars, user names, and ads will break visual tests. Handle them by masking or hiding:
Masking Elements
test("profile page without dynamic content", async ({ page }) => {
await page.goto("http://localhost:3000/profile");
await expect(page).toHaveScreenshot("profile.png", {
mask: [
page.locator(".timestamp"),
page.locator(".avatar"),
page.locator(".random-ad"),
],
});
});
Masked elements are replaced with a solid-color box in the screenshot, preventing them from causing false failures.
Replacing Content with JavaScript
For more control, inject CSS or JavaScript before taking the screenshot:
test("dashboard with stable content", async ({ page }) => {
await page.goto("http://localhost:3000/dashboard");
// Freeze dynamic text
await page.evaluate(() => {
document.querySelectorAll("[data-testid='timestamp']").forEach((el) => {
el.textContent = "Jan 1, 2026";
});
});
await expect(page).toHaveScreenshot("dashboard-stable.png");
});
Disabling Animations
Animations cause screenshots to capture different frames on different runs. Disable them:
// playwright.config.ts
export default defineConfig({
use: {
// Reduce motion for all tests
contextOptions: {
reducedMotion: "reduce",
},
},
});
You can also inject CSS to disable animations globally:
test.beforeEach(async ({ page }) => {
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
});
Waiting for Stable Rendering
If content loads asynchronously, you need to wait before screenshotting:
test("data table after loading", async ({ page }) => {
await page.goto("http://localhost:3000/reports");
// Wait for the loading spinner to disappear
await page.locator(".loading-spinner").waitFor({ state: "hidden" });
// Wait for the table to have data
await page.locator("table tbody tr").first().waitFor();
// Wait for fonts to load
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot("reports-table.png");
});
Playwright’s toHaveScreenshot also has a built-in stability check. It takes two screenshots with a small delay and only proceeds when they match, ensuring animations have settled.
Testing Responsive Layouts
Test the same page at different viewport sizes:
const viewports = [
{ name: "mobile", width: 375, height: 812 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1440, height: 900 },
];
for (const vp of viewports) {
test(`homepage at ${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto("http://localhost:3000");
await expect(page).toHaveScreenshot(`homepage-${vp.name}.png`);
});
}
This catches responsive design regressions — a layout that works on desktop but breaks on mobile.
Testing Dark Mode and Themes
test("homepage in dark mode", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("http://localhost:3000");
await expect(page).toHaveScreenshot("homepage-dark.png");
});
test("homepage in light mode", async ({ page }) => {
await page.emulateMedia({ colorScheme: "light" });
await page.goto("http://localhost:3000");
await expect(page).toHaveScreenshot("homepage-light.png");
});
Visual Tests in CI
Visual tests must run in a consistent environment. Font rendering, anti-aliasing, and sub-pixel positioning differ between macOS, Linux, and Windows. The solution: run visual tests in Docker or on a consistent CI platform.
Using Playwright’s Docker Image
# .github/workflows/visual-tests.yml
name: Visual Regression
on: [pull_request]
jobs:
visual:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test --grep @visual
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diff-report
path: test-results/
Generate baselines inside the same Docker image you use in CI. This ensures pixel-perfect consistency.
Updating Baselines
When a visual change is intentional:
# Regenerate all baselines
npx playwright test --update-snapshots
# Regenerate for a specific test
npx playwright test homepage --update-snapshots
Commit the updated baseline screenshots. Reviewers can see the visual diff in the PR.
Reviewing Visual Diffs
When a visual test fails, Playwright generates three images in the test-results directory:
homepage-actual.png— what the test captured.homepage-expected.png— the baseline.homepage-diff.png— a highlighted diff showing changed pixels.
The HTML report (npx playwright show-report) displays these side by side, making it easy to decide whether the change is intentional.
Organizing Visual Tests
Keep visual tests separate from functional tests:
tests/
functional/
login.spec.ts
checkout.spec.ts
visual/
homepage.visual.spec.ts
components.visual.spec.ts
Tag them for selective execution:
test("homepage layout @visual", async ({ page }) => {
// ...
});
# Run only visual tests
npx playwright test --grep @visual
# Skip visual tests during quick feedback loop
npx playwright test --grep-invert @visual
Key Takeaways
Visual regression testing with Playwright catches CSS and layout bugs that functional tests miss. Use element-level screenshots for focused comparisons, mask dynamic content, disable animations, and run tests in Docker for consistency. Start by adding visual tests to your most critical pages — landing pages, checkout flows, and dashboard layouts. Keep baselines in version control and treat screenshot updates as reviewable changes in pull requests.
Related articles
- Testing End-to-End Testing with Playwright: A Practical Tutorial
Learn how to write reliable end-to-end tests with Playwright, including selectors, fixtures, auto-waiting, and patterns that avoid flakiness.
- Next.js E2E Testing Next.js Apps with Playwright
Set up Playwright for end-to-end testing of Next.js applications. Covers configuration, writing tests, handling auth, and CI integration.
- Testing Integration Testing: Test Real Dependencies, Not Mocks
Learn how to write effective integration tests that verify real database queries, API calls, and service interactions using testcontainers and proper test infrastructure.
- Testing Performance Testing: Load, Stress, and Spike Testing
Learn load testing, stress testing, and spike testing with k6 and Locust. Includes test scripts, result interpretation, and strategies for finding bottlenecks.