Visual Regression Testing on Preview Deploys
Static sites fail visually more often than they fail functionally. A CSS refactor shifts the sidebar on article pages, a dependency update changes the code block font, a new component pushes the footer over the content on narrow screens. Links still work, builds pass, and the problem is only noticed when a reader reports it. Reviewers looking at a preview deployment catch some of this, but nobody clicks through twelve templates at three screen sizes on every pull request.
Visual regression tests do that clicking. They take screenshots of key pages on the preview deployment, compare them with the same pages on main, and show the differences in the pull request. This guide sets them up with Playwright, chooses pages and viewports, removes the noise that makes teams abandon visual tests, and wires the results into review. It is part of Preview Environments for Pull Requests.
Prerequisites
- Preview deployments per pull request, with a URL available to CI — see Automating Preview Deploy Pipelines with GitHub Actions.
- Node.js and
@playwright/testin the repository. - A bypass token if previews are protected — see Password-Protecting Preview Deployments.
What to Screenshot
A static site's pages come from a small number of templates, and a layout bug in a template shows up on every page that uses it. Cover templates, not pages:
Pick URLs whose content is stable — a reference page that rarely changes is better than the newest blog post. Include at least one page with every component that has broken before: long code blocks, wide tables, callouts, image galleries, embedded video placeholders. Three widths — a phone, a tablet and a laptop — catch almost all responsive bugs. If the site has a dark theme, add it as a separate project with colorScheme: 'dark'.
Setting Up Playwright
Playwright's toHaveScreenshot compares a screenshot with a stored baseline and fails if they differ beyond a threshold:
// tests/visual.spec.ts
import { test, expect } from '@playwright/test';
const pages = ['/', '/docs/', '/docs/install/', '/docs/reference/config/', '/search/', '/missing-page/'];
for (const path of pages) {
test(`visual ${path}`, async ({ page }) => {
await page.goto(path, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot({
fullPage: true,
animations: 'disabled',
mask: [page.locator('[data-visual-mask]')],
maxDiffPixelRatio: 0.001,
});
});
}
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.PREVIEW_URL,
extraHTTPHeaders: process.env.BYPASS_TOKEN ? { 'x-preview-bypass': process.env.BYPASS_TOKEN } : {},
},
projects: [
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
{ name: 'tablet', use: { viewport: { width: 768, height: 1024 } } },
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
],
});
Baselines are stored next to the test file, one per page and project. Commit them to the repository; they are the reference for what the site should look like.
Removing Noise
Visual tests are abandoned when they fail for reasons that are not regressions. Deal with each source of noise up front:
- Font rendering. Different operating systems and even different Linux images render fonts differently. Always run visual tests in the official Playwright Docker image (
mcr.microsoft.com/playwright) pinned to a version, both in CI and when updating baselines locally. - Animations and transitions.
animations: 'disabled'stops CSS animations; also add a test-only stylesheet that setsscroll-behavior: autoand hides blinking cursors. - Lazy images and fonts. Wait for
document.fonts.ready, scroll the page to trigger lazy images or setloading="eager"in a test mode, and wait for network idle. - Dynamic content. Dates, "last updated" lines, randomised testimonials and third-party embeds change between runs. Mark them with a
data-visual-maskattribute in the templates and mask them.
Running It on Previews
Add a job that waits for the preview deployment, then runs the tests against its URL:
visual:
needs: preview
runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.47.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test tests/visual.spec.ts
env:
PREVIEW_URL: ${{ needs.preview.outputs.url }}
BYPASS_TOKEN: ${{ secrets.PREVIEW_BYPASS_TOKEN }}
- if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: test-results/
Run the visual job in parallel with other preview checks such as link checks and smoke tests, not after them, so the slowest check sets the pace rather than their sum. Make it a required check only once false positives are rare; until then, report without blocking so the team builds trust in its results.
When a test fails, Playwright writes the expected, actual and diff images to test-results/. Upload them as an artifact and, ideally, post a comment on the pull request linking the HTML report so reviewers see the diffs without downloading anything.
Reviewing and Updating Baselines
A failing visual test is a question, not a verdict: did you mean to change this? If the change is unintended, fix the CSS. If it is intended — a redesigned header, a new component — update the baselines in the same pull request:
docker run --rm -v "$PWD":/work -w /work mcr.microsoft.com/playwright:v1.47.0-jammy \
npx playwright test tests/visual.spec.ts --update-snapshots
Running the update in the same container as CI prevents font-rendering differences from creeping into the baselines. Reviewers then see the new baseline images in the pull request diff, which GitHub renders side by side, and approve the visual change explicitly.
Some teams prefer not to store baselines in Git at all, because design-heavy repositories accumulate many megabytes of images over time. The alternative is to screenshot the main branch preview (or production) in the same job and compare the pull request against those images on the fly. It avoids baseline maintenance and never goes stale, at the cost of twice the screenshots per run and a dependency on main being healthy. Hosted services such as Percy, Chromatic and Argos follow this model and add a review interface on top; they are worth considering once the number of screenshots grows beyond what a pull request diff shows comfortably.
Measured Impact
A documentation team added 21 screenshots across seven templates and three viewports to their pull request pipeline. The job ran in about 90 seconds after the preview was ready. In the first quarter it caught 11 unintended layout changes before merge, including a table overflow on mobile introduced by a Tailwind upgrade and a missing dark-mode background on code blocks. False positives fell below 1% after masking two "last updated" dates and a GitHub star counter. Reader-reported layout bugs dropped from about four a month to one a quarter.
Pitfalls & Rollback
- Baselines from a different environment. Screenshots from a Mac never match Linux CI; always update in the container.
- Too many pages. Hundreds of screenshots make runs slow and reviews tedious; cover templates.
- Low thresholds on huge pages. A tiny ratio on a very long page can fail on one-pixel anti-aliasing; tune per test.
- Protected previews. Without the bypass token every screenshot is the login page, which all match each other perfectly.
- Rollback: make the job non-required, or remove it; nothing else depends on it.
Conclusion
Visual regression tests catch the bugs that static sites are most prone to — layout, spacing, overflow, theme — and preview deployments give them a real environment to run against. Screenshot one or two pages per template at three widths with Playwright, run in a pinned container, disable animations, wait for fonts and images, mask dynamic content, and review diffs in the pull request. Updated baselines then become an explicit, reviewable record of every intended design change.
FAQ
Which pages should a visual regression suite cover on a static site?
One or two representative URLs per template - home, section index, article, a long article with code and tables, search and 404 - at two or three viewport widths. Covering every page adds cost and noise without catching more layout bugs, because pages sharing a template break together.
Why do visual tests produce false positives?
Differences in font rendering between machines, animations, lazy-loaded images, dates, randomised content and third-party embeds all change pixels without a real regression. Run tests in a fixed container, disable animations, wait for fonts and images, and mask dynamic regions.
Should visual tests run against the preview URL or a local build?
Against the preview when you want to test exactly what will ship, including headers, redirects and CDN behaviour. A local build served in CI is faster and avoids authentication, and is a good choice when previews are slow or protected.
Where do the baseline screenshots come from?
From main. Either commit baselines to the repository and update them in the pull request that intentionally changes the design, or generate them on each run from the production site or the main branch preview and compare the pull request against that.
Related
- Parent: Preview Environments for Pull Requests — the full preview workflow.
- Running Smoke Tests Against a Preview URL — functional checks alongside visual ones.
- Password-Protecting Preview Deployments — the bypass token tests need.
- Running WebPageTest Scripts Against Preview Deploys — performance checks on the same previews.
- Evaluating SSG Accessibility Defaults — pairing visual tests with accessibility checks.