JavaScript and React course Β· Module 13: Testing React

Snapshot Testing - Constellation Photograph

9 min read
In this lesson8

Imagine that after every code change you had to check by hand whether the mission status badge still has the same classes, text and structure. With dozens of components nobody would do it. Snapshot testing does it for you: it "photographs" the rendering result of a component and compares it with a saved reference. It's like taking a picture of a star constellation - if any star has shifted the next time you look, the test will detect it.

How Do Snapshots Work?

The mechanism is simple. On the first run of the test, Jest renders the component and saves the resulting HTML to a .snap file in the __snapshots__ folder, next to the test file. On subsequent runs, Jest re-renders the component and compares the result with the saved reference. If something changed, the test fails. It's like comparing two constellation photos taken at different times - if the stars have shifted, you'll notice immediately.

Here's a basic example. We render the component with render from React Testing Library and pass the first element of the container to toMatchSnapshot():

1import { render } from '@testing-library/react';
2import StatusBadge from './StatusBadge';
3
4test('renders active status badge', () => {
5  const { container } = render(<StatusBadge status="active" />);
6  expect(container.firstChild).toMatchSnapshot();
7});

container is the div element into which RTL inserts the component, and container.firstChild is the root of the component itself, here the span tag. You can write expect(asFragment()).toMatchSnapshot() instead: the asFragment function from the render result returns the whole rendered fragment, wrapped in <DocumentFragment>.

In older tutorials you will see snapshots made with react-test-renderer (renderer.create(...).toJSON()). In React 19 this package is deprecated and logs a warning, and the React team recommends React Testing Library instead. That is why in this course a snapshot always comes from the render result.

Generated Snapshot File (.snap)

After the first run, a file named after the test file with the .snap extension appears in the __snapshots__ folder. Jest stores every snapshot in it under the test name and a sequence number:

1// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
3exports[`renders active status badge 1`] = `
4<span
5  class="badge badge-active"
6>
7  Active
8</span>
9`;

You commit the .snap file to the repository together with the test, because it is the reference every later run is compared with. Notice that it stores ready HTML with the class attribute, not JSX with className.

Inline Snapshots

Instead of a separate file, the snapshot can be saved directly in the test. Take a fuel gauge that builds its label as one piece of text:

1function FuelDisplay({ level }) {
2  return (
3    <div className="fuel-display">
4      <span>{`Fuel: ${level}%`}</span>
5    </div>
6  );
7}

We build the label with a template literal, because writing Fuel: {level}% would give three separate lines in the snapshot: every text fragment in JSX is a separate node. The test with toMatchInlineSnapshot looks like this:

1test('renders fuel display', () => {
2  const { container } = render(<FuelDisplay level={75} />);
3  expect(container.firstChild).toMatchInlineSnapshot(`
4    <div
5      class="fuel-display"
6    >
7      <span>
8        Fuel: 75%
9      </span>
10    </div>
11  `);
12});

On the first run it is enough to write an empty toMatchInlineSnapshot(), and Jest writes the reference into the test file for you. An inline snapshot is handy for small components, because the reference sits right next to the test.

Updating Snapshots

When you change a component on purpose, the old reference no longer matches and the test fails. Then you update the snapshots with the --updateSnapshot flag (short -u). The double dash passes the flag through npm test on to Jest:

1# Update all snapshots
2npm test -- --updateSnapshot
3
4# Or shorthand
5npm test -- -u

Before you update, read the diff that Jest showed. The update overwrites the reference without asking, so if the change was a bug, you have just approved it.

When to Use Snapshots?

Good Use Cases

  • Presentational components - simple components without logic
  • Resulting JSX tree - checking HTML structure
  • Serializable data - objects, arrays, JSON

A planet card is a model candidate: it receives props and only displays them, so the same set of props always produces the same HTML:

1// Good snapshot: simple component
2test('renders planet card', () => {
3  const { container } = render(
4    <PlanetCard name="Mars" distance="225M km" color="red" />
5  );
6  expect(container.firstChild).toMatchSnapshot();
7});

Bad Use Cases

  • Components with dynamic logic - snapshot doesn't test behavior
  • Large components - snapshots become hard to review
  • Components with dates/random IDs - snapshot always changes

The most common mistake is a snapshot of a whole screen. The file then has hundreds of lines, and every small change forces someone to review them:

1// BAD snapshot: too large, too dynamic
2test('renders entire dashboard', () => {
3  const { container } = render(<Dashboard />); // 500 lines of HTML!
4  expect(container).toMatchSnapshot(); // Nobody will read this!
5});

Such a test fails on every change and does not tell you what broke, so people quickly start updating it without reading.

Snapshot or Targeted Assertions?

This is a fundamental comparison that every React developer should understand. A snapshot checks the entire structure of a component - every tag, class, and attribute. A targeted assertion checks specific behavior - whether text is visible, whether a button is active, whether an element has the right class. The difference is like taking a photo of the entire control panel versus checking whether a specific indicator light is green.

