Adding multi-snapshot testing to Jest

Adventures in monkeypatching

Igor Davydkin
Sears Israel

--

In this post I explain how to monkeypatch Jest’s internal functionality in order to improve the development experience by creating separate snapshot files per test. This is particularly useful for the DOM snapshots which can be quite large and difficult to review in the context of source control.

Jest is Facebook’s popular open source testing framework in javascript ecosystem. And one of the widely used features of this framework is a snapshot testing. From the docs:

Snapshot tests are a very useful tool whenever you want to make sure your UI does not change unexpectedly.

Snapshot testing basics

Snapshot testing is a simple concept. You create a test for some permutation of your UI component written with one or another framework, a snapshot file will be saved somewhere in your project per test file. You commit snapshots to source control and every time you (or CI) run tests, there is check for accidental UI changes.

But actually, we can snapshot anything. For the simplicity, we will use a basic example that tries to snapshot numbers 😃 :

Even though 100 is a constant, we still can create a snapshot of it. When running this piece of art with jest the new snapshot file will be created under the __snapshots__ directory. The name of this snapshot will be the name of the test (according to our example it will be called simple_snapshot_1.test.js.snap). Since the snapshot was created from the numerical value, the content of the snapshot file will be rather thin:

exports[`match number in snapshot 1`] = `100`;

If we will change the test now to match another value, for example 200 and will run it against the existing snapshot, the test will fail, since the received value (200) doesn’t match the one stored in a snapshot (100).

Now, let’s create a test that tries to snapshot two numbers:

Similarly, the simple_snapshot_2.test.js.snap file will be created under the __snapshots__ directory, and the content of this file will contain both numbers:

exports[`match 2 numbers in snapshot 1`] = `150`;

exports[`match 2 numbers in snapshot 2`] = `250`;

Even if we will create a test with a few cases:

There will be a single snapshot file for this test — simple_snapshot_3.test.js.snap, that will contain all the snapshot values.

Basically, this is what the snapshot matcher (toMatchSnapshot) does.

Multi-snapshot testing

The problem starts when you have big snapshots. For example you have a test for all permutations of some part of your UI in React. In this case snapshot will contains a big chunk of the markup. This snapshot can grow really fast and you won’t really be a able to track all the changes in it. How will you review it ? Will you want to commit a huge file to git ? 😞

The similar problem raised in Storybook here. Storybook has a very useful addon called StoryShots. If you a Storybook fan like me, you will create stories for mostly all components in your project. When adding StoryShots to the picture, you will win the snapshot automation for all stories you’ve created. But !! StoryShots runs in a scope of one test file. Which means that for the entire project listed in your Storybook will be created an enormously big snapshot file. And here comes a need to allow separation of the snapshots to multiple files.

The thing is that Facebook doesn’t really want to support this kind of behavior because of the implications this brings with it: How to track all these snapshot files created from one test? What happens if you delete test, but there were few snapshots created from it, how will they be cleaned? (You can read the discussions here and here) And actually I agree with their arguments — this behavior may have inconsistent and even unpredictable effects. But there are cases when we don’t need such a strict policy. So let’s do it by ourselves.

To be honest, we don’t want to re-create the whole functionality of snapshot testing by ourselves. This is a great deal of work that was already done by Facebook. Let’s just patch the relevant places to support our needs.

In order to not conflict with the Jest’s names we will call our matcher — toMatchSpecificSnapshot 😈. The required behavior is to provide a snapshot path to the matcher as an argument. So the following test should look like this:

Since we are creating a custom matcher, we need to register it to be known by Jest:

Now, let’s see a bit how the standard toMatchSnapshot works in order to understand how to implement our custom toMatchSpecificSnapshot matcher. The entire snapshot’s logic is located in a dedicated package of the Jest’s monorepo — here. I will not drill down too much to the internal logic of the jest-snapshot package (it took me quite some time to debug and understand it). One of the central places is the State object. It’s created internally due to the global setup per every test file run and contains the metadata of the snapshot.

Debugging the contractor of the State object in order to understand when it’s created.

Later, when we call to the regular toMatchSnapshot method from the test, it checks if the received value matches to the one from the snapshot. Note that snapshotState variable exists in the scope of the test:

Debugging the toMatchSnapshot to understand how it uses the state

So, in order to achieve the required behavior, we need to provide to our custom matcher a new state instance created by us, with the path to our separated snapshot:

Now, we need to call the toMatchSnapshot function, but in the way it will use ours new state internally 😓… For this purpose we need to monkeypatch toMatchSnapshot method to be run with the context that contains a new state instance 😰. Sounds confusing, but let’s see:

Looks like everything is done. But when running our new shiny test with the custom matcher — nothing will actually happen. The test is passed but the snapshot is not created.

The thing is that the snapshot file is not really saved once the toMatchSnapshot is called, but in the end of the test run (because as we said, snapshot is stored per test file, so Jest needs to gather the data from all test cases). Besides that, Jest calls SnapshotState.save() method of the instance that was created by Jest’s setup. But we have another instance of this state object… We need to call the save method by ourselves (again !). Since our functionality allows to store the same snapshot from different test files and different snapshots from the same test file, we will need to track all the snapshot states instances and call the save method for all of them after the all tests are done:

Now, we achieved our desired behavior and can safely run the test 🤘 :

Running the test with the specific snapshot matching

Conclusion

In this blog post I skipped the custom serializers issue and the fact that we can run Jest with the updateSnapshot flag (jest --updateSnapshot), all this you can find in the repo here.

After all, you may agree with the way this was done, or oppose it, but I hope you learned something new 😉

P.S. This jest plugin will be used in the upcoming release of Storybook (3.3).

--

--