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

这实现为一个 settings
数据访问层,负责将用户设置读写到本地存储,以及一个显示组件 Dashboard
,负责 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
}
为了测试 Dashboard
组件,我们希望创建一组不同布局的示例,以覆盖关键状态。为简单起见,且不失一般性,我们只关注读取布局的部分。
在整篇文章中,我们将以此为例来解释模块 mocking、我们如何实现它,以及我们的方法相对于其他实现的优势。
现有方法:专有 API
像 Jest 和 Vitest 这样的流行单元测试工具都提供了灵活的模块 mocking 机制。例如,它们会自动在相邻的 mocks 目录中查找 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 替换了导入。因此,对代码的微小更改可能会以令人困惑的方式破坏 mocking。例如,以下变体就会失败。
// 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!!!
});
但我们的目标并非贬低这些优秀的工具。相反,我们希望探索如何使用新的、基于标准的方法更好地进行 mocking。
我们的方法:子路径导入 (Subpath Imports)
Storybook 中的模块 mocking 利用了 子路径导入 (Subpath Imports) 标准,该标准可通过任何 JS 项目的核心 package.json
文件中的 imports
字段进行配置,作为在整个项目中导入 mock 的管道。
对我们而言,这种方法的超能力之一在于,就像 package.json
的 exports
一样,package.json
的 imports
也可以是有条件的,根据运行时环境改变导入路径。这意味着您可以调整 package.json
,使其在 Storybook 中导入 mocked 模块,而在其他地方导入实际模块!
子路径导入 (Subpath Imports) 最早引入于 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"
]
}
}
这里我们指示模块解析器,在 Storybook 中,所有从 #lib/settings
的导入应解析为 ../lib/settings.mock.ts
,而在您的应用程序中则解析为 ../lib/settings.ts
。
这也需要修改您的组件,使其根据Node.js 规范从以 #
符号为前缀的绝对路径导入,以确保与路径或包导入没有歧义。
// Dashboard.test.ts
- import { getDashboardLayout } from '../lib/settings';
+ import { getDashboardLayout } from '#lib/settings';
这可能看起来有些繁琐,但它的好处在于能清晰地向阅读文件的开发者传达该模块可能因运行时环境而异。事实上,我们通常推荐将此标准用于绝对导入,因为它对 mocking 有诸多益处(见下文)。
按故事 Mocking (Per-story mocking)
使用子路径导入,我们能够以基于标准的方法将整个 settings.ts
文件替换为一个新模块。但如果我们要为每个测试(或者在本例中,为每个 Storybook story)改变其实现,应该如何组织 settings.mock.ts
呢?
这是 mocking 任何模块的样板结构。由于我们完全控制代码,我们可以对其进行修改以适应任何特殊情况(例如,删除 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');
现在,只要在 Storybook 中导入 #lib/settings
,就会使用这个 mock 文件。它目前还没做太多事,只是包装了实际的实现——这才是关键。
现在让我们在 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 */ ]);
},
};
在 Storybook 中,使用 mock 函数 fn
意味着
- 我们可以使用 Storybook 新的
beforeEach
hook 为每个故事修改其行为 - Actions 面板现在将记录每次函数被调用的情况
- 我们可以在
play
函数中断言这些调用
我们方法的优势
我们现在已经看到了基于 Subpath Imports package.json
标准的 Storybook 端到端模块 mocking 示例。与 Jest 和 Vitest 所采用的专有方法相比,此方法更明确、类型安全且基于标准。
明确
一些 mocking 框架背后的“魔法”可能使得理解 mock 如何以及何时被应用变得困难。例如,我们在上面看到,从 vi.mock
调用中引用外部定义的变量会导致 mocking 错误,即使它是有效的 JavaScript。
相比之下,由于所有 mock 都明确定义在 package.json
中,我们的解决方案提供了一种清晰且可预测的方式来理解模块在不同环境中的解析方式。这种透明性简化了调试,并使您的测试更具可预测性。
类型安全
Mocking 框架引入了开发者需要熟悉的约定、语法风格和特定 API。此外,这些解决方案通常缺乏对类型检查的支持。
通过使用现有的 package.json
,我们的解决方案只需最少的设置。此外,它还能自然地与 TypeScript 集成,特别是 TypeScript 现在支持带有自动补全的 package.json
子路径导入(自 TypeScript 5.4,2024 年 3 月起)。
基于标准
最重要的是,由于 Storybook 的方法 100% 基于标准,这意味着您可以在任何工具链或环境中使用您的 mock。
这很有用,因为您可以学习这一标准,然后在任何地方重用这些知识,而不必学习每个工具的 mocking 细节。例如,vi.mock
的用法与 Jest 的 mocking 相似但不完全相同。
这也意味着您可以将多个工具结合使用。例如,用户通常会为其组件编写 story,然后使用我们的可移植故事 (Portable Stories) 功能在其他测试工具中重用这些 story。
此外,您可以在多种环境中使用这些 mock。例如,Storybook 的 mock 在 Node 中“免费”工作,因为它们是 Node 标准的一部分;但由于 Webpack 和 Vite 都实现了该标准,因此如果您使用其中一个构建工具,它们在浏览器中也能正常工作。
最后,由于我们与 ESM 标准保持一致,这确保了我们的解决方案向前兼容未来的 JS 变化。我们看好平台。我们相信这是模块 mocking 的未来,并且所有测试工具都应该采用它。
今天就试用
模块 mocking 已在 Storybook 8.1 中提供。在新的项目中试用
npx storybook@latest init
或升级现有项目
npx storybook@latest upgrade
要了解更多关于模块 mocking 的信息,请参阅Storybook 文档以获取更多示例和完整的 API。我们创建了一个完整的Next.js React Server Components (RSC) 应用演示,使用我们的模块 mocking 方法进行测试。我们计划在即将发布的博客文章中进一步记录这一点。
下一步是什么
Storybook 的模块 mocking 功能已完善,可随时使用。我们正在考虑以下增强功能:
- 一个 CLI 工具,用于自动为给定模块生成 mock 样板代码
- 支持从 UI 中可视化/编辑 mock 数据
除了模块 mocking,我们还在致力于众多测试改进。例如,我们构建了一种在浏览器中单元测试 React Server Components 的新颖方法。我们正在努力使 Storybook 的测试结构更接近 Jest/Vitest 中受 Jasmine 启发的结构。
要了解我们正在考虑和积极开展的项目概览,请查看Storybook 的路线图。
我们的最新文章介绍了 Storybook 8.1 中新的模块 mocking API。https://#/B6KdwCZSz7 pic.twitter.com/7zV6yrGO5k
— Storybook (@storybookjs) 2024 年 5 月 30 日