How to add visual regression testing to Playwright
Playwright's built-in toHaveScreenshot() breaks in CI constantly — different OS, different fonts, sub-pixel anti-aliasing differences between your Mac and the Linux runner. Percy and Applitools fix the flakiness but charge $400+/month before you hit any meaningful scale. There's a third option: screenshot every test via a hosted browser API, and do the diff yourself.
Why API screenshots are more consistent than local browser screenshots
When you run page.screenshot() in Playwright, the rendering happens on whatever machine runs the test: your MacBook, a GitHub Actions Ubuntu runner, or a colleague's Windows laptop. Even with the same Playwright version, you get different results because:
- Font rendering differs between operating systems (macOS uses sub-pixel antialiasing, Linux does not by default)
- GPU compositing varies by hardware and driver version
- System font fallbacks differ when a custom font fails to load
PageBolt screenshots always come from the same hosted Chromium instance, same OS, same fonts. Your baseline captured on Tuesday will match the screenshot taken on the GitHub Actions runner on Friday — because both are rendered by the same browser in the same environment, not your local machine.
Setup
Install dependencies:
npm install --save-dev @playwright/test pixelmatch pngjs
Add your PageBolt API key to your environment:
# .env.test (or GitHub Actions secret)
PAGEBOLT_API_KEY=YOUR_API_KEY
Playwright config
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
},
// We don't use Playwright's built-in screenshot comparison —
// we handle it ourselves via the PageBolt API
});
The visual regression helper
// tests/helpers/visual-regression.ts
import fs from 'fs';
import path from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
const PAGEBOLT_API = 'https://pagebolt.dev/api/v1/screenshot';
const BASELINE_DIR = path.join(__dirname, '../visual-baselines');
export async function captureViaPageBolt(pageUrl: string): Promise<Buffer> {
const res = await fetch(PAGEBOLT_API, {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: pageUrl,
width: 1280,
height: 720,
format: 'png',
blockBanners: true,
blockAds: true,
waitUntil: 'networkidle2',
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`PageBolt error: ${err.error}`);
}
return Buffer.from(await res.arrayBuffer());
}
export function getBaselinePath(testName: string): string {
return path.join(BASELINE_DIR, `${testName}.png`);
}
export function diffScreenshots(
baseline: Buffer,
current: Buffer
): { diffPercent: number; diffBuffer: Buffer } {
const img1 = PNG.sync.read(baseline);
const img2 = PNG.sync.read(current);
const { width, height } = img1;
const diff = new PNG({ width, height });
const numDiffPixels = pixelmatch(
img1.data,
img2.data,
diff.data,
width,
height,
{ threshold: 0.1 }
);
const diffPercent = (numDiffPixels / (width * height)) * 100;
return { diffPercent, diffBuffer: PNG.sync.write(diff) };
}
The test file
// tests/visual/homepage.visual.spec.ts
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import {
captureViaPageBolt,
getBaselinePath,
diffScreenshots,
} from '../helpers/visual-regression';
const BASELINE_DIR = path.join(__dirname, '../visual-baselines');
const DIFF_DIR = path.join(__dirname, '../visual-diffs');
const DIFF_THRESHOLD_PERCENT = 0.5; // fail if more than 0.5% of pixels differ
test.beforeAll(() => {
fs.mkdirSync(BASELINE_DIR, { recursive: true });
fs.mkdirSync(DIFF_DIR, { recursive: true });
});
async function visualRegressionCheck(testName: string, url: string) {
const baselinePath = getBaselinePath(testName);
const currentScreenshot = await captureViaPageBolt(url);
if (!fs.existsSync(baselinePath)) {
// First run — save as baseline
fs.writeFileSync(baselinePath, currentScreenshot);
console.log(`Baseline saved: ${baselinePath}`);
return;
}
const baseline = fs.readFileSync(baselinePath);
const { diffPercent, diffBuffer } = diffScreenshots(baseline, currentScreenshot);
if (diffPercent > DIFF_THRESHOLD_PERCENT) {
// Save the diff image for debugging
const diffPath = path.join(DIFF_DIR, `${testName}-diff.png`);
fs.writeFileSync(diffPath, diffBuffer);
throw new Error(
`Visual regression: ${diffPercent.toFixed(2)}% pixels differ (threshold: ${DIFF_THRESHOLD_PERCENT}%). Diff saved to ${diffPath}`
);
}
}
test('homepage looks correct', async () => {
await visualRegressionCheck('homepage', 'https://your-app.com');
});
test('pricing page looks correct', async () => {
await visualRegressionCheck('pricing', 'https://your-app.com/pricing');
});
test('dashboard in dark mode looks correct', async () => {
// Use the PageBolt darkMode param by extending captureViaPageBolt
const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://your-app.com/dashboard',
width: 1440,
height: 900,
format: 'png',
darkMode: true,
blockBanners: true,
waitForSelector: '.dashboard-content',
}),
});
const screenshot = Buffer.from(await res.arrayBuffer());
const baselinePath = getBaselinePath('dashboard-dark');
if (!fs.existsSync(baselinePath)) {
fs.writeFileSync(baselinePath, screenshot);
return;
}
const baseline = fs.readFileSync(baselinePath);
const { diffPercent } = diffScreenshots(baseline, screenshot);
expect(diffPercent).toBeLessThan(DIFF_THRESHOLD_PERCENT);
});
Updating baselines
When you intentionally change the UI and want to update baselines, delete the relevant PNG files from tests/visual-baselines/ and re-run the tests. The first run will save fresh baselines. Or add a flag:
// At the top of your test helper
const UPDATE_BASELINES = process.env.UPDATE_BASELINES === 'true';
// In visualRegressionCheck, replace the "first run" check:
if (!fs.existsSync(baselinePath) || UPDATE_BASELINES) {
fs.writeFileSync(baselinePath, currentScreenshot);
console.log(`Baseline saved: ${baselinePath}`);
return;
}
Then run: UPDATE_BASELINES=true npx playwright test
CI: GitHub Actions
# .github/workflows/visual-regression.yml
name: Visual Regression Tests
on:
pull_request:
branches: [main]
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run visual regression tests
env:
PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
run: npx playwright test tests/visual/
- name: Upload diff images on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: tests/visual-diffs/
The baselines live in your repo (tests/visual-baselines/). Commit them on initial setup. On every PR, the CI job compares fresh PageBolt screenshots against those committed baselines. If anything changes beyond 0.5% of pixels, the job fails and uploads the diff images as artifacts so you can review exactly what changed.
Capturing specific components with selector
You don't have to screenshot the whole page. PageBolt's selector parameter crops the screenshot to a specific element — useful for testing a header, a pricing card, or a modal in isolation:
const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://your-app.com/pricing',
selector: '.pricing-card.pro',
format: 'png',
}),
});
This returns a PNG cropped to just the .pricing-card.pro element — pixel-perfect regardless of the surrounding layout.
Try it free — 100 requests/month, no credit card. → Get started in 2 minutes
Top comments (0)