Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

Visual Regression Testing Without Applitools: Open-Source Approaches That Work in 2026

April 2, 2026 EST. READ: 11 MIN #Quality Assurance

The big-three visual regression vendors (Applitools, Percy, Chromatic) start at roughly $500/month and scale fast based on snapshot volume. For enterprise teams with budget, they're worth it — AI-driven diffing, beautiful dashboards, easy approvals. For startups, indie teams, and freelancers, the cost is hard to justify.

The good news: Playwright shipped toHaveScreenshot() a few years ago, and it's now mature enough to handle the 80% case for free. Combined with a few open-source helpers, you can run visual regression at zero cost on a small team. Here's the setup I use on three client projects, the gotchas, and when I'd actually recommend paying for the commercial tools.

Table of Contents

What You Give Up vs the Commercial Tools

Honest tradeoffs:

  • AI-driven diffing. Applitools ignores anti-aliasing, font rendering jitter, ad regions automatically. Open-source diffing is pixel-by-pixel; you handle exclusions yourself.
  • Approval workflow. Commercial tools have a beautiful UI for reviewing diffs. Open-source: you read PR diffs and look at images side by side.
  • Cross-browser cloud rendering. Commercial tools maintain browser farms. Open-source: you render on your own CI.
  • Storage and search. Commercial tools store every baseline forever. Open-source: baselines live in your repo.

For a startup with 30–50 snapshots, none of these matter much. For an enterprise with 5,000 snapshots across 15 browsers, they matter a lot.

Playwright's Built-In toHaveScreenshot()

The simplest possible visual test:

test('homepage matches design', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

First run creates a baseline at tests/homepage.spec.ts-snapshots/homepage-chromium-darwin.png. Subsequent runs compare against the baseline. Differences fail the test with a side-by-side diff in the test report.

Update baselines after intentional changes:

npx playwright test --update-snapshots

That's the entire core API. Configuration tunes it — diff threshold, animation handling, masking — but the workflow is just "render, compare, approve."

The Full Workflow: Capture, Diff, Approve

1. Capture stable baselines

Stable baselines need: deterministic data, disabled animations, fixed viewport, controlled fonts. Set globally in your config:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      animations: 'disabled',
      maxDiffPixelRatio: 0.01, // 1% pixel difference allowed
      threshold: 0.2, // per-pixel sensitivity
    },
  },
  use: {
    viewport: { width: 1280, height: 720 },
  },
});

For data: mock the API responses (see my mocking patterns post) so the same fixtures render every run.

2. Run the test, see diffs in the HTML report

npx playwright test --reporter=html
npx playwright show-report

The HTML report shows expected, actual, and diff side by side. Click through. Decide intentional vs regression.

3. Approve intentional changes

If the diff is intentional (you redesigned a button), update the baseline:

npx playwright test path/to/specific.spec.ts --update-snapshots

Commit the new baseline with the PR that intentionally changed it. Reviewer sees the new image alongside the code change. Approves both together.

Handling Dynamic Content (Timestamps, Animations, Fonts)

This is where most visual regression setups die. Three classes of dynamism:

Timestamps and dates

Mask them out:

await expect(page).toHaveScreenshot('dashboard.png', {
  mask: [page.getByTestId('last-updated-timestamp')],
});

Masked regions get filled with pink. The diff ignores them.

Or freeze the clock:

await page.clock.install({ now: new Date('2026-04-15T10:00:00Z') });
await page.goto('/dashboard');
// All Date.now() in the page now returns the fixed time

Animations

animations: 'disabled' in your config waits for animations to finish before screenshotting. For animations that never finish (loading spinners, marquees), use caret: 'hide' and the masking pattern.

Font rendering

This is the hardest. Fonts render slightly differently on macOS vs Linux vs Windows. If your CI is Linux and your dev is Mac, baselines fail.

Fix: run snapshot generation on Linux only. Either

  • Run baseline-update only in CI: npx playwright test --update-snapshots as a CI job, never locally.
  • Use Docker locally: docker run mcr.microsoft.com/playwright:v1.59.0 npm run test:visual -- --update-snapshots.

Cross-Browser Snapshot Management

By default, Playwright generates one baseline per browser engine. homepage-chromium-darwin.png, homepage-firefox-darwin.png, homepage-webkit-darwin.png. Engines render text and form controls slightly differently; you can't share one baseline.

