All Articles

StoryBook, StoryShots and React Testing Library, pt. I

This is the first of two posts on this topic. I’ll show you how to integrate StoryBook, StoryShots and React Testing Library, giving you a great foundation for both testing and developing your components and pages the way your user is going to interact with them.

Tree covered in growth, photo by Torkel Rogstad Photo by Torkel Rogstad

Lately I’ve been really falling in love with a couple of fantastic pieces of technology for frontend development: StoryBook and React Testing Library. They share a core principle, which makes them a natural fit for rapid iteration while keeping confidence in your changes: Your end user does not care about the implementation. What they do care about is how your application behaves and looks. Your goal when writing tests should be to be confident that your application looks and behaves as expected, while allowing you to change your implementation without breaking the tests.

In this post I’ll show you how to achieve this, with the help of Jest Snapshot Testing through StoryShots. The end result of these blog posts is a setup where we can:

  1. Script interactions with our components
  2. See the visual result of this in StoryBook
  3. Verify that the rendered output matches our expected snapshot
  4. Render our stories, after they’ve been interacted with, in regular Jest unit tests where we can assert on everything else, such as the content the user is seeing, the state of the HTML elements on screen, or what API calls have been made.

Setup

First we need to get our project started:

# create a React project
$ npx create-react-app rtl-storyshots && cd rtl-storyshots

# initiate StoryBook
$ npx sb init

# get newest version of React Testing Library
$ yarn upgrade --latest \
  --scope @testing-library

# start storybook
$ yarn storybook

You can now navigate to localhost:6006 to see your stories.

Interacting with our stories

We now want to interact with our stories! The first step is to make an interactive story. In Button.stories.js, replace const Template = ... with the following:

const Template = (args) => {
  const [clicks, setClicks] = useState(0);
  return (
    <>
      I've been clicked {clicks} time(s):{' '}
      <Button {...args} onClick={() => setClicks(clicks + 1)} />
    </>
  );
};

You should now see that your story changes when clicking on the button. To script interactions with this, we add the following:

import * as rtl from '@testing-library/react';
import userEvent from '@testing-library/user-event';

const Template = (args) => {
  // NEW CODE BEGIN
  useEffect(() => {
    const root = document.getElementById('root');
    userEvent.click(rtl.getByRole(root, 'button'));
  }, []); // empty dependency array = run on mount
  // NEW CODE END

  const [clicks, setClicks] = useState(0);
  return (
    <>
      I've been clicked {clicks} time(s):{' '}
      <Button {...args} onClick={() => setClicks(clicks + 1)} />
    </>
  );
};

When you reload your story, you’ll see that the button already has been clicked. This might seem like it doesn’t accomplish much, but you also script more complex interactions, like typing values into a form, navigating around a page or triggering mouse events on components. This could then be used to verify that your form validation UI renders correctly, the correct route is rendered or that animations happen when they should.

Snapshot testing our components

As the final part of this (first out of two) post(s) we’ll add snapshot testing of our stories.

Snapshot testing is a testing technique where you render your components, and then compare the result to a known good result, AKA your snapshot. It has several benefits:

  1. It catches unintended changes to components, for example when changing a utility function you you did expect to affect your component
  2. You get a free “sanity check” of all components, where you verify that they don’t throw any errors when rendering.

Luckily, the great folks behind StoryBook has us covered, and we can utilize a ready-made addon for this: StoryShots (@storybook/addon-storyshots). First, we add it to our project:

$ yarn add --dev react-test-renderer @storybook/addon-storyshots

Then, we a single test file to our project, that’s going to run snapshot tests on all our stories! Magical. Create a new file src/Storyshots.test.jsx, with the following content:

import initStoryshots from '@storybook/addon-storyshots';

initStoryshots();

Let’s run our new suite of snapshot tests, limiting ourselves to a single story, to make it easier to see in the console what’s going on:

$ yarn test src/Storyshots.test.js -t 'Button Primary'
  ● Storyshots › Example/Button › Primary

    TypeError: Expected container to be an Element, a Document or a DocumentFragment but got null.

      16 |   useEffect(() => {
      17 |     const root = document.getElementById('root');
    > 18 |     userEvent.click(rtl.getByRole(root, 'button'));
         |                         ^
      19 |   }, []); // empty dependency array = run on mount
      20 |
      21 |   const [clicks, setClicks] = useState(0);

Output truncated for clarity

Oh no! document.getElementById('root') returned null… It looks like our component didn’t get rendered as a complete HTML document. However, StoryShots is highly configurable. Let’s change src/Storyshots.test.jsx to this:

import initStoryshots, {
  Stories2SnapsConverter,
} from '@storybook/addon-storyshots';
import React from 'react';
import * as rtl from '@testing-library/react';

initStoryshots({
  test: ({ story: { storyFn: Story }, context }) => {
    const converter = new Stories2SnapsConverter();
    const snapshotFilename = converter.getSnapshotFileName(context);
    const rendered = rtl.render(
      <div id="root">
        <Story {...context} />
      </div>
    );
    expect(rendered).toMatchSpecificSnapshot(snapshotFilename);
  },
  snapshotSerializers: [
    {
      print: (val, serialize) => {
        const root = val.container.firstChild;
        return serialize(root);
      },
      test: (val) => val.hasOwnProperty('container'),
    },
  ],
});

What we’re doing here is using React Testing Library as the renderer of our story, which gives us a few benefits:

  1. We get access to a full-fledged document, which in turn lets us use the full set of queries and interaction utilities from React Testing Library and user-event.
  2. Everything renders correctly, including the result of ReactDOM.createPortal. This throws an error without using React Testing Library

Furthermore, we also change the serializer for our components, which leaves us with a nicer output in the snapshot files.

If you now look at src/stories/__snapshots__/Button.stories.storyshot, you’ll see that it has received a click:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Storyshots Example/Button Primary 1`] = `
<div
  id="root"
>
  I've been clicked
  1
   time(s):

  <button
    class="storybook-button storybook-button--medium storybook-button--primary"
    type="button"
  >
    Button
  </button>
</div>
`;

Congratulations, you just made an interactive story with scripted interactions and decent test coverage, without hardly any work!

Remember that the setup you just did for your snapshot test is a single-time activity, and that all your new stories will get a snapshot test for free from now on.

However, there are some drawbacks to this (as it currently stands):

  1. If we do any asynchronous interactions with our components, this will not show up in the snapshot. This includes typing into a field, or waiting for components to appear or disappear. The snapshot is taken right after our component is rendered (and any immediate pending callbacks are executed).
  2. I also promised to let you render these stories in regular Jest tests. If you try that right now, you’ll end up with the dreaded not wrapped in act(...) warning

Despair not, both of these issues are fixable! Tune in later, for the second installment on how to supercharge your component driven UI workflow.