jest snapshots: what are they, how to use them, and serializers

2021-05-01

 | 

~9 min read

 | 

1710 words

When it comes to testing UIs, there’s a lot to keep track of. Sometimes it can be helpful to just make sure that if a component changes, you are warned before that change makes it to production.

Obviously, code reviews are great for this, but a snapshot can shift that effort left and bring it to the author’s attention even earlier.

(Note: while I’m talking about UIs, snapshots are actually generally applicable and can be used for all sorts of purposes. From the docs:

…snapshots can capture any serializable value and should be used anytime the goal is testing whether the output is correct. )

Snapshots come with Jest, they’re one of the baked-in assertions. They can be defined in a separate file or inline.

According to Jest, there are three best practices to keep in mind when using Snapshots:

  1. Treat them as code, i.e. they should be reviewed like other code, avoid unnecessary complexity, and format accordingly.
  2. Ensure they’re deterministic, and if the code isn’t, look into property matchers.
  3. Use descriptive snapshot names, i.e., don’t rely on the auto-generated name (more on naming tests).

Let’s walk through a few examples to see why you might want to use them.

Managing Lists

A list is just a collection of serializable information. In that way, it’s perfect for Snapshots. Because a list can be rather long, and keeping them updated is arduous, we can make use of Snapshots to help.

Let’s assume for a moment that we run a small clothier that sells only socks. If we have a getProducts call, we’ll get a list of all of our socks (it’s a limited inventory at the moment).

src/example.js
export default function getProducts() {
  //...implementation details
}

/* [
    { style: "ankle", color: "blue", size: "medium" },
    { style: "ankle", color: "blue", size: "small" },
    { style: "ankle", color: "blue", size: "large" },
    { style: "ankle", color: "red", size: "medium" },
    { style: "ankle", color: "red", size: "small" },
    { style: "ankle", color: "red", size: "large" },
]*/

In our test, we might make sure that we get all of our products with a test that explicitly matches all of them:

src/example.test.js
import getProducts from "./example"

test("getProducts returns all of our products", () => {
  expect(getProducts()).toEqual([
    { style: "ankle", color: "blue", size: "medium" },
    { style: "ankle", color: "blue", size: "small" },
    { style: "ankle", color: "blue", size: "large" },
    { style: "ankle", color: "red", size: "medium" },
    { style: "ankle", color: "red", size: "small" },
    { style: "ankle", color: "red", size: "large" },
  ])
})

This will work, but what happens when we add a new sock? We need to manually go into the test and update the result!

Using Snapshots

Snapshots solve this problem by automatically documenting the results and failing a test when the result changes.

In our example above, instead of using the toEqual assertion, we could use the toMatchSnapshot or toMatchInlineSnapshot (the difference is where the snapshot will be stored). One benefit to the inline variant is that it’s harder to forget about / allow to grow too large.

src/example.test.js
import getProducts from "./example"

test("getProducts returns all of our products", () => {
  expect(getProducts()).toMatchInlineSnapshot()
})

The first time we run our test, Jest will automatically write the snapshot:

src/example.test.js
import getProducts from "./example"

test("getProducts returns all of our products", () => {
  expect(getProducts()).toMatchInlineSnapshot(`
    Array [
      Object {
        "color": "blue",
        "size": "medium",
        "style": "ankle",
      },
      Object {
        "color": "blue",
        "size": "small",
        "style": "ankle",
      },
      Object {
        "color": "blue",
        "size": "large",
        "style": "ankle",
      },
      Object {
        "color": "red",
        "size": "medium",
        "style": "ankle",
      },
      Object {
        "color": "red",
        "size": "small",
        "style": "ankle",
      },
      Object {
        "color": "red",
        "size": "large",
        "style": "ankle",
      },
    ]
  `)
})

The console will also alert us of the new snapshot:

 PASS  src/example.test.js
 › 1 snapshot written.

Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 written, 1 total
Time:        2.022 s
Ran all test suites.