For most teams, just run snapshots on Chromium + Linux. That's your baseline. Cross-browser visual differences are usually not the primary risk; cross-browser functional differences are. Test functional behavior on all browsers; visual on one.

// Only run visual tests on chromium project
projects: [
  { name: 'chromium-visual', testMatch: /\.visual\.spec\.ts/, use: devices['Desktop Chrome'] },
  { name: 'chromium', testMatch: /(?!\.visual)\.spec\.ts/, use: devices['Desktop Chrome'] },
  { name: 'firefox', testMatch: /(?!\.visual)\.spec\.ts/, use: devices['Desktop Firefox'] },
]

CI Integration Without a Paid Dashboard

The pattern that works on GitHub Actions:

  1. Run visual tests in CI on PR.
  2. If they fail, upload the HTML report as a CI artifact.
  3. PR description gets a comment with a link to the artifact.
  4. Reviewer downloads the artifact, opens locally, decides intentional vs regression.
  5. If intentional, dev runs --update-snapshots locally and commits.

Not as slick as Percy's GitHub integration, but free.

- name: Run visual tests
  run: npx playwright test --grep visual

- name: Upload report on failure
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 14

- name: Comment on PR with artifact link
  if: failure()
  uses: thollander/actions-comment-pull-request@v2
  with:
    message: |
      Visual tests failed. Download the report from CI artifacts to review diffs.

When to Actually Pay for Applitools/Percy/Chromatic

Three scenarios where the open-source approach doesn't scale:

  1. 1,000+ snapshots. Storing baselines in git, downloading CI artifacts, and reviewing diffs in HTML reports stops working at scale. Commercial dashboards earn their cost here.
  2. Cross-browser visual coverage required. If you must verify visual parity across Chrome, Firefox, Safari, Edge, IE — the commercial tools manage browser farms; you don't want to.
  3. Responsive testing across many viewports. If you snapshot 50 components × 5 viewports = 250 snapshots per change, the per-snapshot cost still beats your engineer's time on infrastructure.

For everything else, Playwright's built-ins plus the workflow above is enough.

FAQs

What about resemble.js or pixelmatch?

They're the engines under Playwright's toHaveScreenshot (pixelmatch specifically). You don't need to use them directly — Playwright wraps them.

How do I handle snapshots for components, not pages?

Use Playwright Component Testing (see my CT post) or Storybook + a Playwright reader. Both give you per-component snapshots.

What about diff sensitivity tuning?

maxDiffPixelRatio for percentage of pixels allowed to differ; threshold for per-pixel sensitivity. Start with the defaults; tune only when you have specific false positives.

How do I commit baselines to a monorepo?

Same as a regular repo. Baselines are PNGs. They diff in git noisily but git LFS handles it if your repo gets large.

Should I run visual tests on every PR?

Run on every PR; only block merge on visual failures for the most critical pages. For most pages, treat visual diffs as informational and let the reviewer decide.

How do I handle dark mode and theme variants?

Capture both. page.emulateMedia({ colorScheme: 'dark' }) before screenshotting. Save as homepage-dark.png.

What about visual regression for emails?

Different tooling. Mailosaur or Litmus. Visual regression on rendered HTML emails is a separate problem from visual regression on web pages.

How do I prevent baseline drift over time?

Code review. Every PR that updates a snapshot must have an explanation in the description. Reviewers check that the intent matches the change.

What about generated/random data in the page?

Mock the data with fixtures. See pattern 4 in my network mocking post.

Can I reuse baselines between team members?

If you all run on the same OS (or all use Docker), yes. Otherwise commit only CI-generated baselines and don't update locally.

Wrap-Up

Visual regression doesn't require an enterprise budget. Playwright's toHaveScreenshot plus a few config tweaks plus a sane CI artifact workflow handles 80% of cases for free. The commercial tools earn their cost when you cross specific thresholds — high snapshot count, multi-browser visual coverage, large team approval workflows. Below those thresholds, open-source is the right call.

If your team is evaluating visual regression and weighing the buy-vs-build decision, that's part of framework engagements. Or book a free call.

Related reading:

Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.

// related_dispatches

YOU MIGHT ALSO READ

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire