Back to blog

Type-safe module mocking in Storybook

A new, standards-based mocking approach

loading
Jeppe Reinhold
@DrReinhold
loading
Kasper Peulen
@KasperPeulen

Consistency is crucial to develop and test UI in isolation.

Ideally, your Storybook stories should always render the same UI no matter who’s viewing them when, and whether or not the backend is working. The same input to your story should always result in identical output.

This is trivial when the only input to your UI is the props passed to your components. If your component depends on data from context providers you can mock those out by wrapping your stories with decorators. For UI whose inputs are fetched from the network, there’s the very popular Mock Service Worker addon that deterministically mocks network requests.

But what if your component depends on another source, such as a browser API, like reading the user’s theming preferences, data in localStorage, or cookies? Or if your components behave differently based on the current date or time? Or maybe your component uses a meta-framework API like Next.js’s next/router?

Mocking those types of inputs has historically been difficult in Storybook. And that is what we’re solving today with module mocking in Storybook 8.1! Our approach is simple, type-safe, and standards-based. It favors explicitness and debugging clarity over opaque/proprietary module APIs. And we’re in good company: Epic Stack creator Kent C. Dodd recommends using a similar approach for absolute imports and React Server Component architect Seb Markbåge directly inspired Storybook mocking.

👉
Note: This work also allows us to mock out Node-only code and test React Server Components (RSCs) in Storybook in the browser. We’ll share more about that in a future blog post. Stay tuned!

What is module mocking?

Module mocking is a technique in which you substitute a module that is imported directly or indirectly by your component with a consistent, independent alternative. In unit tests, this can help test code in a reproducible state. In Storybook, this can be used to render and test components that retrieve their data in interesting ways.

Consider, for example, a user-configurable Dashboard component that allows the user to choose what information is shown and stores those settings in the browser’s local storage:

Network Monitoring Dashboard. Monitor the health and performance of your network. 3 cards. 1: Network Utilization. 72%. Arrow up 5%. 2: Bandwidth Usage. 250 Mbps. Arrow up 10%. 3: Device Uptime. 98.7%. Arrow up 0.5%.
A Dashboard component

This is implemented as a settings data access layer that reads and writes the user’s settings to local storage, and a display component, Dashboard, that is responsible for the UI:

// lib/settings.ts
export const getDashboardLayout = () => {
  const layout = window.localStorage.getItem('dashboard.layout');
  return layout ? parseLayout(layout) : [];
};
// components/Dashboard.tsx
import { getDashboardLayout } from '../lib/settings.ts';

export const Dashboard = (props) => {
  const layout = getDashboardLayout();
  // logic to display layout
} 

To test the Dashboard component, we want to create a set of examples of different layouts that exercise key states. For simplicity’s sake, and without loss of generality, we focus only on the piece that reads the layout.

Throughout this post, we’ll use this as a running example to explain module mocking, how we achieve it, and the advantages of our approach versus other implementations.

Existing approach: Proprietary APIs

Popular unit testing tools like Jest and Vitest both provide flexible mechanisms for module mocking. For example they automatically look for mock files in an adjacent mocks directory:

// lib/__mocks__/settings.ts
export const getDashboardLayout = () => ([ /* dummy data here */ ]);

Alternatively, they provide imperative APIs to declare the mocks inside your test files:

// components/Dashboard.test.ts
import { vi, fn } from 'vitest';
import { getDashboardLayout } from '../lib/settings.ts';

