How to Do Visual Testing with Playwright
toHaveScreenshot() and toMatchSnapshot() matchers, requiring no external services or additional libraries. You capture a screenshot in any Playwright test, compare it against a stored baseline with configurable pixel thresholds, and the test fails when the visual difference exceeds your tolerance. This guide walks through the complete setup from first screenshot to CI integration.
Playwright's built-in visual testing is the fastest path to screenshot comparison for teams already using Playwright for end-to-end testing. No SDK to install, no service to sign up for, no dashboard to maintain. The screenshots live in your repository alongside your tests, and baseline updates happen with a single CLI flag. The tradeoff is that you manage baselines manually and review diffs through your normal code review process rather than a dedicated visual review dashboard.
Install Playwright and Create a Test File
If you do not already have Playwright installed, set it up with npm init playwright@latest, which creates a project structure with a config file and example tests. For visual testing, you write standard Playwright tests with an added screenshot assertion.
Create a test file, for example tests/visual.spec.ts, with a simple visual test:
import { test, expect } from '@playwright/test';
test('homepage visual test', async ({ page }) => {
await page.goto('https://your-site.com');
await expect(page).toHaveScreenshot('homepage.png');
});
test('pricing page visual test', async ({ page }) => {
await page.goto('https://your-site.com/pricing');
await expect(page).toHaveScreenshot('pricing.png');
});
The string argument to toHaveScreenshot() is the baseline filename. If omitted, Playwright generates a name from the test title. Specifying filenames explicitly keeps your baseline directory organized and makes it clear which baseline belongs to which test.
You can also capture screenshots of specific elements instead of the full page:
test('navigation visual test', async ({ page }) => {
await page.goto('https://your-site.com');
const nav = page.locator('nav');
await expect(nav).toHaveScreenshot('navigation.png');
});
Element-level screenshots are useful for testing specific components in the context of a full page, isolating the visual assertion to just the component that matters while ignoring the rest of the page.
Generate Initial Baselines
The first time you run a visual test, there is no baseline to compare against, so the test will fail. Generate the initial baselines by running with the update flag:
npx playwright test --update-snapshots
This captures screenshots for every toHaveScreenshot() call and stores them in a directory next to your test file, following the pattern tests/visual.spec.ts-snapshots/. The directory structure includes platform-specific subdirectories (like homepage-chromium-linux.png) because different operating systems render fonts and anti-aliasing differently.
After generating baselines, review the images to confirm they represent the correct appearance. Then commit them to version control. These baseline images are now the reference that all future test runs compare against.
You can change the snapshot directory by setting snapshotDir or snapshotPathTemplate in your playwright.config.ts:
export default defineConfig({
snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
});
Configure Comparison Thresholds
Pixel-perfect comparison is too strict for most real-world use. Sub-pixel rendering differences, anti-aliasing variations, and minor floating-point rounding in CSS layouts produce tiny visual differences that are invisible to humans but fail a zero-tolerance comparison. Playwright provides three threshold controls:
maxDiffPixels sets an absolute count of pixels that can differ before the test fails. Good for pages where you know approximately how many pixels might vary due to rendering noise:
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
});
maxDiffPixelRatio sets the threshold as a fraction of total pixels. Better for responsive tests where page size varies across viewports:
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.002, // 0.2% of pixels
});
threshold sets the color difference threshold per pixel, from 0 (exact match) to 1 (any color matches). The default is 0.2, which tolerates minor anti-aliasing differences. Lowering it makes comparison stricter; raising it tolerates larger color variations:
await expect(page).toHaveScreenshot('homepage.png', {
threshold: 0.1,
maxDiffPixelRatio: 0.001,
});
Set default thresholds in playwright.config.ts under expect to avoid repeating them in every test:
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.002,
threshold: 0.2,
},
},
});
Start with conservative thresholds and loosen only when specific tests produce consistent false positives that you cannot eliminate at the source. A test that needs a 5% threshold to pass reliably is telling you that something in the rendering pipeline is unstable.
Handle Dynamic Content
Pages with timestamps, user-specific content, live data, or animations produce different screenshots on every run, causing false positives. Playwright provides several mechanisms to handle dynamic content.
Use the mask option to cover dynamic elements with a colored block before capture:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.locator('.timestamp'),
page.locator('.user-avatar'),
page.locator('.live-counter'),
],
});
Disable animations before capturing to prevent mid-animation screenshots from producing diffs:
// Add to your test or global setup
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
Playwright also supports the animations: 'disabled' option directly in toHaveScreenshot(), which pauses CSS animations at their current state:
await expect(page).toHaveScreenshot('animated-page.png', {
animations: 'disabled',
});
For pages that depend on API data, use Playwright's route interception to serve fixed mock data during visual tests. This ensures the page content is identical across runs:
await page.route('**/api/dashboard', route =>
route.fulfill({ json: mockDashboardData })
);
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png');
Wait for fonts to load before capturing. Missing or partially loaded web fonts are one of the most common sources of visual test flakiness:
await page.goto('https://your-site.com');
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot('homepage.png');
Add Responsive Visual Tests
Most visual bugs are viewport-specific, so testing at a single viewport width misses the majority of responsive layout regressions. Playwright projects let you configure multiple viewport sizes that run the same tests at each size:
export default defineConfig({
projects: [
{
name: 'desktop',
use: { viewport: { width: 1440, height: 900 } },
},
{
name: 'tablet',
use: { viewport: { width: 768, height: 1024 } },
},
{
name: 'mobile',
use: { viewport: { width: 375, height: 812 } },
},
],
});
Each project generates separate baseline images, so the mobile version of your homepage has its own baseline independent of the desktop version. When a CSS change breaks the mobile layout but the desktop layout is fine, only the mobile visual test fails, pointing you directly at the breakpoint where the regression lives.
For deeper coverage of responsive visual testing strategies, see our responsive visual testing guide.
Run Visual Tests in CI
Visual tests in CI need a consistent rendering environment. The same page rendered on a developer's macOS laptop and a CI runner's Ubuntu produces different screenshots because of font rendering, anti-aliasing, and available system fonts. The solution is Docker.
Playwright provides official Docker images with all dependencies and consistent font rendering. Use them in your CI pipeline to ensure that the environment generating screenshots matches the environment where baselines were created:
# GitHub Actions example
jobs:
visual-tests:
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 tests/visual.spec.ts
Generate your baselines inside the same Docker image by running the update command in the container locally:
docker run --rm -v $(pwd):/work -w /work \
mcr.microsoft.com/playwright:v1.48.0-jammy \
npx playwright test --update-snapshots
This ensures that baselines and CI screenshots use identical rendering, eliminating cross-platform false positives entirely. For a deeper walk-through, see our visual testing in CI/CD guide.
When a visual test fails in CI, Playwright generates a diff image and attaches it to the test report. Configure your CI to store the Playwright HTML report as a build artifact so reviewers can inspect the baseline, actual screenshot, and highlighted diff without downloading the full repository.
Playwright's built-in visual testing is the zero-overhead path to screenshot comparison. One matcher, one CLI flag to update baselines, and Docker for CI consistency. If you are already using Playwright for E2E testing, you can have visual regression coverage in minutes with no new dependencies.