
Storybook 中的类型安全模块模拟
一种新的、基于标准的模拟方法
一致性对于独立开发和测试 UI 至关重要。
理想情况下,无论谁在何时查看,也无论后端是否正常工作,您的 Storybook 故事都应该始终渲染相同的 UI。对您的故事的相同输入应始终产生相同的输出。
当 UI 的唯一输入是传递给组件的 props 时,这很容易实现。如果您的组件依赖于来自 Context 提供程序的数据,您可以通过用decorators包装您的故事来模拟这些数据。对于从网络获取数据的 UI,有一个非常流行的 Mock Service Worker 插件,它可以确定性地模拟网络请求。
但是,如果您的组件依赖于其他来源,例如浏览器 API(例如读取用户的 theng 偏好、localStorage中的数据或 cookie)?或者如果您的组件根据当前日期或时间表现不同?或者您的组件使用了像 Next.js 的next/router这样的元框架 API?
在 Storybook 中模拟这些类型的输入历来很困难。而这正是我们今天通过 Storybook 8.1 中的模块模拟来解决的问题!我们的方法简单、类型安全且基于标准。它倾向于明确性和调试清晰度,而不是不透明/专有模块 API。而且我们与优秀的同行在一起:Epic Stack 创建者 Kent C. Dodd 推荐使用类似的方法进行绝对导入,而 React Server Component 架构师 Seb Markbåge 直接启发了 Storybook 模拟。
什么是模块模拟?
模块模拟是一种技术,您可以用一致的、独立替代方案替换组件直接或间接导入的模块。在单元测试中,这有助于在可重现的状态下测试代码。在 Storybook 中,这可用于渲染和测试以有趣方式检索其数据的组件。
例如,考虑一个用户可配置的Dashboard组件,该组件允许用户选择显示哪些信息,并将这些设置存储在浏览器的本地存储中。

