Snapshot Testing React Components: Jest, React Testing Library, and What Actually Works
React component testing has evolved considerably. Enzyme snapshots gave way to React Testing Library, and the community's view on snapshot testing shifted along with it. The current reality: snapshots aren't obsolete, but their role is more specific than it once was. Understanding what they're good for in a React context — and where RTL assertions do better — will save you from maintaining a snapshot suite that no one trusts.
Basic Component Snapshots with Jest
The simplest snapshot test renders a component and captures the output:
import { render } from '@testing-library/react';
import Alert from './Alert';
test('renders error alert', () => {
const { container } = render(
<Alert variant="error" message="Something went wrong" />
);
expect(container).toMatchSnapshot();
});The first run creates a .snap file. Subsequent runs compare output to that file. If the component's markup changes for any reason — a new class, a restructured element, an added attribute — the test fails.
What this captures: the full DOM structure, all class names, all HTML attributes, all rendered text. What it doesn't capture: event handlers (they don't serialize), computed styles, or actual behavior.
Snapshots with render vs asFragment
Using container in your snapshot includes the wrapper <div> that RTL creates. Using asFragment() gives you a cleaner DocumentFragment without that wrapper:
test('renders card component', () => {
const { asFragment } = render(<Card title="Hello" body="World" />);
expect(asFragment()).toMatchSnapshot();
});The difference is cosmetic but matters for readability. asFragment() snapshots more closely represent what's actually rendered. Use whichever is more readable for your team — just be consistent.
The styled-components Problem
If you use styled-components, default snapshots show generated class names like sc-bdfxgF eBzFQa. These change whenever the component's styles change, or sometimes just because the insertion order of other components changed. Your snapshot breaks constantly for reasons unrelated to the component you're testing.
The fix is the styled-components serializer:
npm install --save-dev jest-styled-componentsimport 'jest-styled-components';
test('renders styled button', () => {
const { container } = render(<StyledButton>Click me</StyledButton>);
expect(container).toMatchSnapshot();
});With the serializer, snapshots show the actual CSS rules:
.c0 {
background-color: #0070f3;
color: white;
padding: 8px 16px;
border-radius: 4px;
}Now your snapshot is meaningful: it breaks when the CSS changes, not when class name generation changes.
Snapshotting State Changes
Snapshots can capture different component states by rendering with different props or simulating interactions:
import { render, fireEvent } from '@testing-library/react';
import Accordion from './Accordion';
test('renders collapsed state', () => {
const { container } = render(<Accordion title="Details" />);
expect(container).toMatchSnapshot();
});
test('renders expanded state', () => {
const { container } = render(<Accordion title="Details" />);
fireEvent.click(container.querySelector('.accordion-header'));
expect(container).toMatchSnapshot();
});Each snapshot captures a distinct state. When the component's expanded markup changes, only the expanded snapshot fails — clear signal about what broke.
When RTL Assertions Beat Snapshots
For behavioral assertions, React Testing Library's query-based approach is more explicit and more resilient than snapshots.
Snapshot approach (fragile):
test('shows error message', () => {
const { container } = render(<LoginForm />);
fireEvent.submit(container.querySelector('form'));
expect(container).toMatchSnapshot(); // Is the error in there? Hard to tell.
});RTL approach (explicit):
test('shows error message when form submitted empty', async () => {
render(<LoginForm />);
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
expect(screen.getByRole('alert')).toHaveTextContent('Email is required');
});The RTL version communicates intent. When it fails, you know exactly what's missing. The snapshot version might catch the same regression, but the failure message — "snapshot doesn't match" — tells you less.
RTL wins for:
- Testing that actions produce expected results
- Asserting on specific text content or element presence
- Accessibility-focused assertions (roles, labels, descriptions)
- Testing user interactions end to end
Snapshots win for:
- Tracking structural changes to component markup over time
- Establishing baselines for stable, infrequently-changed components
- Catching accidental regressions across large component trees
A Practical Snapshot Strategy for React
Rather than all-or-nothing, use snapshots deliberately:
Snapshot the stable shell, test the dynamic parts with RTL:
describe('ProductCard', () => {
test('snapshot — stable markup structure', () => {
const { asFragment } = render(
<ProductCard name="Widget" price={29.99} inStock={true} />
);
expect(asFragment()).toMatchSnapshot();
});
test('shows out of stock badge when not in stock', () => {
render(<ProductCard name="Widget" price={29.99} inStock={false} />);
expect(screen.getByText('Out of Stock')).toBeInTheDocument();
});
test('add to cart button is disabled when out of stock', () => {
render(<ProductCard name="Widget" price={29.99} inStock={false} />);
expect(screen.getByRole('button', { name: 'Add to Cart' })).toBeDisabled();
});
});The snapshot protects the overall structure. The RTL tests verify specific behaviors. Both run in CI. If the card's markup changes, the snapshot fails and prompts a review. If the out-of-stock logic breaks, the RTL test fails with a clear message.
Inline Snapshots for Small Components
For small, leaf-level components, inline snapshots keep the expected output visible in the test file:
test('renders badge with count', () => {
const { container } = render(<Badge count={5} />);
expect(container).toMatchInlineSnapshot(`
<div>
<span
class="badge"
>
5
</span>
</div>
`);
});Inline snapshots are self-documenting: you see the assertion without opening a .snap file. Keep them for components with predictable, rarely-changing output.
What Not to Snapshot
Connected components with complex state. Redux-connected or context-dependent components require significant mocking setup, and the snapshots often include provider wrapper markup that obscures the actual component structure.
Components with dynamic IDs or timestamps. If your component renders <div id="tooltip-1234567890">, the snapshot fails on every run because the ID is generated from Date.now(). Mock those values or use a stable ID strategy before snapshotting.
Large page-level components. A snapshot of your entire checkout page is hundreds of lines of serialized HTML that no reviewer will actually read. Snapshot leaf components and test page composition with RTL or integration tests.
The Bottom Line
Snapshot testing React components works best when you're deliberate about scope. Small, presentational components with predictable output benefit from snapshots — they're easy to review and catch real regressions. Behavioral tests belong in RTL. Large trees and dynamic content are better served by integration tests or visual regression tools that capture actual rendered appearance rather than DOM structure.
The instinct to snapshot everything is understandable — one line of code, comprehensive coverage. But coverage without signal isn't valuable. A snapshot suite that developers update without reading is worse than no snapshots at all.