How Storybook Will Change the Way You Write Frontend Tests

October 10, 2020

You are a product of your environment. So choose the environment that will best develop you toward your objective. - W. Clement Stone

Writing frontend tests is — generally speaking — the worst thing in the world.

Do you find that it takes you longer to write tests than to write the code itself? And on top of the initial time required to produce tests, you’re stuck maintaining poorly written test code. No one has the energy to pay attention to it in code review, and you have a nagging suspicion it isn’t testing anything useful. The code’s real purpose is to enable your team to proudly proclaim, “yeah, we write tests here.”

As is so common in software development, the situation is worse on the frontend. What are you actually supposed to test in the first place? Of course, you understand that unit testing of pure functions is good practice — we’re not savages. But I have a confession: I can’t remember the last time I built a useful frontend application composed of primarily pure functions. Can you? For better or for worse, not all of us write in PureScript.

Despite these challenges, I’ve found hope in an unexpected place. Today we’re going to talk about Storybook. Yes, the component library thing (trust me, it’s much more than that). By the end of this post, you’ll understand:

  • How a change of environment helps you write more testable code
  • How Storybook can help you plan for your tests ahead of time
  • A less known use-case of Storybook that can elevate your unit tests to the highest ranks of cleanliness in your codebase

What is Storybook?

Storybook is a “harness” for developing UI components. It provides a sandboxed environment with everything required to render a React component in isolation with minimal boilerplate. It organizes these premade components into groups of “stories.”

Storybook Example

But what does this have to do with testing?

Better testing starts with better code

If your code is hard to test, you have a sign that it could use improvement. Perhaps the most significant benefit of Storybook is that it pushes developers towards building components in isolation.

The way most of us write code

Left to our own devices, many developers tend to take the path of least resistance.

Rather than reviewing the landscape, we let inertia push us into banging out that new component in the same file as the one we happen to be working in.

Most Developers

Before you know it, the component you’ve written is aware of far too many details regarding its parent, siblings, or general context. It becomes unusable in other areas without substantial refactoring. Among other problems, this certainly makes it much harder to test.

Let’s imagine we have a “MessageList” component that displays a truncated list of received chat or DM like messages:

// MessageList.js
export const MessageList = ({ userId, limit }) => {

  const [messages, setMessages] = useState([]);
  const [isLoading, setIsLoading] = useState(true); 
  const [error, setError] = useState(null;)
  
  useEffect(() => {
    fetchMessagesByUserId(userId)
    .then(messages => {
      setIsLoading(false);
      setMessages(messages);
    })
    .catch(err => {
      setError(err);
    });
  }, []);
  
  const msgSlice = messages.slice(0, limit);
  const numRemainingMsgs = Math.max(messages.length - limit, 0); 
  
  if (isLoading) return <Loading />;
  if (error) return <ErrorMessage error={err} />;
  return (
    <Fragment>
      { msgSlice
         .map(messageData => <Message {...messageData} />) }
      { (numRemainingMsgs > 0) && 
         <div> + { numRemainingMsgs } more... </div> }
    </Fragment>
  )
}

Why this is a problem

Fantastic job writing this component in just a couple of minutes. But now we realize we want to use this component inside a page that already has fetched messages.

Additionally, we’d like to be able to trigger re-fetching based on actions captured in a separate component.

And on top of that, we’re also starting to think we should write a few tests for this component — it will be used in other parts of our app.

Upon revisiting our code, though, we see immediate problems that make testing harder than it should be:

  1. How do we deal with the network request and useEffect hook? I guess we’ll have to mock those…
  2. Oh, we also want to test the error state, so we’ll have to remember to also mock the network request so it will reject a promise…
  3. We can’t even visually test the loading state unless we comment out source code

None of these are showstoppers on their own. But this is just one unrealistically simple component. Problems like this add up and compound in a large codebase, reducing testing to the horrible experience that no one wants to deal with we’re all too familiar with.

Developing in isolation with Storybook

Storybook guides us towards developing components in isolation with fewer implicit dependencies. Alongside our components, we write “stories” in Storybook. You’ll typically have multiples stories per component: each story should describe an “interesting” state of the component.

As you develop the component or its stories, Storybook reloads in real-time, providing an ergonomic, visual feedback loop. It’s a lovely development experience. When you’re done, your stories serve as a visual spec for the component.

How the Storybook environment influences our development process

Let’s apply this to our message list component. If we were to use Storybook, we’d quickly realize that including the network request inside this component won’t work out of the box, so let’s just start with the list of messages itself:

