Docs
Storybook Docs

Interaction tests

As you build more complex UIs like pages, components become responsible for more than just rendering the UI. They fetch data and manage state. Interaction tests allow you to verify these functional aspects of UIs.

In a nutshell, you start by supplying the appropriate props for the initial state of a component. Then simulate user behavior such as clicks and form entries. Finally, check whether the UI and component state update correctly.

In Storybook, this familiar workflow happens in your browser. That makes it easier to debug failures because you're running tests in the same environment as you develop components: the browser.

How does component testing in Storybook work?

You start by writing a story to set up the component's initial state. Then simulate user behavior using the play function. Finally, use the test-runner to confirm that the component renders correctly and that your interaction tests with the play function pass. Additionally, you can automate test execution via the command line or in your CI environment.

  • The play function is a small snippet of code that runs after a story finishes rendering. You can use this to test user workflows.
  • The test is written using Storybook-instrumented versions of Vitest and Testing Library coming from the @storybook/test package.
  • @storybook/addon-interactions visualizes the test in Storybook and provides a playback interface for convenient browser-based debugging.
  • @storybook/test-runner is a standalone utilityβ€”powered by Jest and Playwrightβ€”that executes all of your interactions tests and catches broken stories.

Set up the interactions addon

To enable interaction testing with Storybook, you'll need to take additional steps to set it up properly. We recommend you go through the test runner documentation before proceeding with the rest of the required configuration.

Run the following command to install the interactions addon and related dependencies.

npm install @storybook/test @storybook/addon-interactions --save-dev

Update your Storybook configuration (in .storybook/main.js|ts) to include the interactions addon.

.storybook/main.js
export default {
  // Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
  framework: '@storybook/your-framework',
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    // Other Storybook addons
    '@storybook/addon-interactions', // πŸ‘ˆ Register the addon
  ],
};

Write an interaction test

The test itself is defined inside a play function connected to a story. Here's an example of how to set up an interaction test with Storybook and the play function:

LoginForm.stories.js|jsx
This snippet doesn't exist for null. In the meantime, here's the React snippet.
import { userEvent, within, expect } from '@storybook/test';
 
import { LoginForm } from './LoginForm';
 
export default {
  component: LoginForm,
};
 
export const EmptyForm = {};
 
