Jest’s snapshot testing is a surprisingly powerful way to catch unintended UI regressions by treating your UI components like test subjects and their rendered output like DNA.
Let’s see it in action. Imagine a simple button component:
// Button.jsx
import React from 'react';
function Button({ children, onClick, disabled }) {
return (
<button onClick={onClick} disabled={disabled} className="my-button">
{children}
</button>
);
}
export default Button;
And its corresponding test file:
// Button.test.jsx
import React from 'react';
import renderer from 'react-test-renderer';
import Button from './Button';
it('renders correctly', () => {
const tree = renderer
.create(<Button onClick={() => {}}>Click Me</Button>)
.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders disabled correctly', () => {
const tree = renderer
.create(<Button onClick={() => {}} disabled>Disabled Button</Button>)
.toJSON();
expect(tree).toMatchSnapshot();
});
When you run npm test (or your test runner command), Jest will execute these tests. The first time, it won’t have any snapshots to compare against. So, it will create them. You’ll see a new directory, __snapshots__, appear next to your test file, containing Button.test.jsx.snap.
Inside Button.test.jsx.snap, you’ll find something like this:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<button
className="my-button"
onClick={[Function onClick]}
>
Click Me
</button>
`;
exports[`renders disabled correctly 1`] = `
<button
className="my-button"
disabled={true}
onClick={[Function onClick]}
>
Disabled Button
</button>
`;
This is the "DNA" of your button component at that specific moment. Now, let’s say a developer, perhaps with good intentions, changes the Button component:
// Button.jsx (modified)
import React from 'react';
function Button({ children, onClick, disabled }) {
// Oops, accidentally added a class!
return (
<button onClick={onClick} disabled={disabled} className="my-button updated-style">
{children}
</button>
);
}
export default Button;
If you run npm test again, Jest will compare the current rendered output of the Button component against the saved snapshot. It will find a difference and fail the test. The output will look like this:
● Button › renders correctly
The update captured in this snapshot differs from the received snapshot.
See https://goo.gl/fbAQLP for help with snapshot updates.
Encountered keys: className
Difference:
- className: "my-button updated-style"
+ className: "my-button"
Snapshot:
<button
className="my-button"
onClick={[Function onClick]}
>
Click Me
</button>
Received:
<button
className="my-button updated-style"
onClick={[Function onClick]}
>
Click Me
</button>
Test Suites: 1 failed, 1 total
Tests: 1 failed, 2 total
This output clearly shows that the className property has changed. The test doesn’t know if this change is good or bad, but it does know that a change occurred. This forces the developer to consciously review the diff and decide if the change is intentional. If it is, they run npm test -- -u to update the snapshot. If it’s not, they revert the code.
This mechanism tackles the problem of visual regressions – those subtle, often unnoticed, changes in your application’s appearance that can accumulate over time and erode user experience. Instead of relying on manual visual checks (which are tedious and error-prone), snapshot tests provide an automated, objective way to detect these deviations.
The magic lies in how Jest serializes the React element tree into a readable string format. It’s not just comparing the HTML string; it’s a structured representation that captures attributes, props, and children. This makes the diffs much more precise than a simple string comparison.
The core idea is to treat your UI components as pure functions of their props and state. Given the same inputs, a component should always produce the same output. Snapshot tests verify this by capturing that output.
You can apply this to more complex scenarios too. Imagine a form with various input fields and error messages. A snapshot of the entire form component, including its conditional rendering of error states, will catch any unexpected changes in layout, text, or the presence/absence of elements.
// Form.jsx
import React, { useState } from 'react';
function Form() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const handleChange = (e) => {
setName(e.target.value);
if (e.target.value.length < 3) {
setError('Name must be at least 3 characters');
} else {
setError('');
}
};
return (
<div>
<label htmlFor="name">Name:</label>
<input id="name" type="text" value={name} onChange={handleChange} />
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
export default Form;
// Form.test.jsx
import React from 'react';
import renderer from 'react-test-renderer';
import Form from './Form';
it('renders initial state', () => {
const tree = renderer.create(<Form />).toJSON();
expect(tree).toMatchSnapshot();
});
it('shows error when name is too short', () => {
const component = renderer.create(<Form />);
const instance = component.getInstance();
// Simulate typing "a"
instance.handleChange({ target: { value: 'a' } });
// Force update to re-render with new state
component.update(component.toTree());
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
After running these tests for the first time, you’ll get snapshots for both the initial form state and the state with the error message. If, for example, the error message text or its styling changes, the snapshot test will fail, prompting review.
A common misconception is that snapshot tests replace visual regression testing tools that render to the DOM and compare pixel-perfect images. While related, snapshot tests are more about the structure and props of your rendered output. They are excellent for catching changes in component logic, conditional rendering, or prop propagation. For highly visual, pixel-level accuracy, tools like Percy or Chromatic are better suited, often integrating with Jest snapshots as a first line of defense.
The single most powerful aspect of snapshot testing, and also its biggest pitfall, is that it doesn’t judge the correctness of the change, only its existence. This is a feature, not a bug. It removes subjective decision-making from the automated test and places it squarely on the developer’s shoulders during the review process. The test’s job is to alert you that something changed; your job is to determine if that something is what you intended.
The next hurdle you’ll encounter is managing large, complex snapshots and understanding how to effectively update them when intentional changes occur across many components.