Hands down, writing tests is not the funniest thing one can do, yet it’s still a crucial part of software engineering. Time to step up the game and bring Facebook’s testing framework Jest and its snapshot mechanism into play.
Throughout this article, I am going to share my personal experience with snapshot tests while taking a closer look at its up- and downsides.
What is Snapshot Testing?
Snapshot tests capture specific states of an app, component or code at a certain point in time and can speed up the process of writing and maintaining tests. Instead of having to implement all assertions by hand, we can specify when and in which states Jest should automatically take and compare the snapshots.
The concept is quite simple. A snapshot test passes if the newer result matches the old one and fails if differences or no snapshots can be found.
When to Use It?
Snapshot tests are suitable whenever an expected value is serializable, including UI markup (e.g. a React component/tree), log and error messages as well as various data structures such as objects and arrays.
Let’s say we would like to write a simple test to verify that all the essential elements in our UI are getting rendered correctly.
That’s all we need! To capture the output of the
Calculator component in its default state, we just have to use the
expect method and call
toMatchSnapshot() on the — via Enzyme’s
shallow-rendered — wrapper, which then generates and saves a Jest snapshot of the markup into a dedicated snapshot file.
Note: This example is using the
enzyme-to-json serializer to transform the output into a more readable format. Without it, the snapshot would contain additional, Enzyme-related data, which is not relevant for our test.
A test rerun will now instruct Jest to take another snapshot to compare it with the previous one and to point out possible differences between them if the snapshots do not match.
Once we have verified that the new snapshot meets the requirements, we can update the snapshot by running
jest --updateSnapshot (also supports
-u flag) or by using the keyboard shortcut
Moreover, an Interactive Snapshot Mode provides a convenient way to step through failing snapshots only, should multiple tests be failing at once.
Same Test: Without Snapshots
The following example shows what it would take to write a similar test like the one before, but without using snapshots. Similar, because much more code would be needed to verify the UI the same way the snapshot does.
Besides the snapshot test being much shorter and the fact that it is capturing the whole UI, it’s less code to write and very easy to update. But this also comes with a downside.
Because of the snapshot test’s simplicity, it’s not entirely clear anymore what the requirements are and how the test ensures that these requirements are met, whereas the second one is almost self-descriptive by putting more focus on the elements and their existence.
You can find some of the tests shown here in this GitHub repository.
Snapshot What You Can!? 🃏
Although using snapshots may give us power and make our lives easier, they should neither be treated as a wildcard to have a test for the sake of completeness nor are they a full replacement or guarantee for a good test.
On the one hand, it is important to ask ourselves about the expectations and requirements our product/test needs to fulfill. Is it the goal to ensure a consistent and working UI? Then generating a snapshot sounds like a good idea as it will be a 1:1 representation of the markup that is going to be rendered later on in the browser.
With great power comes great responsibility — Uncle Ben
On the other hand, checking the existence of a specific element or the result of a function might also completely satisfy our needs without snapshots, which is why it’s usually a question of preference which way to go in the end.
UI Testing via Shallow Rendering 🌴
When writing unit tests for our UI, we shouldn’t have to worry about possible changes from outside, like the ones that could emerge from child components.
Shallow rendering is useful to constrain yourself to testing a component as a unit, and to ensure that your tests aren’t indirectly asserting on behavior of child components. — Enzyme’s Shallow Rendering API
You may have asked yourself about the difference between the “Add” and “Reset” button in the first snapshot. That’s because the
Calculator component also renders a
Button component and not just the plain HTML
<button> element as you can see in the snippet below.
exports[`should render correctly 1`] = `
// Removed the rest for better visibility
Button component renders nothing else than another
<button> element, but this time with a
The snapshot only contains our
Calculator component output and not the way the
Button component is rendered internally, which allows child components to change independently over time (e.g. the
custom-button class) without causing our tests to break.
shallow-rendering components, it’s recommended to either use the
enzyme-to-json serializer plugin or the
debug() method. Both variants will generate a more readable and nicely formatted snapshot, whereas
text() would take child components and the HTML representation into consideration.
Again, it depends on whether we want to test the unit itself or write an integration test to verify how child components would behave within the currently tested component.
Explicit Assertions 🔍
Snapshot testing can also reduce code redundancies and a constant back and forth between the original implementation and the test code, especially in situations where the expected result is, for example, a hard-coded text.
As an example, think about a test that should verify the correct rendering of an error message depending on the user’s input.
We would usually copy the text and use the
toBe-matcher to ensure that the right text gets displayed, but since we are in control of what should get captured by the snapshot, we don’t need to take care of this part anymore. Nevertheless, we should be careful when selecting the snapshot element for various reasons that have been outlined below.
// very bad, captures the full output
❌ Variant 1 (full snapshot): We could easily capture everything with our snapshot and pretend that our work is done.
However, that’s not the recommended way if we only want to capture a small portion — the error message — of the output. Maintaining this kind of test would become more time-consuming in the end due to the additional, but unimportant information that would be captured and other people looking at the code would probably have a hard time to locate the message within the snapshot.
This test would always fail, not only when the text itself changes but also when its (captured) surroundings do — something we should avoid when taking snapshots.
// good, self-descriptive, requires additional maintenance
expect(output.text()).toBe('Please verify your input!');
✔️ Variant 2 (no snapshot): Instead of capturing everything, we should be as explicit as possible when testing a specific state, regardless of the type of test we are about to write.
The classic approach is to manually match the expected message with the hard-coded string (or other sorts of data). This guarantees that the test will only fail when this particular text gets changed.
// good, only captures the error message, easy update
✔️ Variant 3 (partial snapshot): Alternatively, we can rewrite the previous snapshot test by only capturing the relevant content of the
#output HTML element that contains the error message.
exports[`should render error message if input is not valid 1`] = `”Please verify your input.”`;
Although this assertion is not as descriptive as the test before, it reduces redundancy in our code base, allows us to ignore the actual text in our component and lets Jest handle the snapshot comparison automatically.
This snapshot test would also only fail when the text is getting changed and is not affected by changes from outside.
// good, stores the snapshot within the test file expect(output.text()).toMatchInlineSnapshot();
✔️ Variant 4 (partial inline snapshot): The advantages of variant 2 and 3 can also be combined by using
toMatchInlineSnapshot(), under the premise that Prettier is installed and locatable by Jest.
expect(output.text()).toMatchInlineSnapshot(`"Please verify your input!"`);
In contrast to
toMatchInlineSnapshot() is inlining the snapshot via Prettier under the hood as call argument of
toMatchInlineSnapshot(snapshot) within the original test file.
Capturing Errors 🚨
Jest can also take snapshots of actual errors in our code. Similar to the approach before, we would avoid hard-coding error messages and can offload the text comparison to Jest again.
The following two matchers can be used to accomplish this:
The generated snapshot of the caught error would then, just like the other ones, contain the error message of the error object.
exports[`reducer should trigger an error when trying to do something 1`] = `"capture me if you can"`;
Snapshots Serializers for Different Data 🍭
Jest is using serializers to transform different results into a stringified snapshot version. Some of them are already built-in, while custom serializers can be added to save them in a more readable format.
The library “Pretty algorithms”, for example, impressively shows how a binary search tree could be serialized and represented as a snapshot.
exports[`createBST 1`] = `
│ ┌── 20
│ ┌── 18
│ │ └── 17
│ ┌── 13
│ │ └── 9
│ ┌── 7
│ ┌── 4
Deterministic Tests and Property Matchers🔮
Whenever code is about to introduce non-predictable results, we’d usually have to mock them first in order to write deterministic tests and create snapshots for reliable comparisons across different environments, time zones, etc. Here are some indicators for when mocking results becomes necessary.
- Platform dependencies: (e.g. formatting with the native
- Date and time:
Date.now = jest.fn(() => 1482363367071);
- Random data:
Math.random = jest.fn(() => 123);
However, Jest provides an additional way for serializable objects to sort of “isolate” specific properties by using asymmetric matchers while other property values stay intact when the snapshot gets captured.
The test below defines an object with a randomly generated id and the current date. Instead of mocking these values,
toMatchSnapshot([propertyMatchers, snapshotName]) can be invoked with an optional object consisting of the aforementioned matchers for problematic properties.
The resulting snapshot will then contain mock-like data due to the asymmetric matchers we have defined before and will also include the value we want to test.
exports[`should match the value 1`] = `
Where to Start and When to Stop 🏁
Should snapshots be used everywhere, somewhere, or nowhere at all?
In short, there is no clear line to be drawn between writing snapshots tests or implementing them in the good “old” way. We are only limited by the data we want to capture, and the rest pretty much depends on us, the developers.
Let’s take a look at the following unit test for our
And here the same based on snapshots.
exports[`should return 4 when calculating 1 + 3 1`] = `4`;
Honestly, we wouldn’t gain something by using snapshots here, but decide for yourself whether you prefer the former or the latter option.
In my opinion, the first option makes it very clear
- what is happening,
- how it’s happening and
- what the expected result should be.
Because it’s a simple example, imagine testing a function that returns an array consisting of objects. Without test fixtures, we could “snapshot” and maintain this ourselves or leverage Jest’s built-in snapshot mechanism once again to handle this for us.
Another aspect worth considering is the importance of the code we need to test. In the case of our calculator, accurate calculations are indispensable, whereas an unshown error message will not necessarily lead to wrong results or break the entire product.
In any case, writing stable and highly maintainable tests that are resistant to chances of false positives/negatives should be our primary goal, either by using snapshots or any other tool that may help us to finish this task.
The Good and Evil
It’s time to put all cards on the table and face the advantages and disadvantages of testing with Jest’s snapshot feature.
Whether it’s the result of a function or the output of a UI, as long as the value is serializable, snapshots can confidently improve the process of writing tests.
- Easy to set-up, write, update, debug and maintain
- Fast and reliable snapshot generation/execution
- Perfect for testing UI markup, messages, and other serializable data
- Snapshots can be treated and committed as code
- Custom snapshot serializers
- Could avoid code redundancies
Using Jest’s snapshot feature also comes with a few downsides, but not too often are they caused by the user who is using the tool rather than the tool itself.
- Readability of tests and snapshots might suffer if names of tests are not (self-)descriptive enough, or snapshot matchers are randomly aligned within a single test case. This can also lead to higher review times.
- Requires less attention when writing snapshot tests due to the tool’s simplicity and ease-of-use, but also more attention when updating existing snapshots.
- Test flakiness due to huge/wrongly taken snapshots; merge conflicts caused by changes in multiple components within the same render tree (e.g. no
shallow-rendering, people working on the same component).
- TDD/BDD principles are not fully applicable because snapshot tests are not necessarily written before the feature implementation or would generate falsy snapshots for something that doesn’t exist (yet).
Snapshot testing with Jest can increase productivity and be a joy to work with, but can also easily cause the opposite effect if not used correctly. Treating snapshots as a supportive tool next to traditional unit tests seems to be a good compromise to make, but the exception proves the rule. In the end, it comes down to each of us, the requirements, environments, available time, budget as well as readiness for responsibility when working with this kind of testing.
The more critical the code to verify, the more I would personally want to rely on self-descriptive tests rather than auto-generated snapshots. Mainly because of the additional obstacles I’d be willing to take to be in full control when writing and updating these tests in the first place, but also to lower the risk of introducing potential “blind spots” within my test code others and myself could potentially overlook.