// MessageList.js
export const MessageList = (
  { userId, messages, isLoading, limit = 10 }
) => {
  const msgSlice = messages.slice(0, limit);
  const numRemainingMsgs = Math.max(messages.length - 10, 0); 
  
  if (isLoading) return <Loading />;
  if (error) return <ErrorMessage error={err} />;

  return (
    <Fragment>
      { msgSlice
         .map(messageData => <Message {...messageData} />) }
      { (numRemainingMsgs > 0) && 
         <div> + { numRemainingMsgs } more... </div> }
    </Fragment>
  )
}

Now that we have our basic component, we can immediately view it by writing its first story:

// MessageList.stories.js
import MessageList from './MessageList';

// This defines the label for the actual page in storybook
export default {
  component: MessageList,
  title: 'MessageList',
}

const messages = [
  { text: "foo" },
  { text: "bar" },
]

// Our first story
export const Default = () => <MessageList messages={messages} />
Adding more Stories

But, we also want to check out a few other noteworthy states while we’re developing: the loading state, error state, and overflow state (where we the limit prop is exceeded). Since we’re using Storybook, we might as well write some quick stories for these. Storybook encourages a few conventions that will make this easy:

// MessageList.stories.js
import MessageList from './MessageList';

const messages = [
  { text: "foo" },
  { text: "bar" },
  { text: "baz" },
];

// This defines the label for the actual page in storybook
export default {
  component: MessageList,
  title: 'MessageList',
  // these args will apply to all stories
  args: {
    limit: 10, 
    loading: false,
    messsages,
  }
};

// We'll use this template to create variants of the component
const Template = args => <MessageList {...args} />

// .bind creates a copy of the template
export const Default = () => Template.bind({});

// The args object lets us define the props ahead of time 
export const Loading = () => Template.bind({});
Loading.args = {
  loading: true,
};

export const Error = () => Template.bind({})
Error.args = {
  error: Error("Failed to fetch"),
};

export const OverflowLimitTen = () => Template.bind({})
OverflowLimitTen.args = {
  // Here w pass a list of 12 messages
  messages: [
    ...messages,
    ...messages,
    ...messages,
    ...messages
  ],
};

We’ve now created a cohesive component that’s less coupled to wherever we originally intended to use it in our project. Furthermore, by developing stories for the component, we’ve also produced a visual spec.

Any developer on our team can visually process the stories and quickly understand the specifications for this component without even needing to inspect its source code.

Finally, we’ve also saved ourselves quite a bit of pain when it comes to writing tests.

How this improves testing

We’ll start with a disclaimer. You might find at this point that you don’t need to write a test suite for the component, and that’s fine.

The consistent and clear visual inspection storybook provides may be enough in the way of quality control. I encourage this mindset. Tests should ensure behavior, and what better way to test the behavior of frontend code than by actually looking at and interacting with your component? Your stories will help make this process much more efficient and reliable.

But, there’s also plenty of value to be had in writing specific unit tests to protect against regressions, test logic that can’t be evaluated visually, and save time through automation. Interestingly, our stories even help in this effort.

We’ve already done half the work

By creating stories in the first place, you’ve front-loaded some of the most challenging pieces of testing: recognizing the “interesting” states and initializing them.

For many engineers, the development cycle looks like this:

  1. Write component
  2. Test manually
  3. Fix bugs
  4. Decide you should write tests
  5. Think of the states you should test
  6. Write tests, initialize the correct state in each one

With “Storybook Driven Development” (I’m so sorry), your process looks more like this:

  1. Write component and stories
  2. Add unit tests for individual stories

Re-purposing our stories for tests

Here’s one of the coolest, less-known uses of storybook: we can import stories into tests just like we do with typical components.

We’ve already done the work of thinking through the important states, so now for any additional testing, we can just import the story itself into our test file and render it like a standard react component.

In this example, we’ll use React Testing Library to test that the list doesn’t render items past the specified overflow limit (we’ll assume the components render as <li /> tags in the DOM).

// MessageList.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

import { OverflowLimitTen } from './MessageList.stories';

it('does not render overflow items', () => {
  render(<OverflowLimitTen {...OverflowLimitTen.args} />);
  expect(screen.getAllByRole('li')).toHaveLength(10);
});

This is about as boilerplate free as frontend tests can get. That might not sound like much, but there are some subtle wins here.

For one, we’re much more likely to write tests in the first place. It’s less painful.

Our tests are also more readable. Most teams hold test code to a lower standard than they do application code, and this discrepancy serves to make testing frustrating. You’ve certainly experienced this if you’ve had to enter the world of pain that is modifying someone else’s unexpectedly breaking tests for the first time.

We’ve also produced plenty of shareable, interactive documentation in the form of Storybook stories. Our code has improved as well. And most importantly, it was much more fun than the path of least resistance.


© 2020, Castles of Code