Now, when we add a new color, let’s say, “yellow” to our line of socks, the test will automatically fail! The output calls out what went wrong:

 FAIL  src/example.test.js
  ● socks works as expected

    expect(received).toMatchInlineSnapshot(snapshot)

    Snapshot name: `socks works as expected 1`

    - Snapshot  -  0
    + Received  + 15

    @@ -27,6 +27,21 @@
        Object {
          "color": "red",
          "size": "large",
          "style": "ankle",
        },
    +   Object {
    +     "color": "yellow",
    +     "size": "medium",
    +     "style": "ankle",
    +   },
    +   Object {
    +     "color": "yellow",
    +     "size": "small",
    +     "style": "ankle",
    +   },
    +   Object {
    +     "color": "yellow",
    +     "size": "large",
    +     "style": "ankle",
    +   },
      ]

      11 |
      12 | test('socks works as expected', () => {
    > 13 |   expect(getProducts()).toMatchInlineSnapshot(`
         |                         ^
      14 |     Array [
      15 |       Object {
      16 |         "color": "blue",

      at Object.<anonymous> (src/other/super-heros.test.js:13:25)1 snapshot failed.

Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or run `npm test -- -u` to update them.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   1 failed, 1 total
Time:        2.055 s

This is totally normal and expected, and now, instead of having to update our tests ourselves (after reviewing the diff to ensure that everything looks right), we can update the snapshots by passing the -u flag to jest:

% npm test -- -u
 PASS  src/example.test.js
 › 1 snapshot updated.

Snapshot Summary
 › 1 snapshot updated from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   1 updated, 1 total
Time:        2.022 s
Ran all test suites.

Pretty nice! This works similarly for DOM nodes. The important piece to remember there is keep your snapshot as focused as possible to avoid breaking tests for irrelevant reasons.

Update: If you’re running tests in watch mode jest --watch, then there are two options that are exposed when a snapshot fails that might be useful:

Watch Usage
› Press a to run all tests.
› Press f to run only failed tests.
› Press p to filter by a filename regex pattern.
› Press t to filter by a test name regex pattern.
› Press u to update failing snapshots.› Press i to update failing snapshots interactively.› Press q to quit watch mode.
› Press Enter to trigger a test run.

While snapshots are great, there are some limitations. Take for example a situation where you’re using CSS-in-JS - whether that’s Emotion, styled-components, or one of the dozen others libraries.

src/MainComponent.js
import React from "react"

function MainComponent(props) {
  return (
    <div
      {...props}
      css={{
        position: "relative",
        color: "white",
        background: "#1c191c",
        lineHeight: "130px",
        fontSize: "6em",
        flex: "1",
      }}
    >
      {/*...*/}
    </div>
  )
}

export default MainComponent

Note: There’s some babel magic here for the css prop that’s managed using @emotion/babel-preset-css-prop (more info).

.babelrc.js
const isProd = process.env.NODE_ENV === "production"
module.exports = {
  presets: [
    [
      "@emotion/babel-preset-css-prop",
      {
        hoist: isProd,
        sourceMap: !isProd,
        autoLabel: !isProd,
        labelFormat: "[filename]--[local]",
      },
    ],
  ],
}

styled-components has a similar plugin, babel-plugin-styled-components (more info).

This might be tested with a snapshot like so:

src/MainComponent.test.js
import React from "react"
import { render } from "@testing-library/react"
import MainComponent from "./MainComponent"

test("it renders", () => {
  const { container } = render(<MainComponent value="0" />)
  expect(container.firstChild).toMatchInlineSnapshot(`
    <div
      class="css-lq9ahq-MainComponent--MainComponent"
    >
      {/*...*/}
    </div>
  `)
})

The issue is that class isn’t very descriptive. Worse, if the CSS changes, the test will fail, but the error message will reflect only the change in the class name, not the CSS that resulted in a new, different class.

MainComponent.js
import React from "react"

function MainComponent(props) {
    return (
        <div
            {...props}
            css={{
                position: "relative",
                color: "white",
                background: "#1c191c",
                lineHeight: "130px",
                fontSize: "6em",
-                 flex: "1",
+                 flex: "2",
            }}
        >
            {/*...*/}
        </div>
    )
}

export default MainComponent
 FAIL  src/MainComponent.test.js
  ● it renders

    expect(received).toMatchInlineSnapshot(snapshot)

    Snapshot name: `it renders 1`

    - Snapshot  - 1
    + Received  + 1

    @@ -1,7 +1,7 @@
      <div
    -   class="css-lq9ahq-MainComponent--MainComponent"
    +   class="css-16aikww-MainComponent--MainComponent"

The solution to this is to use @emotion/jest, a snapshot serializer (for styled-components, there’s jest-styled-components):

% yarn add --dev @emotion/jest

Once installed, we need to configure it:

jest.config.js
module.exports = {
  snapshotSerializers: ["@emotion/jest/serializer"],
}

With the snapshot serializer configured, when we re-run the test, it still fails, but there’s more information for why:

 FAIL  src/shared/MainComponent.test.js
  ● it renders

    expect(received).toMatchInlineSnapshot(snapshot)

    Snapshot name: `it renders 1`

    - Snapshot  -  1
    + Received  + 12

    @@ -1,7 +1,18 @@
    + .emotion-0 {
    +   position: relative;
    +   color: white;
    +   background: #1c191c;
    +   line-height: 130px;
    +   font-size: 6em;
    +   -webkit-flex: 2;
    +   -ms-flex: 2;
    +   flex: 2;
    + }
    +
      <div
    -   class="css-lq9ahq-MainComponent--MainComponent"
    +   class="emotion-0"
      >

Notice that now the class is emotion-0 and there’s a full description of the class above? So, what would this have looked like if we’d been using the serializer the whole time?

Once the inline snapshot was written, our test would look like this:

MainComponent.test.js
import React from "react"
import { render } from "@testing-library/react"
import MainComponent from "./MainComponent"

test("it renders", () => {
  const { container } = render(<MainComponent value="0" />)
  expect(container.firstChild).toMatchInlineSnapshot(`
    .emotion-0 {
      position: relative;
      color: white;
      background: #1c191c;
      line-height: 130px;
      font-size: 6em;
      -webkit-flex: 1;
      -ms-flex: 1;
      flex: 1;
    }

    <div
      class="emotion-0"
    >
      {/*...*/}
    </div>
  `)
})

So, if the flex attribute got changed, for example, when we ran the tests, that’d be immediately evident:

 FAIL  src/shared/MainComponent.test.js
  ● it renders

    expect(received).toMatchInlineSnapshot(snapshot)

    Snapshot name: `it renders 1`

    - Snapshot  - 3
    + Received  + 3

    @@ -2,13 +2,13 @@
        position: relative;
        color: white;
        background: #1c191c;
        line-height: 130px;
        font-size: 6em;
    -   -webkit-flex: 1;
    -   -ms-flex: 1;
    -   flex: 1;
    +   -webkit-flex: 2;
    +   -ms-flex: 2;
    +   flex: 2;
      }

Wrap Up

In this post we not only introduced Jest Snapshots, how to use and configure them, as well as serializers through exploring Emotion’s serializer.

Notes

For more on Snapshots, the docs have a nice FAQ section.


Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!