vi.mock('../lib/settings.ts', () => ({
  getDashboardLayout: fn(() => ([ /* dummy data here */])),
});

This looks like a simple API, but under the hood this code actually triggers a complex, somewhat magical file transformation to replace the import with its mocks. As a result, small changes to the code can break the mocking in confusing ways. For example, the following variation fails:

// components/Dashboard.test.ts
import { vi, fn } from 'vitest';
import { getDashboardLayout } from '../lib/settings.ts';

const dummyLayout = [ /* dummy data here */];
vi.mock('../lib/settings.ts', () => ({
  getDashboardLayout: fn(() => dummyLayout), // FAIL!!!
});

But our goal is not to bash either of these excellent tools. Rather, we wish to explore how we can mock better using a new, standards-based approach.

Our approach: Subpath Imports

Module mocking in Storybook leverages the Subpath Imports standard, configurable via the imports field of package.json — the beating heart of any JS project — as a pipeline for importing mocks throughout your project.

For our purposes, one of the superpowers of this approach is that, just like package.json exports, package.json imports can be made conditional, varying the import path depending on the runtime environment. This means you can tailor your package.json to import mocked modules in Storybook, while importing the real modules elsewhere!

Subpath Imports was first introduced in Node.js, but is now also supported across the JS ecosystem, including TypeScript (since version 5.4), Webpack, Vite, Jest, Vitest, and so on.

Continuing the example from above, here’s how you would mock the module at ./lib/settings.ts:

{
  "imports": {
    "#lib/settings": {
      "storybook": "./lib/settings.mock.ts",
      "default": "./lib/settings.ts"
    },
    "#*": [ // fallback for non-mocked absolute imports
      "./*",
      "./*.ts",
      "./*.tsx"
    ]
  }
}

Here we’re instructing the module resolver that all imports from #lib/settings should resolve to ../lib/settings.mock.ts in Storybook, but to ../lib/settings.ts in your application.

This also requires modifying your component to import from an absolute path prefixed with the #-sign as per the Node.js spec, to ensure there are no ambiguities with path or package imports.

// Dashboard.test.ts

- import { getDashboardLayout } from '../lib/settings';
+ import { getDashboardLayout } from '#lib/settings';

This may look cumbersome, but it has the benefit of clearly communicating to developers reading the file that the module might be different depending on the runtime. In fact, we recommend this standard for absolute imports in general, for all the reasons it’s great for mocking (see below).

Per-story mocking

Using subpath imports, we are able to replace the entire settings.ts file with a new module using a standards-based approach. But how should we structure settings.mock.ts if we want to vary its implementation for every test (or, in our case, Storybook story)?

Here is a boilerplate structure for mocking any module. Because we have full control of the code, we can modify it to suit any special circumstances (e.g. removing Node code so that it doesn’t run in the browser, or vice versa).

// lib/settings.mock.ts
import { fn } from '@storybook/test';
import * as actual from './settings'; // 👈 Import the actual implementation

// 👇 Re-export the actual implementation.
// This catch-all ensures that the exports of the mock file always contains
// all the exports of the original. It is up to the user to override
// individual exports below as appropriate.
export * from './settings';

// 👇 Export a mock function whose default implementation is the actual implementation.
// With a useful mockName, it displays nicely in Storybook's Actions addon
// for debugging.
export const getDashboardLayout = fn(actual.getDashboardLayout)
  .mockName('settings::getDashboardLayout');

This mock file will now be used in Storybook whenever #lib/settings is imported. It doesn’t do much yet except wrapping the actual implementation—that’s the important part.

Now let’s use it in a Storybook story:

// components/Dashboard.stories.ts

import type { Meta, StoryObj } from '@storybook/react';
import { expect } from '@storybook/test';

// 👇 You can use subpaths as an absolute import convention even
// for non-conditional paths
import { Dashboard } from '#components/Dashboard';

// 👇 Import the mock file explicitly, as that will make
// TypeScript understand that these exports are the mock functions
import { getDashboardLayout } from '#lib/settings.mock'

const meta = {
  component: Dashboard,
} satisfies Meta<typeof Dashboard>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Empty: Story = {
  beforeEach: () => {
    // 👇 Mock return an empty layout
    getDashboardLayout.mockReturnValue([]);
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    // 👇 Expect the UI to prompt when the dashboard is empty
    await expect(canvas).toHaveTextContent('Configure your dashboard');
    // 👇 Assert directly on the mock function that it was called as expected
    expect(getDashboardLayout).toHaveBeenCalled();
  },
};

export const Row: Story = {
  beforeEach: () => {
    // 👇 Mock return a different, story-specific layout
    getDashboardLayout.mockReturnValue([ /* hard-coded "row" layout data */ ]);
  },
};

In Storybook, using the mock function fn means:

  1. We can modify its behavior for each story using Storybook’s new beforeEach hook
  2. The Actions panel will now log whenever the function is called
  3. We can assert on the calls in the play function
Need to assert on more than just text? Quickly test how your component actually looks in any state to catch UI bugs across multiple browsers and viewports with the Visual Tests addon from Chromatic.

Advantages of our approach

We’ve now seen an end-to-end module mocking example in Storybook based on the Subpath Imports package.json standard. Compared to the proprietary approach taken by Jest and Vitest, this approach is explicit, type-safe, and standards-based.

Explicit

The magic behind some mocking frameworks can make it hard to understand how and when mocks are being applied. For example, we saw above how referencing an externally-defined variable from within a vi.mock call causes mocking error, even though it is valid JavaScript.

In contrast, with all mocks explicitly defined in package.json, our solution offers a clear and predictable way to understand how modules are resolved in different environments. This transparency simplifies debugging and makes your tests more predictable.

Type-safe

Mocking frameworks introduce conventions, syntax styles, and specific APIs that developers need to be familiar with. Plus, these solutions often lack support for type-checking.

By using your existing package.json, our solution requires minimal setup. Plus, it integrates with TypeScript naturally, especially as TypeScript now supports package.json Subpath Imports with autocompletion (as of TypeScript 5.4, March 2024).

Standards-based

Most importantly, because Storybook’s approach is 100% standards-based, it means that you can use your mocks in any toolchain or environment.

This is useful because you can learn the standard and then reuse that knowledge everywhere, instead of having to learn each tool’s mocking details. For example, vi.mock usage is similar, but not identical, to Jest’s mocking.

It also means you can use multiple tools together. For example, it’s common for users to write stories for their components and then reuse those stories in other testing tools using our Portable Stories feature.

Additionally, you can use those mocks in multiple environments. For example, Storybook’s mocks work “for free” in Node since they are part of the Node standard, but since that standard is implemented by both Webpack and Vite, they also work fine in the browser assuming you use one of those builders.

Finally, because we are aligned to ESM standards, it ensures our solution is forward-compatible with future JS changes. We are betting on the platform. We believe this is the future of module mocking and that every testing tool should adopt it.

Try it today

Module mocking is available in Storybook 8.1. Try it in a new project:

npx storybook@latest init

Or upgrade an existing project:

npx storybook@latest upgrade

To learn more about module mocking, please see the Storybook documentation for more examples and the full API. We’ve created a full demo of the Next.js React Server Components (RSC) app tested using our module mocking approach. We plan to document this further in an upcoming blog post.

What’s next

Storybook’s module mocking is feature complete and ready to use. We are considering the following enhancements:

  1. A CLI utility to automatically generate mock boilerplate for a given module
  2. Support for visualizing / editing mock data from the UI

In addition to module mocking, we’re also working on numerous testing improvements. For example, we have built a novel way to unit test React Server Components in the browser. And we are working on bringing Storybook’s tests much closer to Jest/Vitest’s Jasmine-inspired structure.

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,623 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

Interactive story generation

Make your first story in seconds without leaving the browser!
loading
Valentin Palkovic

Storybook 8.1

A more productive, organized, and predictable Storybook
loading
Michael Shilman

Portable stories for Playwright Component Tests

Test your stories in Playwright CT with minimal setup.
loading
Yann Braga
Join the community
6,623 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