Snapshot Testing vs Approval Testing: A Practical Comparison
"Snapshot testing" and "approval testing" describe the same fundamental idea: capture output, store it, and detect future deviations. But the two approaches differ in important ways—their review process, tooling philosophy, and best-fit use cases. Conflating them leads to poor test design choices.
This comparison covers how they differ, where each excels, and how to pick the right one.
The Shared Foundation
Both approaches follow the same three-step cycle:
- Capture: run code, capture output
- Store: save output as the reference
- Compare: on future runs, compare current output to stored reference
Both detect unintended changes. Both require human judgment when output changes—is this a regression or an intentional update?
The difference is in how that judgment is exercised and when it happens.
Snapshot Testing
Snapshot testing is most commonly associated with Jest's toMatchSnapshot(), though other tools support it.
How it works
// Jest snapshot test
it("renders user card correctly", () => {
const component = render(<UserCard name="Alice" plan="Pro" />);
expect(component.toJSON()).toMatchSnapshot();
});First run: Jest creates a .snap file automatically.
// UserCard.test.js.snap
exports[`renders user card correctly 1`] = `
<div className="user-card">
<h2>Alice</h2>
<span className="plan">Pro</span>
</div>
`;Subsequent runs: Jest compares the rendered output to the .snap file. Any difference fails the test.
Updating snapshots
When you change the component intentionally:
jest --updateSnapshot
# or
jest -uThis updates all failing snapshots in one command. No per-test review process.
Characteristics
- Automatic capture: no explicit approval step—the first run creates the snapshot
- Bulk update:
--updateSnapshotupdates everything at once - Inline snapshots: can be embedded in the test file itself
- Framework-coupled: tightly integrated with the test runner (Jest, Vitest)
- Inline review: the diff appears in the test output, not a separate diff tool
Approval Testing
Approval testing follows a more deliberate process. The ApprovalTests library (available in Java, .NET, JavaScript, Python, Ruby) is the most common implementation.
How it works
import { verify } from "approvals/lib/Providers/Jest/JestApprovals";
it("generates invoice", () => {
const invoice = generateInvoice(order);
verify(JSON.stringify(invoice, null, 2));
});First run: test fails. The library writes a .received.txt file and opens a diff tool for review.
tests/__approved__/
InvoiceTest.generates_invoice.approved.txt ← doesn't exist yet
InvoiceTest.generates_invoice.received.txt ← current outputYou review the diff in your preferred tool (Beyond Compare, Kaleidoscope, VS Code). If correct, you copy received to approved (or click "accept" in the diff tool).
Subsequent runs: received is compared to approved. Mismatch = failure + diff.
Characteristics
- Explicit approval: each test output must be individually approved
- Diff-tool integration: review happens in a dedicated diff tool
- Per-test control: you approve each test's output independently
- Framework-agnostic: works with any test runner
- Separate files:
.received.txtand.approved.txtare distinct
Side-by-Side Comparison
| Dimension | Snapshot Testing | Approval Testing |
|---|---|---|
| First capture | Automatic on first run | Manual approval required |
| Update workflow | --updateSnapshot (bulk) |
Per-test approval via diff tool |
| Review granularity | All-at-once or per-file | Per test |
| Diff tool | Terminal/IDE inline | External diff tool (configurable) |
| File format | .snap (Jest-specific) |
.approved.txt (plain text) |
| Framework coupling | Tight (Jest, Vitest) | Loose (works with any runner) |
| Output types | JSON, component trees | Any string, text, JSON |
| Best for | UI components, React trees | Complex objects, reports, text |
| Human oversight | Low (easy to --updateSnapshot) |
High (explicit approval step) |
The Key Philosophical Difference
Snapshot testing optimizes for speed. The workflow is: run, auto-capture, update when needed. The mental model is "approve unless proven wrong."
Approval testing optimizes for deliberateness. The workflow is: run, review, explicitly accept. The mental model is "reject unless proven right."
This matters when output changes frequently:
- With snapshots, developers often
--updateSnapshotwithout carefully reviewing—especially on large refactors where dozens of snapshots fail simultaneously - With approval testing, every change requires a deliberate review action
When to Use Snapshot Testing
UI component testing. Snapshot testing is purpose-built for this. React component trees, rendered HTML—snapshot testing captures them naturally.
it("renders empty state", () => {
const { container } = render(<UserList users={[]} />);
expect(container).toMatchSnapshot();
});Output that changes frequently but predictably. When UI changes are common and review is handled in code review (the diff in the PR), snapshot --updateSnapshot is efficient.
Tightly integrated toolchains. Jest projects with React or Vue benefit from zero-configuration snapshot support.
When to Use Approval Testing
Complex non-UI output. Generated reports, formatted text, API response structures, serialized domain objects—approval testing handles these better.
verify(generateMonthlyReport(data)); // complex multi-page textHigh-stakes correctness requirements. When updates require explicit human sign-off (not just --updateSnapshot), approval testing enforces the review step.
Legacy code characterization. Approval testing is a natural fit for capturing existing behavior before refactoring.
Mixed-language codebases. ApprovalTests has consistent behavior across Java, .NET, Python, JavaScript—snapshot tools are framework-specific.
The "Snapshot Trap"
The most common complaint about snapshot testing:
Snapshot summary
› 47 snapshots failed.
Run with --updateSnapshot to update them.When a refactor or dependency update breaks 47 snapshots simultaneously, the common response is to --updateSnapshot all of them without carefully reviewing each one. Regressions hide in the noise.
Approval testing avoids this by requiring per-test approval. But it also slows you down when changes are legitimate and widespread.
Mitigation for snapshot testing:
- Keep snapshots small and focused—one component, one state
- Use
--cimode in CI to fail on any snapshot mismatch (never auto-update) - Review snapshot diffs carefully in code review
Combining Both
The approaches aren't mutually exclusive. Many teams use:
- Snapshot testing for React components (fast, tightly integrated)
- Approval testing for backend output: API responses, generated files, reports
// Component: snapshot test
it("renders invoice", () => {
expect(render(<InvoicePage data={invoiceData} />).toJSON()).toMatchSnapshot();
});
// Generator: approval test
it("generates invoice JSON", () => {
verify(JSON.stringify(generateInvoice(orderData), null, 2));
});Beyond Output Comparison
Both snapshot and approval tests verify internal output. They don't verify the full user experience—the HTTP layer, the browser, the real rendering stack.
HelpMeTest complements both with end-to-end tests written in plain English. When your snapshots confirm the component renders correctly in isolation, and your approval tests confirm the API returns the right JSON, HelpMeTest confirms the full flow works for actual users.
Summary
| Choose | When |
|---|---|
| Snapshot testing | React/Vue components, Jest-based projects, frequently changing UI |
| Approval testing | Complex text output, high-stakes reviews, legacy characterization, multi-language teams |
| Both | Large projects with distinct UI and backend output testing needs |
The difference isn't which output to capture—it's how deliberately you want to approve it. Snapshot testing is fast and easy to skip. Approval testing is deliberate and harder to bypass.