Type-safe module mocking in Storybook
A new, standards-based mocking approach
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.
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:
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:
- We can modify its behavior for each story using Storybook’s new
beforeEach
hook - The Actions panel will now log whenever the function is called
- We can assert on the calls in the
play
function
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:
- A CLI utility to automatically generate mock boilerplate for a given module
- 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.