DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to add visual regression testing to Playwright

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
Enter fullscreen mode Exit fullscreen mode

Add your PageBolt API key to your environment:

# .env.test (or GitHub Actions secret)
PAGEBOLT_API_KEY=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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',
  }),
});
Enter fullscreen mode Exit fullscreen mode

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)