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-parser or cheerio for 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 valueNormalization
Content hashes in asset names (app.3f9a1c2e.js)replace with app.[hash].js
Build time in footers or meta tagsreplace with [build-time]
Git commit SHA in "edit this page" linksreplace with [sha]
Absolute preview origin in canonicalsreplace with production origin
Random IDs from components (id="r:3a")replace with sequential placeholders
lastmod dates from Git when history is shallownormalize 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.

Normalization steps before snapshot comparison Built HTML passes through normalization that replaces asset hashes, build times, commit SHAs and preview origins with placeholders. The normalized HTML is then either stored in full for key pages or reduced to a structural summary for every page. The result is compared with the committed snapshot. From build output to a stable snapshot Built HTML dist or public Normalize hashes, times, SHAs, origins Full snapshot 12 key pages Structural summary every page Compare diff shown in the PR
Without normalization every snapshot changes on every build and the diff becomes noise that reviewers learn to ignore.

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.

Structural summaries compared with full snapshots Two panels. Structural summaries cover every page, store title, canonical, heading outline and element counts, total 1.9 megabytes for 2,300 pages, and catch pages that lost sections or metadata. Full snapshots cover 12 key pages, store prettified head and main markup, total 640 kilobytes, and catch attribute and component markup changes. Two kinds of snapshot Structural summary, every page title, canonical, description heading outline link, image, code, table counts 1.9 MB for 2 300 pages Full snapshot, 12 key pages prettified head and main attributes, classes, component markup one per template 640 KB total
Summaries answer how many pages changed; full snapshots answer exactly how a template changed.

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(', ')}`);
Unexpected scope of changes caught by snapshot summaries Horizontal bars for four pull requests caught by snapshot summaries. A Markdown renderer upgrade changed 312 pages through footnote numbering. A sidebar refactor dropped a section on 188 pages. A trailing slash default change altered links on 2,300 pages. A shortcode edit changed 41 pages in an unrelated section. Pages changed by pull requests that should change one page Trailing-slash default change 2,300 pages Markdown renderer upgrade 312 pages Sidebar refactor 188 pages Shortcode edit 41 pages
Each pull request was expected to change zero or one page; reviewers saw the real scope from the summary before merge.

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

MeasureBeforeAfter (12 months)
Unintended multi-page changes reaching production111
Dependency updates with unnoticed output changesunknown0 (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.

Filed underHow-tos