1// Snapshot: checks the ENTIRE structure
2expect(container.firstChild).toMatchSnapshot();
3
4// Targeted assertion: checks SPECIFIC behavior (recommended!)
5expect(screen.getByText('Active')).toHaveClass('badge-active');
6expect(screen.getByRole('button')).toBeEnabled();

A targeted assertion also states directly what a snapshot would not explain. In a sortable table, the column header tells screen readers the sort direction with the aria-sort attribute, and the test checks it in one statement:

1expect(screen.getByRole('columnheader', { name: /name/i }))
2  .toHaveAttribute('aria-sort', 'ascending');

A snapshot would also store aria-sort, but it would get lost among hundreds of other attributes. A targeted assertion names the behavior you protect, and its failure message tells you right away what broke.

Rule: Prefer targeted assertions over snapshots. Snapshots easily become "invisible" - developers often update them automatically with the -u command without checking what changed. Targeted assertions, on the other hand, require a conscious check of specific behavior. Snapshots are good as additional protection against unexpected changes in the HTML structure, but should not be the main way of testing. Also remember that a DOM snapshot does not see CSS styles, so only tests that compare screenshots catch changes in appearance.

Data That Changes on Every Run

Random IDs and the current date make the snapshot different on every run. For objects, Jest has property matchers for this: in place of the changing fields you give a type instead of a value. The createMissionLog function creates a log entry with a random id and a creation date:

1test('creates a mission log', () => {
2  const log = createMissionLog('Artemis III');
3
4  expect(log).toMatchSnapshot({
5    id: expect.any(String),
6    createdAt: expect.any(Date),
7  });
8});

Instead of concrete values the .snap file will contain Any<String> and Any<Date>, while the remaining fields, such as the mission name, are still compared exactly. In a component that shows today's date, freeze the clock with jest.useFakeTimers() and jest.setSystemTime(new Date('2030-07-20')), so that every run sees the same day.

The second tool is serializers, which decide how a value is written to the .snap file. The one below replaces ship numbers in strings with a fixed marker:

1// In the jest.setup.js file
2expect.addSnapshotSerializer({
3  test: (val) => typeof val === 'string' && /^ship-\d+$/.test(val),
4  print: () => '"ship-<id>"',
5});

The test function picks the values the serializer handles, and print returns how they are written. Beware of a test that is too broad: a serializer that takes over every element with a CSS class writes one line instead of the whole component structure, and the snapshot stops guarding anything.

Snapshot of a Selected Part

You do not have to photograph the whole component. When you guard only one part, for example the list of links in the navigation, take a snapshot of that element alone:

1test('renders navigation with correct structure', () => {
2  const routes = [
3    { path: '/mars', label: 'Mars' },
4    { path: '/europa', label: 'Europa' },
5  ];
6  render(<SpaceNavigation routes={routes} />);
7
8  // Snapshot of only part of the component
9  expect(screen.getByRole('navigation')).toMatchSnapshot();
10});

getByRole('navigation') finds the <nav> element the way a screen reader sees it, so the snapshot covers only the menu. A smaller reference is easier to review, and changes in the rest of the component do not touch it.

Summary - When Snapshot, When Not?

SituationSnapshotTargeted assertion
Simple UI componentYesYes
Business logicNoYes
User interactionsNoYes
Data formattingYesYes
Large componentNoYes
Unintended change in the HTML structureYesNo

Look back at the path you have traveled in this module. We started with a pure function test, then came a component rendering test, a user interaction test and a test of an asynchronous component with a mocked API. At the top stands the integration test, which checks several components at once - you will build it in the main project. A snapshot is an addition to each of these levels, not a replacement for them.

My advice: treat a snapshot as a control photo, not as proof of correctness, and read every diff before you type -u. Remember: a snapshot makes sure the constellation has not shifted, but it is targeted assertions that tell you whether the ship is flying in the right direction.

