Brevity is the soul of wit
- Polonius, Hamlet
…and what I like to think Shakespeare was hinting at (with remarkable prescience) is an important lesson about snapshot testing: keep your snapshots small, to the point, and focussed on the key details.
Snapshot Testing
Snapshot testing lets us render a React component to a custom format (e.g. JSON) that is committed alongside the tests and allows us to see how the rendered output will change as our implementation evolves. The idea is that a human will see a change to the snapshot and determine whether or not it is correct.
Snapshots are a great tool for asserting the state of a complex object (usually DOM) without a million expect(...)
calls, but as with all powerful tools they can be abused. It is dangerously easy to create a several-hundred-line snapshot file that covers a huge surface area and doesn’t really indicate what it is trying to test.
If a test fails then you want the intent of the failing test to be clear, whether it is a snapshot or not. Large snapshots stand a significant risk of important changes being lost in the noise.
So how can we make sure that our snapshots are useful?
(Note: I am mostly talking about Enzyme mount()
-ed snapshots in this post. shallow()
-rendered snapshots are already fairly terse and don’t need a lot of help!)
Give it a good name
This seems obvious and is certainly not unique to snapshot tests but it needs mentioning: name your tests!
A test called it("works as expected")
is not helpful - if that fails then the only way to find out what broke is to dig through the test code. Similarly it("renders as expected")
for a snapshot doesn’t tell you much.
Instead, come up with a name that will help out the next dev: it("renders form fields as labels in read-only mode")
or it("renders menu, navigation and article content")
. Snapshots can be trickier to name because they tend to cover a lot more ground but you can still give people a hint!
Snapshot what you care about
A snapshot for MyComponent
doesn’t necessarily have to start with MyComponent
at the root. If your component renders a set of form fields nested inside a wrapper inside a modal inside another thing and you’re testing those form fields… just render the form fields.
it('should render form field as inputs', () => {
const wrapper = mount(<MyComponent />);
const form = wrapper.find('form');
expect(form).toMatchSnapshot();
});
This limits the snapshot to the part relevant to your test, lets the next developer easily spot changes that might be important and reduces the chance of unrelated changes appearing in the snapshot.
Don’t use a snapshot
Just because you’re testing a React component doesn’t necessarily mean you need to use a snapshot. If you care about an element becoming enabled after a change, for example, then a snapshot isn’t a particularly good way to articulate that. There’s nothing wrong with a vanilla expect
call.
it('should disable child form fields', () => {
const wrapper = mount(<MyComponent disabled />);
const inputs = wrapper.find('input');
inputs.forEach(input => expect(input.props().disabled).toBe(true));
});
Mock uninteresting components
When you snapshot a component with a deep hierarchy of children there will often be some group of the children that are not relevant to your test. You can replace these with mocks to reduce the noise in your snapshot.
Module Mocks will cause Jest to replace the mocked component with some custom behaviour that you can either define yourself or leave to the defaults.
The default behaviour is to render the component element and it’s props but nothing further (i.e. no children).
const ExampleComponent = ({ name, children }) => (
<>
<h1>{name}</h1>
{children}
</>
);
mount(
<ExampleComponent name="example">
<p>hello, world</p>
</ExampleComponent>
);
// unmocked snapshot:
// <ExampleComponent name="example">
// <div>example</div>
// <p>hello, world</p>
// </ExampleComponent>
// mocked snapshot:
// <ExampleComponent name="example" />
There are a number of ways to instruct Jest to mock a specific component, the simplest being to include the below in your test fixture:
//for default exports
jest.mock("path/to/ExampleComponent");
//for named exports
jest.mock("path/to/ExampleComponent", () => ({
ExampleComponent: () => null
}}));
If you want to define custom behaviour for your mock (e.g. rendering children) then you can specify an implementation to either of these overloads.
//for default exports
jest.mock("path/to/ExampleComponent", () =>
function ExampleComponent(props) {
return props.children;
}
);
//for named exports
jest.mock("path/to/ExampleComponent", () => ({
ExampleComponent: props => props.children
}}));
The second argument to jest.mock
is a function that returns a replacement module implementation, so the two examples above replace ExampleComponent
with a functional component that returns it’s children and nothing else.
Note that for the default export I am using a named function. This is important because it ensures the component appears in the snapshot as ExampleComponent
. If you do not name this function then it will appear as Component
which is losing important information. This is not necessary for the other versions listed above.
Custom serializers for uninteresting props
Another cause of noisy snapshots can be large props objects or arrays being passed down a tree of components. More noise in the snapshot makes it more difficult to see what is relevant:
<DropDownContainer
options={Array [
"Option 1",
"Option 2",
"...many lines later..."
"Option 99"
]}
>
<DropDown
options={Array [
"Option 1",
"Option 2",
"...even more lines later..."
"Option 99"
]}
/>
</DropDownContainer>
Checking props values is important but can be achieved without snapshots (e.g. expect()
-ing the values), and we can tidy up our snapshots by using custom serializers that hide away irrelevant content.
Custom serializers can be specified globally or within the scope of a single test and consist of 2 functions: test
and print
. Values in the snapshot are first passed to test
and - if it returns true
- are then passed through the print
function to return a serialized string.
For example, if you have an instance of MyType
that is passed into props then the below will render it’s id
instead of the full object.
module.exports = {
test(value: any) {
return value && value instanceof MyType;
}
print(value: any) {
return `[MyType: ${value.id}]`
}
}
This module can be registered in the jest configuration under package.json
and will be applied across your entire project.
For more limited-scope changes you can scope a custom serializer to a specific test fixture. For example, if you have a long array of options in a test then your serializer can replace that options array with a nice short string.
// in MyComponent.spec.tsx
import { longArrayOfOptions } from 'MenuOptions';
expect.addSnapshotSerializer({
test(value: any) {
return value === longArrayOfOptions;
},
print() {
return '[Long Options Array]';
}
});
This will render in your snapshot as below. Much easier to read and so much easier to see the important details that have changed.
<DropDownContainer
options={[Long Options Array]}
>
<DropDown
options={[Long Options Array]}
/>
</DropDownContainer>
In Summary
These approaches allow you to reduce the noise in your snapshots and therefore - hopefully - highlight the important elements that change as your code evolves.