/ Josh Dover / blog

When Not to Use Jest Snapshots

April 18, 2019 • 6 minute read

Jest has become the JavaScript community’s favorite unit testing tool as of late, largely due to it’s fantastic support for testing React components. Although it isn’t without it’s caveats (it can’t be run in a browser), it has many unique features that make testing JavaScript code ergonomic and flexible. In this post, I’m going to focus on one of Jest’s key features, snapshots, and how to use them effectively.

What are snapshots?

If you’re not familiar, snapshots allow you to let the test framework record a value, and save it to disk. If the result of an expected snapshot changes, your test fails. This feature makes writing tests very fast especially when working with UI code that may have large component trees.

Here’s a basic example:

expect({ x: 1 }).toMatchSnapshot();

Jest will record the results of the snapshot (whatever you passed into expect) into the __snapshots__/my_module.test.ts.snap file on the first run and then use that snapshot to compare against future runs. Jest will let you know when the results don’t match what it previously recorded.

Jest also supports inline snapshots which put the actual contents of the snapshot inline with the expectation.

expect({ x: 1 }).toMatchInlineSnapshot();

Jest will update the test source file on the first run to include the snapshot data.

expect({ x: 1 }).toMatchInlineSnapshot(`
Object {
  "x": 1,
}
`);

Nice! Now we can see the snapshot right inside our test.

One of the best parts about Jest snapshots is how good Jest is at explaining why a snapshot test failed, such as which key of an object doesn’t match the snapshot:

Received value does not match stored snapshot "mytest 1"    

- Snapshot
+ Received

  Object {
-   "x": 1,
+   "x": 2,
  }

> 1 | expect({ x: 2 }).toMatchInlineSnapshot(`
    |                  ^
  2 | Object {
  3 |   "x": 1,
  4 | }

Congrats, you now know how to use Jest snapshots! There are other details how objects are serialized, but for 95% of cases, the default serializer will do just fine.

When snapshots snap back

Tests just got easier right? Half of your expectations can be written by the computer! What’s to complain about? Well, here’s where my rant explanation begins.

Snapshots can discourage writing good tests

A good test unit has a few, unalienable qualities:

You can violate all of three of these pretty damn quickly with a bad application of snapshots.

It’s too easy to drop a toMatchSnapshot() call into a test, think “I know this code works so I’ll just assume the snapshot is right”, never look at the generated snapshot (because it’s huge, in another file, or both) and move on your merry way. Only later do you find out that, yes, your code did work 10 minutes ago, but you changed something small and now it’s broken with a test suite that says it’s working. Doh. I only know this happens because I’ve done it.

TDD was all about programming with intention rather than by coincidence. Snapshots threw that all out the window in on fell swoop. Snapshots are not all bad, and maybe I just miss tests that explain behavior rather verify state. But we can do better to write thoughtful, useful tests.

Think twice before you take the easy way out and make sure what you’re snapshotting is actually what your test should be verifying. It’s also good to think about how the error message will look when this fails. Will it be obvious why the test is failing to another developer?

Snapshots can create false negatives

It’s too easy to snapshot implementation details that shouldn’t effect what your test cares about. Take this example:

class MyService {
  constructor() {
    this.publicValue = 1;
    this._internalState = 0;
  }
}

expect(new MyService()).toMatchInlineSnapshot(`
MyService {
  "_internalState": 0,
  "publicValue": 1,
}
`);

It’s a toy example, but what’s wrong here is obvious: we’re now testing implementation details rather than behavior. Now, if I change how _internalState is used or represented, I’ve broken a test even though the external behavior has not changed. While you could blame this on JavaScript not having true private fields, you have to admit that snapshots encourage this pattern.

You might think this is just a novice mistake, but this is something I’ve seen quite a bit. I suspect it has more to do with the fact that snapshots are stored in separate files by default (non-inline). Many developers don’t even notice that they’re testing internal details.

The testing tools we use certainly shape the quality and robustness of our suite. False negatives create a brittle and unreliable test suite. Unreliable test suites become disheartening quick, in turn discouraging writing tests at all.

Snapshots can make tests hard to read

When I open a test file and I can actually understand how the module should work by reading the tests, I swoon. Consistent BDD structure and good naming can help a lot with this, but at the end of the day the test implementation needs to be clear.

Before snapshots came onto the horizon, I was already firmly in the “no magic tests” camp. I’d rather my test suite be a lot of copy/paste than be implemented with a magic generic function that takes me an hour to understand. Understandability of a code base is an incredibly valuable asset. Doubly so in the code that supposed to serve as your “spec.”

I believe snapshots, when used well, help increase readability quite a bit. Paired with a good serializer, some object structures and state become infinitely more readable. Take a rendered React component for example:

const MyComponent = () => <h1>Hello</h1>;

expect(render(<MyComponent />)).toMatchInlineSnapshot(`
<h1>
  Hello
</h1>
`);

It’s super clear what this component should do, just by reading the test. Great!

However, there’s two ways this goes sideways: using non-inline snapshots (the default), or testing really big objects (!). When you do either of these things, you put the onus on the reader to figure out what the hell you were trying to test in the first place.

// Non-inline snapshot. I have no idea what MyComponent does :(
expect(render(<MyComponent />)).toMatchSnapshot();

// Big ol' object. I have no idea which field(s) matter :(
expect(MyObjectWith20Keys).toMatchInlineSnapshot(`{ ... }`);

OK, so when should I use them?

If you’ve made it this far, you’ve might have noticed two things about my opinion on snapshots:

If you avoid these two pitfalls, snapshots will serve you pretty well. You can write clear, readable, and intentional tests faster than you could without them. Just don’t sacrifice taking the time to write a good test for quickly writing a bad one. Bad tests just make your test suite less useful and your team less productive.