Back to blog

Component testing in Storybook

The future of UI testing

loading
Michael Shilman
@mshilman
Last updated:

Over the past decade, web UI technology has evolved by leaps and bounds. In spite of that, it’s harder than ever to build/maintain a production UI in 2024.

At Storybook, we work with thousands of the top UI teams around the world, at places like Microsoft, Supabase, and JPMorganChase. And no matter how big or small the team, or how polished the final result, we see similar struggles to manage complex frontend development.

Many teams want test coverage on their UI to catch regressions, but they can’t afford the cost of maintaining a large end-to-end test suite (which we’ll explore in more detail below). Meanwhile, they often have thousands of unit tests, which don’t give them much UI confidence because they run in Node using a simulated browser environment.

After seeing the same patterns again and again, we are betting on Component Testing as the future of UI testing.

A component test renders a UI component in the browser, outside of the rest of the application. It can also interact with the component and make assertions.

Component tests hit a sweet spot in UI testing, providing end-to-end style browser fidelity with the speed, reliability, and compactness of unit tests.

Component tests are not a replacement for end-to-end or unit tests, but rather a perfect complement. Read on to learn more about component testing, how it fits into the broader testing landscape, and why we think it’s a great fit for the majority of your UI tests.

What’s a component test?

If you’ve been building in the JavaScript ecosystem for the past decade, you’ve probably seen unit tests like this (courtesy of Testing Library):

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Fetch from './fetch';

it('loads and displays greeting', async () => {
  // ARRANGE
  render(<Fetch url="/greeting" />);

  // ACT
  await userEvent.click(
    screen.getByText('Load Greeting')
  );
  await screen.findByRole('heading');

  // ASSERT
  expect(screen.getByRole('heading'))
    .toHaveTextContent('hello there');
  expect(screen.getByRole('button')).toBeDisabled();
});

This test renders a component called Fetch, interacts with it through the DOM, and then asserts on changes to the DOM based on that interaction.

It looks a little bit like an end-to-end (E2E) test, in that it’s simulating a user interacting with some application UI. Components can be as small as a button or as large as an entire application page, and the higher up the tree you go, the more it looks like E2E.

But it’s not E2E, because it’s testing a single component in isolation of the rest of the application. This difference is both a strength and a weakness of component testing, depending on what you want to test, as we’ll see below.

Also, this example (Jest + Testing Library) runs in Node on top of a DOM emulation layer like JSDom. Because it’s only running in a simulation of the browser, tests that pass in JSDom can fail in real-world scenarios and vice versa.

Tools like Storybook, Vitest, Playwright, Cypress, Webdriver, and Nightwatch also render and test a component, but in an actual browser. Those tests are what we define as component tests.

Thus, a component test:

  • Renders a component in the browser for high fidelity
  • Simulates a user interacting with actual UI, like an E2E test
  • Only tests a unit (e.g. a single component) of UI, and can reach into the implementation to mock things or manipulate data, like a unit test

Component tests: a perfect complement

As we’ve seen above, a component test shares elements of both a unit and an E2E test. But why are component tests useful, and when should you use them?

Let’s start with a simple claim:

E2E tests are the highest fidelity tests, because they test exactly what the user will see when they use your application.

In absence of any other considerations, if you can test a particular feature of your UI with an E2E test then you should. E2E tests give you the most confidence that everything works together as it should.

But if E2E is so great (and it is!) why do many teams use them sparingly?

The catch is “other considerations.” Despite plenty of advances in E2E testing, there are practical constraints that make it challenging to E2E test every facet of your UI.

Challenges include:

  • Slower test runs that are subject to flake
  • Lots of “hard-to-reach” states
  • Overhead to set up and test the backend
  • Black box environment can only be manipulated from the outside

All of these challenges are solved by component tests, at the expense of not testing the entire system.

This makes the two techniques perfect complements:

  • E2E tests can cover a small number of happy paths through your app
  • Component tests can cover a wide range of other important UI states

And this is exactly how we think UIs should be tested.

An illustration showing a "happy path" of critical components and all of the other variations and other components surrounding them

The Mealdrop example app

To make this proposition concrete, let’s consider Mealdrop, an example project that implements a food delivery service:

Screenshot of the MealDrop app homepage, showing three restaurant cards, the first of which is called Burger Kingdom

E2E testing

A happy path through this app starts at the homepage, navigates to a restaurant, adds an item to the shopping cart, and checks out.

0:00
/0:15

We’ve implemented this flow in Playwright using Chromatic to visual test each step along the way. The test navigates to each page and takes a visual snapshot of the UI at every state to ensure that the page is rendered correctly. It also makes a few key assertions about the DOM along the way. The test is condensed for brevity below; the full test is available in the Mealdrop repo.

import { test, expect } from '@playwright/test';

test('should complete the full user journey from home to success page', async ({ page }) => {
  await page.goto('http://localhost:3000');

  // Navigate to Restaurants page
  await page.getByText('View all restaurants').click();

  // Select "Burgers" category
  await page.getByText('Burgers').click();

  // Select the first restaurant from the list
  const restaurantCards = await page.getAllByTestId('restaurant-card');
  await restaurantCards.first().click();

  // Add Cheeseburger to the cart
  const foodItem = await page.getByText(/Cheeseburger/i);
  await foodItem.click();

  // Go to "Checkout" page
  await page.getByText(/checkout/i).click();

  // Fill in order details...

  // Complete the order
  await page.getByRole('button', { name: 'Complete order' }).click();

  await expect(page.locator('h1')).toContainText('Order confirmed!');
});