这实现为settings数据访问层,该层读取并将用户设置写入本地存储,以及一个负责 UI 的显示组件Dashboard。
// 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
}
为了测试Dashboard组件,我们希望创建一组不同布局的示例,以测试关键状态。为了简单起见,并且不失一般性,我们仅关注读取布局的部分。
在本文中,我们将使用此作为运行示例来解释模块模拟,我们如何实现它,以及我们的方法相对于其他实现的优势。
现有方法:专有 API
流行的单元测试工具,如 Jest 和 Vitest,都提供了灵活的模块模拟机制。例如,它们会自动查找相邻 mock 目录中的 mock 文件。
// lib/__mocks__/settings.ts
export const getDashboardLayout = () => ([ /* dummy data here */ ]);
或者,它们提供命令式 API 来在测试文件中声明 mock。
// 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 */])),
});
这看起来是一个简单的 API,但在底层,此代码实际上会触发一个复杂、有些神奇的文件转换,以用 mock 替换导入。因此,代码的小更改可能会以令人困惑的方式破坏模拟。例如,以下变体会失败:
// 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!!!
});
但我们的目标不是贬低这些优秀的工具。相反,我们希望探索如何使用一种新的、基于标准的、更好的方法来进行模拟。
我们的方法:子路径导入
Storybook 中的模块模拟利用了子路径导入标准,可通过 package.json 的 imports 字段进行配置——这是任何 JS 项目的核心——作为整个项目导入 mock 的管道。
就我们而言,这种方法的一个超能力是,就像 package.json exports 一样,package.json imports 可以条件化,根据运行时环境改变导入路径。这意味着您可以定制您的 package.json 以在 Storybook 中导入模拟模块,同时在其他地方导入真实模块!
子路径导入最初在 Node.js 中引入,但现在已在整个 JS 生态系统中得到支持,包括 TypeScript(自 5.4 版本起)、Webpack、Vite、Jest、Vitest 等。
继续上面的示例,这是您如何模拟 ./lib/settings.ts 的模块:
{
"imports": {
"#lib/settings": {
"storybook": "./lib/settings.mock.ts",
"default": "./lib/settings.ts"
},
"#*": [ // fallback for non-mocked absolute imports
"./*",
"./*.ts",
"./*.tsx"
]
}
}
在这里,我们指示模块解析器,所有从 #lib/settings 导入的模块在 Storybook 中应解析为 ../lib/settings.mock.ts,而在您的应用程序中则解析为 ../lib/settings.ts。
这还需要修改您的组件,使其从以 # 符号为前缀的绝对路径导入,如 Node.js 规范所述,以确保路径或包导入没有歧义。
// Dashboard.test.ts
- import { getDashboardLayout } from '../lib/settings';
+ import { getDashboardLayout } from '#lib/settings';
这可能看起来很麻烦,但它的好处是清楚地向阅读文件的开发人员传达模块可能因运行时而异。事实上,我们普遍推荐这种标准用于绝对导入,原因如下(请参阅下文)。
按故事的模拟
使用子路径导入,我们能够使用基于标准的、方法替换整个 settings.ts 文件为一个新模块。但是,如果我们想为每个测试(或者在本例中,为每个 Storybook 故事)改变其实现,settings.mock.ts 应该如何构造?
以下是模拟任何模块的样板结构。因为我们完全控制代码,所以我们可以修改它以适应任何特殊情况(例如,删除 Node 代码,使其在浏览器中不运行,反之亦然)。
// 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');
现在,每当导入 #lib/settings 时,此 mock 文件将在 Storybook 中使用。它目前做的不多,只是包装了实际实现——这是重要部分。
现在让我们在 Storybook 故事中使用它:
// 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 */ ]);
},
};
在 Storybook 中,使用 mock 函数 fn 意味着:
- 我们可以使用 Storybook 的新
beforeEach钩子来修改其行为,每个故事都不同: - 现在,当函数被调用时,Actions 面板将记录下来:
- 我们可以在
play函数中断言调用:
我们的方法的优势
我们已经看到了基于子路径导入 package.json 标准的端到端模块模拟示例。与 Jest 和 Vitest 所采取的专有方法相比,这种方法是明确的、类型安全的且基于标准的。
明确
一些模拟框架背后的魔法可能会让人难以理解模拟是如何以及何时应用的。例如,我们上面看到,在 vi.mock 调用中引用外部定义的变量会导致模拟错误,尽管它是有效的 JavaScript。
相反,所有 mock 都明确定义在 package.json 中,我们的解决方案提供了一种清晰且可预测的方式来理解模块如何在不同环境中解析。这种透明度简化了调试,并使您的测试更具可预测性。
类型安全
模拟框架引入了开发人员需要熟悉的约定、语法样式和特定 API。此外,这些解决方案通常缺乏类型检查支持。
通过使用现有的 package.json,我们的解决方案需要最少的设置。此外,它与 TypeScript 自然集成,尤其是在 TypeScript 现在支持带自动完成功能的 package.json 子路径导入(自 TypeScript 5.4,2024 年 3 月起)。
基于标准
最重要的是,因为 Storybook 的方法是 100% 基于标准的,这意味着您可以在任何工具链或环境中实现您的 mock。
这很有用,因为您可以学习标准,然后在任何地方重用该知识,而无需学习每个工具的模拟细节。例如,vi.mock 的用法与 Jest 的模拟相似,但不完全相同。
它还意味着您可以组合使用多个工具。例如,用户通常会为他们的组件编写故事,然后使用我们的 Portable Stories 功能在其他测试工具中重用这些故事。
此外,您可以在多个环境中重用这些 mock。例如,Storybook 的 mock 在 Node 中“免费”工作,因为它们是 Node 标准的一部分,但由于该标准由 Webpack 和 Vite 实现,因此只要您使用其中一个构建器,它们在浏览器中也能正常工作。
最后,因为我们与 ESM 标准保持一致,所以它确保我们的解决方案与未来的 JS 更改兼容。我们押注于平台。我们相信这是模块模拟的未来,并且每个测试工具都应该采用它。
立即试用
模块模拟已在 Storybook 8.1 中提供。在新项目中尝试一下。
npx storybook@latest init
或升级现有项目
npx storybook@latest upgrade
要详细了解模块模拟,请参阅 Storybook 文档,了解更多示例和完整 API。我们创建了一个完整的 Next.js React Server Components (RSC) 应用演示,该演示使用我们的模块模拟方法进行测试。我们计划在即将发布的博客文章中进一步记录这一点。
下一步
Storybook 的模块模拟功能已完整且准备好使用。我们正在考虑以下增强功能:
- 一个 CLI 工具,可为给定模块自动生成 mock 样板代码。
- 支持从 UI 中可视化/编辑 mock 数据。
除了模块模拟之外,我们还在进行许多测试改进。例如,我们构建了一种在浏览器中单元测试 React Server Components 的新方法。我们正在努力使 Storybook 的测试更接近 Jest/Vitest 基于 Jasmine 的结构。
For an overview of projects we’re considering and actively working on, please check out Storybook’s roadmap.
我们最新的帖子介绍了 Storybook 8.1 中新的模块模拟 API。https://#/B6KdwCZSz7 pic.twitter.com/7zV6yrGO5k
— Storybook (@storybookjs) 2024 年 5 月 30 日