Snapshot Testing Build Output
The riskiest pull requests on a static site are not new pages; they are changes to shared templates, plugins, Markdown configuration and dependencies. An upgrade of the Markdown renderer changes how footnotes are numbered on 300 pages. A refactor of the sidebar partial should change nothing and accidentally drops one section. A generator upgrade changes the default for trailing slashes in generated links. None of these fail a build, and a reviewer looking at a preview checks two or three pages, not three hundred.
Snapshot testing makes the effect of a change on the whole site visible: save a normalized representation of the built output, rebuild on every pull request, and show the diff. This guide sets it up for a Hugo documentation site with 2,300 pages and an Astro site with 410, using full snapshots for a few key pages and compact structural summaries for every page. It is part of Testing Static Sites in CI.
Prerequisites
- A deterministic build: the same input produces the same output apart from known volatile values.
- Node 20+ and Vitest (or Jest) for snapshot storage and diffing.
node-html-parserorcheeriofor extracting structure from HTML.
Step 1: Normalize the Volatile Parts
A snapshot is only useful if an unchanged site produces an unchanged snapshot. Build twice without changes and diff the output to find what varies:
hugo --minify -d /tmp/a && hugo --minify -d /tmp/b && diff -r /tmp/a /tmp/b | head
Typical volatile values and their normalizations:
| Volatile value | Normalization |
|---|---|
Content hashes in asset names (app.3f9a1c2e.js) | replace with app.[hash].js |
| Build time in footers or meta tags | replace with [build-time] |
| Git commit SHA in "edit this page" links | replace with [sha] |
| Absolute preview origin in canonicals | replace with production origin |
Random IDs from components (id="r:3a") | replace with sequential placeholders |
lastmod dates from Git when history is shallow | normalize or fetch full history |
// tests/snapshots/normalize.mjs
export function normalize(html) {
return html
.replace(/\.[0-9a-f]{8,}\.(js|css|woff2|avif|webp|png|svg)/g, '.[hash].$1')
.replace(/data-build-time="[^"]*"/g, 'data-build-time="[build-time]"')
.replace(/\/blob\/[0-9a-f]{7,40}\//g, '/blob/[sha]/')
.replace(/https:\/\/[a-z0-9-]+\.preview\.example\.com/g, 'https://docs.example.com');
}
After normalization, two builds of the same commit produced byte-identical snapshots on both sites.
Step 2: Structural Summaries for Every Page
A full snapshot of 2,300 pages is 70 MB and unreviewable. A structural summary captures what matters about each page in a few lines: title, description, canonical, heading outline, counts of links, images, code blocks and tables, and the IDs of JSON-LD types.
// tests/snapshots/summary.test.mjs
import { test, expect } from 'vitest';
import { globby } from 'globby';
import { parse } from 'node-html-parser';
import { readFile } from 'node:fs/promises';
import { normalize } from './normalize.mjs';
test('site structure', async () => {
const pages = {};
for (const file of (await globby('public/**/*.html')).sort()) {
const root = parse(normalize(await readFile(file, 'utf8')));
pages[file.replace(/^public/, '')] = {
title: root.querySelector('title')?.text,
canonical: root.querySelector('link[rel=canonical]')?.getAttribute('href'),
h: root.querySelectorAll('main h1, main h2, main h3').map((h) => `${h.tagName}:${h.text.trim()}`),
links: root.querySelectorAll('main a[href]').length,
images: root.querySelectorAll('main img').length,
code: root.querySelectorAll('main pre').length,
jsonld: root.querySelectorAll('script[type="application/ld+json"]').length
};
}
expect(pages).toMatchFileSnapshot('./__snapshots__/structure.json');
});
toMatchFileSnapshot stores the snapshot as plain JSON, so its diff in the pull request is readable. The file was 1.9 MB for 2,300 pages, and a typical content change touched a handful of lines in it.
Step 3: Full Snapshots for Key Pages
For a small set of pages — the home page, one page per template, the most complex content pages — store the full normalized HTML of the <main> element and the <head>. These catch the detailed changes summaries miss: attribute changes, class names, the exact markup of a component.
const KEY_PAGES = ['/index.html', '/guide/install/index.html', '/api/client/connect/index.html' /* 12 total */];
for (const page of KEY_PAGES) {
test(`full ${page}`, async () => {
const root = parse(normalize(await readFile(`public${page}`, 'utf8')));
const html = root.querySelector('head').outerHTML + '\n' + root.querySelector('main').outerHTML;
await expect(prettify(html)).toMatchFileSnapshot(`./__snapshots__/full${page}.html`);
});
}
Formatting the HTML with Prettier before snapshotting makes the diff line-oriented; minified HTML on one line produces useless diffs.
Step 4: Review Changes in the Pull Request
In CI, the snapshot tests run without updating. If output changed, the job fails and uploads the diff; the author either fixes an unintended change or updates snapshots intentionally with a labelled job (npm run test:snap -- -u) that commits the new files to the branch. The reviewer then sees code changes and output changes side by side.
A summary in the job output makes the size of a change obvious: "structure.json: 3 pages changed (2 headings, 1 link count)" is reassuring for a content edit; "structure.json: 2,300 pages changed (canonical)" is a stop sign.
// scripts/snapshot-summary.mjs — prints counts of changed pages per field
const before = JSON.parse(execSync('git show origin/main:tests/snapshots/__snapshots__/structure.json'));
const after = JSON.parse(readFileSync('tests/snapshots/__snapshots__/structure.json'));
const changed = {};
for (const [page, s] of Object.entries(after)) {
for (const k of Object.keys(s)) if (JSON.stringify(s[k]) !== JSON.stringify(before[page]?.[k])) (changed[k] ??= []).push(page);
}
for (const [k, pages] of Object.entries(changed)) console.log(`${k}: ${pages.length} page(s) changed, e.g. ${pages.slice(0, 3).join(', ')}`);
Keeping Snapshots Useful
Snapshots decay into noise when they are updated reflexively. Three rules kept them useful on both sites. Snapshot updates must be their own commit, so the diff is easy to review. A snapshot update that touches more than 50 pages needs a sentence in the pull request description explaining why. And content-only pull requests from writers are exempt from full-snapshot tests of unrelated pages: the structural summary is enough, and it keeps writers from having to understand HTML diffs.
Dependency updates from Renovate or Dependabot are where snapshots pay off most. A minor version bump of a Markdown plugin that changes output shows up as a failing snapshot on the bot's pull request, instead of as a mystery months later. On the Hugo site, 7 of 41 dependency updates in a year changed output; 2 of those changes were unwanted and were pinned or patched.
Snapshots also make generator migrations measurable. When the Astro site was evaluated as a replacement for an older Gatsby build, the structural summary of both outputs was diffed page by page: headings, link counts and metadata that differed between the two builds became a checklist, much like the parity audit described in Auditing Content Parity After a Migration.
Finally, keep snapshot files out of the site's search and deploy paths — store them under tests/, never under public/ or static/ — so they are never published by accident.
Measured Impact
| Measure | Before | After (12 months) |
|---|---|---|
| Unintended multi-page changes reaching production | 11 | 1 |
| Dependency updates with unnoticed output changes | unknown | 0 (7 detected, 2 rejected) |
| Snapshot test time per pull request | — | 19 s (Hugo), 6 s (Astro) |
| Snapshot storage in repository | — | 2.5 MB |
| Pull requests needing snapshot updates | — | 18% |
Pitfalls & Rollback
- Unnormalized volatile values. Every run differs and reviewers stop reading diffs.
- Snapshotting minified HTML. One-line diffs are unreadable; prettify first.
- Reflexive updates. An update command in CI that runs automatically defeats the purpose.
- Shallow clones changing dates. Git-derived dates vary with history depth; fetch full history or normalize.
- Rollback: snapshots are test files; deleting them or skipping the job has no effect on the site.
Conclusion
Snapshot testing shows the true scope of a change to a static site. Normalize volatile values so an unchanged site produces an unchanged snapshot, summarize every page structurally, snapshot a few key pages in full, and make the diff part of review. On these sites it caught eleven would-be production surprises a year — most of them from dependency updates nobody expected to change the output.
FAQ
What is snapshot testing for a static site?
Saving a normalized copy or summary of the built output and comparing each new build against it. Any difference is shown in the pull request, so a change that should affect one page but alters two hundred is visible before merge.
Won't snapshots change on every build?
Only if volatile values are left in. Build timestamps, content hashes in asset file names, random IDs and absolute preview URLs must be normalized before comparing. After normalization, a build with no content or template changes produces an identical snapshot.
Should I snapshot every page in full?
No. Full HTML snapshots of thousands of pages are too large to review. Snapshot a small set of key pages in full, and every page as a compact structural summary such as title, headings, link count and metadata.
How do I update snapshots when a change is intentional?
Run the snapshot update command locally or through a labelled CI job, commit the updated snapshot files, and let the reviewer inspect the snapshot diff alongside the code change. The diff is the documentation of what the change did to the site.
Related
- Parent: Testing Static Sites in CI — the rest of the suite.
- Validating HTML Output in CI — validity checks on the same output.
- Testing Structured Data and Meta Tags — asserting specific metadata rules.
- Visual Regression Testing on Preview Deploys — the visual counterpart to HTML snapshots.
- Upgrading Jekyll and Ruby Versions Safely — where output diffs protect upgrades.
Filed underHow-tos