Code for this lesson: App.jsx
1import React, { useState, useRef, useLayoutEffect } from 'react';
2
3// Presentational components: they receive props and only display them,
4// which makes them good candidates for snapshots
5
6const STATUS_LABELS = {
7  active: 'active',
8  completed: 'completed',
9  planned: 'planned',
10  critical: 'critical',
11};
12
13function StatusBadge({ status, outlined = false }) {
14  const className = `status-badge badge-${status}${outlined ? ' outlined' : ''}`;
15  return <span className={className}>{STATUS_LABELS[status].toUpperCase()}</span>;
16}
17
18function PlanetCard({ name, distance, diameter, moons }) {
19  return (
20    <div className="planet-card">
21      <div className="planet-icon">{name[0]}</div>
22      <div className="planet-info">
23        <h3>{name}</h3>
24        <div className="planet-stats">
25          <span>Distance: {distance}</span>
26          <span>Diameter: {diameter}</span>
27          <span>Moons: {moons}</span>
28        </div>
29      </div>
30    </div>
31  );
32}
33
34function CrewTable({ members }) {
35  return (
36    <table className="crew-table">
37      <thead>
38        <tr>
39          <th>Name</th>
40          <th>Role</th>
41          <th>Mission status</th>
42        </tr>
43      </thead>
44      <tbody>
45        {members.map(m => (
46          <tr key={m.name}>
47            <td>{m.name}</td>
48            <td>{m.role}</td>
49            <td><StatusBadge status={m.status} /></td>
50          </tr>
51        ))}
52      </tbody>
53    </table>
54  );
55}
56
57// Lab: the first run saves the reference, later runs compare against it
58function SnapshotLab() {
59  const [outlined, setOutlined] = useState(false);
60  const [snap, setSnap] = useState(null);
61  const [markup, setMarkup] = useState('');
62  const [result, setResult] = useState('The test has not run yet.');
63  const previewRef = useRef(null);
64
65  useLayoutEffect(() => {
66    setMarkup(previewRef.current.innerHTML);
67  }, [outlined]);
68
69  const runTest = () => {
70    if (snap === null) {
71      setSnap(markup);
72      setResult('First run: Jest saved the reference in the .snap file.');
73    } else if (snap === markup) {
74      setResult('PASS: the output matches the saved snapshot.');
75    } else {
76      setResult('FAIL: the output differs from the snapshot. A bug or an intended change?');
77    }
78  };
79
80  const updateSnapshot = () => {
81    setSnap(markup);
82    setResult('Snapshot updated (npm test -- -u).');
83  };
84
85  return (
86    <div className="section">
87      <h2>Snapshot lab</h2>
88      <div ref={previewRef} className="lab-preview">
89        <StatusBadge status="active" outlined={outlined} />
90      </div>
91      <pre className="markup">{markup}</pre>
92      <div className="btn-row">
93        <button onClick={runTest}>Run the test</button>
94        <button onClick={() => setOutlined(o => !o)}>Change the component code</button>
95        <button onClick={updateSnapshot} disabled={snap === null}>Update the snapshot</button>
96      </div>
97      <p className={result.startsWith('FAIL') ? 'lab-result fail' : 'lab-result'} role="status">
98        {result}
99      </p>
100    </div>
101  );
102}
103
104export default function App() {
105  const planets = [
106    { name: 'Mars', distance: '225M km', diameter: '6,779 km', moons: 2 },
107    { name: 'Jupiter', distance: '778M km', diameter: '139,820 km', moons: 95 },
108    { name: 'Saturn', distance: '1.4B km', diameter: '116,460 km', moons: 146 },
109  ];
110
111  const crew = [
112    { name: 'Commander Nova', role: 'captain', status: 'active' },
113    { name: 'Astro', role: 'pilot', status: 'planned' },
114    { name: 'Engineer Bolt', role: 'engineer', status: 'completed' },
115  ];
116
117  return (
118    <div className="app">
119      <h1>Snapshot testing</h1>
120      <p className="subtitle">A constellation photo: comparing the component structure</p>
121
122      <SnapshotLab />
123
124      <div className="section">
125        <h2>Status badges</h2>
126        <div className="badge-row">
127          <StatusBadge status="active" />
128          <StatusBadge status="completed" />
129          <StatusBadge status="planned" />
130          <StatusBadge status="critical" />
131        </div>
132      </div>
133
134      <div className="section">
135        <h2>Planet cards</h2>
136        {planets.map(p => <PlanetCard key={p.name} {...p} />)}
137      </div>
138
139      <div className="section">
140        <h2>Crew table</h2>
141        <CrewTable members={crew} />
142      </div>
143
144      <div className="hint">
145        Snapshots work well for simple presentational components like the ones above.
146        Check behavior (clicks, API data) with targeted assertions.
147      </div>
148    </div>
149  );
150}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. When is snapshot testing the BEST choice?

  2. 2. What is the main philosophy of React Testing Library?

Hands-on tasks in the game

  • Vertical ordering

    Arrange the stages of a snapshot's lifecycle in the correct order:

  • Horizontal ordering

    Arrange the elements of the snapshot test syntax in the correct order:

  • Click in order

    Arrange the elements of the snapshot update command in the correct order:

  • Vertical ordering

    Arrange RTL selectors from highest priority (best) to lowest:

  • Horizontal ordering

    Arrange the elements of the waitFor syntax with an asynchronous assertion in the correct order:

  • Click in order

    Arrange the elements of the Jest timer mocking syntax in the correct order:

  • Vertical ordering

    Arrange the testing types from simplest to most complex:

  • Click in order

    Arrange the elements of the act() syntax with renderHook in the correct order:

  • Code editor

    Finish the DataTable table of the planet catalogue. The column headers are buttons inside th, and th has aria-sort ('ascending', 'descending' or 'none'). ___BLANK1___: before sorting, make a copy of the rows array, because sort() changes the array you call it on, and props must not be changed. ___BLANK2___: a second click on the same header changes the direction to 'descending' (the next one back to 'ascending'). ___BLANK3___: when the rows array is empty, a message with role="status" is shown instead of the table. The test clicks the headers, reads the order of the row cells (the row and cell roles) and checks aria-sort.

  • Vertical ordering

    Arrange the stages of setting up a React testing environment in the correct order:

Useful articles