/*
 * See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const FilledForm = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // πŸ‘‡ Simulate interactions with the component
    await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
 
    await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
 
    // See https://storybook.js.org/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    await userEvent.click(canvas.getByRole('button'));
 
    // πŸ‘‡ Assert DOM structure
    await expect(
      canvas.getByText(
        'Everything is perfect. Your account is ready and we should probably get you started!',
      ),
    ).toBeInTheDocument();
  },
};

Once the story loads in the UI, it simulates the user's behavior and verifies the underlying logic.

Run code before each test

It can be helpful to run code before each test to set up the initial state of the component or reset the state of modules. You can do this by adding an asynchronous beforeEach function to the story, meta (which will run before each story in the file), or the preview file (.storybook/preview.js|ts, which will run before every story in the project).

Additionally, if you return a cleanup function from the beforeEach function, it will run after each test, when the story is remounted or navigated away from.

It is not necessary to restore fn() mocks with the cleanup function, as Storybook will already do that automatically before rendering a story. See the parameters.test.restoreMocks API for more information.

Here's an example of using the mockdate package to mock the Date and reset it when the story unmounts.

Page.stories.js
import MockDate from 'mockdate';
 
import { getUserFromSession } from '#api/session.mock';
import { Page } from './Page';
 
export default {
  component: Page,
  // πŸ‘‡ Set the value of Date for every story in the file
  async beforeEach() {
    MockDate.set('2024-02-14');
 
    // πŸ‘‡ Reset the Date after each story
    return () => {
      MockDate.reset();
    };
  },
};
 
export const Default = {
  async play({ canvasElement }) {
    // ... This will run with the mocked Date
  },
};

API for user-events

Under the hood, Storybook’s @storybook/test package provides Testing Library’s user-events APIs. If you’re familiar with Testing Library, you should be at home in Storybook.

Below is an abridged API for user-event. For more, check out the official user-event docs.

User eventsDescription
clearSelects the text inside inputs, or textareas and deletes it
userEvent.clear(await within(canvasElement).getByRole('myinput'));
clickClicks the element, calling a click() function
userEvent.click(await within(canvasElement).getByText('mycheckbox'));
dblClickClicks the element twice
userEvent.dblClick(await within(canvasElement).getByText('mycheckbox'));
deselectOptionsRemoves the selection from a specific option of a select element
userEvent.deselectOptions(await within(canvasElement).getByRole('listbox'),'1');
hoverHovers an element
userEvent.hover(await within(canvasElement).getByTestId('example-test'));
keyboardSimulates the keyboard events
userEvent.keyboard(β€˜foo’);
selectOptionsSelects the specified option, or options of a select element
userEvent.selectOptions(await within(canvasElement).getByRole('listbox'),['1','2']);
typeWrites text inside inputs, or textareas
userEvent.type(await within(canvasElement).getByRole('my-input'),'Some text');
unhoverUnhovers out of element
userEvent.unhover(await within(canvasElement).getByLabelText(/Example/i));

Assert tests with Vitest's APIs

Storybook’s @storybook/test also provides APIs from Vitest, such as expect and vi.fn. These APIs improve your testing experience, helping you assert whether a function has been called, if an element exists in the DOM, and much more. If you are used to expect from testing packages such as Jest or Vitest, you can write interaction tests in much the same way.

Form.stories.js|jsx
import { userEvent, waitFor, within, expect, fn } from '@storybook/test';
 
import { Form } from './Form';
 
export default {
  component: Form,
  args: {
    // πŸ‘‡ Use `fn` to spy on the onSubmit arg
    onSubmit: fn(),
  },
};
 
/*
 * See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const Submitted = {
  play: async ({ args, canvasElement, step }) => {
    // Starts querying the component from its root element
    const canvas = within(canvasElement);
 
    await step('Enter credentials', async () => {
      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
    });
 
    await step('Submit form', async () => {
      await userEvent.click(canvas.getByRole('button'));
    });
 
    // πŸ‘‡ Now we can assert that the onSubmit arg was called
    await waitFor(() => expect(args.onSubmit).toHaveBeenCalled());
  },
};

Group interactions with the step function

For complex flows, it can be worthwhile to group sets of related interactions together using the step function. This allows you to provide a custom label that describes a set of interactions:

MyComponent.stories.js|jsx
import { userEvent, within } from '@storybook/test';
 
import { MyComponent } from './MyComponent';
 
export default {
  component: MyComponent,
};
 
/*
 * See https://storybook.js.org/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const Submitted = {
  play: async ({ args, canvasElement, step }) => {
    const canvas = within(canvasElement);
 
    await step('Enter email and password', async () => {
      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
    });
 
    await step('Submit form', async () => {
      await userEvent.click(canvas.getByRole('button'));
    });
  },
};

This will show your interactions nested in a collapsible group:

Interaction testing with labeled steps

Mocked modules

If your component depends on modules that are imported into the component file, you can mock those modules to control and assert on their behavior. This is detailed in the mocking modules guide.

You can then import the mocked module (which has all of the helpful methods of a Vitest mocked function) into your story and use it to assert on the behavior of your component:

NoteUI.stories.js
import { expect, userEvent, within } from '@storybook/test';
 
import { saveNote } from '#app/actions.mock';
import { createNotes } from '#mocks/notes';
import NoteUI from './note-ui';
 
export default {
  title: 'Mocked/NoteUI',
  component: NoteUI,
};
 
const notes = createNotes();
 
export const SaveFlow = {
  name: 'Save Flow β–Ά',
  args: {
    isEditing: true,
    note: notes[0],
  },
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);
 
    const saveButton = canvas.getByRole('menuitem', { name: /done/i });
    await userEvent.click(saveButton);
    // πŸ‘‡ This is the mock function, so you can assert its behavior
    await expect(saveNote).toHaveBeenCalled();
  },
};

Interactive debugger

If you check your interactions panel, you'll see the step-by-step flow. It also offers a handy set of UI controls to pause, resume, rewind, and step through each interaction.

The play function is executed after the story is rendered. If there’s an error, it’ll be shown in the interaction addon panel to help with debugging.

Since Storybook is a webapp, anyone with the URL can reproduce the error with the same detailed information without any additional environment configuration or tooling required.

Interaction testing with a component

Streamline interaction testing further by automatically publishing Storybook in pull requests. That gives teams a universal reference point to test and debug stories.

Execute tests with the test-runner

Storybook only runs the interaction test when you're viewing a story. Therefore, you'd have to go through each story to run all your checks. As your Storybook grows, it becomes unrealistic to review each change manually. Storybook test-runner automates the process by running all tests for you. To execute the test-runner, open a new terminal window and run the following command:

npm run test-storybook

Interaction test with test runner

If you need, you can provide additional flags to the test-runner. Read the documentation to learn more.

Automate

Once you're ready to push your code into a pull request, you'll want to automatically run all your checks using a Continuous Integration (CI) service before merging it. Read our documentation for a detailed guide on setting up a CI environment to run tests.

Troubleshooting

The TypeScript types aren't recognized

If you're writing interaction tests with TypeScript, you may run into a situation where the TypeScript types aren't recognized in your IDE. This a known issue with newer package managers (e.g., pnpm, Yarn) and how they hoist dependencies. If you're working with Yarn the process happens automatically and the types should be recognized. However, if you're working with pnpm, you'll need to create a .npmrc file in the root of your project and add the following:

// .npmrc
public-hoist-pattern[]=@types*

If you're still encountering issues, you can always add the @types/testing-library__jest-dom package to your project.


What’s the difference between interaction tests and visual tests?

Interaction tests can be expensive to maintain when applied wholesale to every component. We recommend combining them with other methods like visual testing for comprehensive coverage with less maintenance work.

What's the difference between interaction tests and using Jest + Testing Library alone?

Interaction tests integrate Jest and Testing Library into Storybook. The biggest benefit is the ability to view the component you're testing in a real browser. That helps you debug visually, instead of getting a dump of the (fake) DOM in the command line or hitting the limitations of how JSDOM mocks browser functionality. It's also more convenient to keep stories and tests together in one file than having them spread across files.

Learn about other UI tests