This single test covers a whole variety of states in a single flow, mimicking a user’s actual experience in the app. It takes 5-6 seconds to run on a 2021 MacBook M1 Pro with 16G of RAM.

But there are a wide variety of states that are left uncovered, such as loading and error states, form validation checks, and so on. To cover them, we could add more E2E tests that take different paths through the app and intentionally trigger our missing states. But instead, per our argument above, we choose to cover these states using component tests.

Component testing

To cover the missing states, we use Storybook for component testing. Each story is a small code snippet that configures a component into a key UI state. Let's consider several stories for Mealdrop's RestaurantDetailPage component.

The simplest story, Success, barely looks like a test. It performs a smoke test by mocking the data used by the RestaurantDetailPage component using Mock Service Worker and verifying that the component renders successfully:

// RestaurantDetailPage.stories.tsx
import { Meta, StoryObj } from '@storybook/react';
import { http, HttpResponse } from 'msw';
import { expect } from '@storybook/test';

import { BASE_URL } from '../../api';
import { restaurants } from '../../stub/restaurants';
import { RestaurantDetailPage } from './RestaurantDetailPage';

const meta = {
  component: RestaurantDetailPage,
  // All stories render the component and a spot to render the modal
  render: () => {
    return (
      <>
        <RestaurantDetailPage />
        <div id="modal" />
      </>
    );
  },
} satisfies Meta<typeof RestaurantDetailPage>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Success = {
  parameters: {
    // Mock data dependency
    msw: {
      handlers: [
        http.get(BASE_URL, () => HttpResponse.json(restaurants[0])),
      ],
    },
  },
} satisfies Story;
Screenshot of Success story of the RestaurantDetailPage in Storybook

But stories can also interact with the browser and assert on its contents using the play function. For example, WithModalOpen clicks on one of the restaurant's menu items and verifies that the resulting modal is present in the DOM:

// RestaurantDetailPage.stories.tsx

export const WithModalOpen = {
  ...Success,
  play: async ({ canvas, userEvent }) => {
    const item = await canvas.findByText(/Cheeseburger/i);
    await userEvent.click(item);
    await expect(canvas.getByTestId('modal')).toBeInTheDocument();
  },
} satisfies Story;
Screenshot of With Modal Open story of the RestaurantDetailPage in Storybook, you can see the scripted interactions in a list, each with a green checkmark

And finally, we can mock network requests to simulate access errors, such as this 404 NotFound story:

// RestaurantDetailPage.stories.tsx

export const NotFound = {
  parameters: {
    msw: {
      handlers: [
        // Mock a 404 response
        http.get(BASE_URL, () => HttpResponse.json(null, { status: 404 })),
      ],
    },
  },
  play: async ({ canvas }) => {
    const item = await canvas.findByText(/We can't find this page/i);
    await expect(item).toBeInTheDocument();
  },
} satisfies Story;
Screenshot of Not Found story of the RestaurantDetailPage in Storybook, you can see that the 404 page rendered

Unlike E2E tests that interact with the entire application as a black box, component tests are free to mock or spy any level of the stack as the author sees fit.

As a result, it is possible to reach any UI state–something that can be really challenging in E2E tests. Most of Mealdrop's 45 components have 100% test coverage based on stories like the ones shown above.

Furthermore, these tests run very quickly. The entire suite of 89 tests takes 8-10s to run in the browser on a 2021 MacBook M1 Pro with 16G of RAM, which is barely longer than it takes to run the single E2E test above.

Try it today

Component testing is supported by Storybook 8.2. Try it in a new project:

npx storybook@latest init

Or upgrade an existing project:

npx storybook@latest upgrade

For the full example shown in this post, please see the Mealdrop repo. And to learn more, please see Storybook's component testing docs.

What’s next?

End-to-end (E2E) tests are powerful because they test your application exactly as a user would see it. But it’s challenging to write and maintain the large number of E2E tests necessary to cover all the key UI states in a complex app, due to test flake, execution speed, and other practical considerations. Component tests provide the perfect complement, and that’s why we’re all in on turning Storybook into a component testing powerhouse.

Over the coming months, we will be shipping a variety of UI testing improvements in Storybook. These changes include:

  1. Bringing Storybook’s story format to parity with other testing tools.
  2. Lightning fast test execution in partnership with Vitest.
  3. The ability to run tests and view results from Storybook’s UI.
  4. Combining multiple types of tests in a single run, including functional, visual, a11y tests, and more.
  5. A unique way to seamlessly debug test failures, in both dev and CI.

For an overview of projects we’re considering and actively working on, please check out Storybook’s roadmap.

Join the Storybook mailing list

Get the latest news, updates and releases

6,615 developers and counting

We’re hiring!

Join the team behind Storybook and Chromatic. Build tools that are used in production by 100s of thousands of developers. Remote-first.

View jobs

Popular posts

Storybook 8.3

Blazing fast component tests
loading
Michael Shilman

React Native Storybook 8

React Native is back in the fold!
loading
Michael Shilman

Storybook 8.2

Towards no-compromise component testing
loading
Michael Shilman
Join the community
6,615 developers and counting
WhyWhy StorybookComponent-driven UI
DocsGuidesTutorialsChangelogTelemetry
CommunityAddonsGet involvedBlog
ShowcaseExploreProjectsComponent glossary
Open source software
Storybook

Maintained by
Chromatic
Special thanks to Netlify and CircleCI