Mocking 模块
组件通常依赖于其他模块,例如其他组件、实用函数或库。这些可能来自外部包或内部项目。在 Storybook 中渲染这些组件或对它们进行测试时,您可能希望 mock 这些模块以控制其行为并隔离组件的功能。
例如,这个简单的组件依赖于两个模块:一个用于访问用户浏览器会话的本地实用函数,以及一个用于生成唯一 ID 的外部包。
import { v4 as uuidv4 } from 'uuid';
import { getUserFromSession } from '../lib/session';
export function AuthButton() {
const user = getUserFromSession();
const id = uuidv4();
return (
<button onClick={() => { console.log(`User: ${user.name}, ID: ${id}`) }}>
{user ? `Welcome, ${user.name}` : 'Sign in'}
</button>
);
}上面的示例是用 React 编写的,但同样的原理也适用于 Vue、Svelte 或 Web Components 等其他渲染器。重要的是要使用这两个模块依赖项。
在为该组件编写 stories 或测试时,您可能希望 mock getUserFromSession 函数以控制返回的用户数据,或者 mock uuidv4 函数以返回可预测的 ID。这使您能够在不依赖于这些模块的实际实现的情况下测试组件的行为。
为了获得最大的灵活性,Storybook 提供了三种 mock 模块的方式,供您的 stories 使用。让我们逐一介绍,从最简单的方法开始。
自动 Mocking
自动 Mocking 是 Storybook 中 mock 模块最简单的方法,我们推荐所有使用 Vite 和 Webpack builder 的项目使用此方法(其他 builder 必须使用下面的其他技术之一)。此方法需要最少的配置,同时允许灵活地 mock 模块。
它通过两个步骤工作。首先,在您的 Storybook 配置中注册您要 mock 的模块。然后,在您的 stories 中控制行为并对 mock 模块进行断言。
注册要 mock 的模块
在自动 Mocking 时,您可以使用 sb.mock 实用函数来注册您想要 mock 的模块。有三种注册模块的方式:仅作为 spy、完全自动 mock 或使用 mock 文件。每种方法都有其使用场景和优势。
在使用 sb.mock 实用函数时,有一些关键细节需要牢记。
- 您可以注册本地模块(例如
../lib/session.ts)和node_modules中的包(例如uuid)。 - 您只能在项目级别的配置中注册 mock 模块:
.storybook/preview.js|ts。这可确保在项目的所有 stories 中进行一致且高性能的 mock。您可以在 stories 中修改这些模块的行为,但不能直接在 story 文件中注册它们。 - 注册本地模块的 mock 时,路径必须
- 不使用别名或子路径导入(例如
@/lib/session.ts或#lib/session)。 - 相对于
.storybook/preview.js|ts文件。 - 包含文件扩展名(例如
.ts或.js)。
- 不使用别名或子路径导入(例如
- 如果您使用 TypeScript,可以将模块路径包装在
import()中,以确保模块被正确解析和类型化。例如,sb.mock(import('../lib/session.ts'))。 - 如果您使用 Webpack builder,则只能自动 mock 具有 ESModules (ESM) 入口点的
node_module包。如果一个模块同时具有 CommonJS (CJS) 和 ESM 入口点,Webpack 将无法正确解析 ESM 入口点,因此无法对其进行 mock。Webpack 用户仍然可以通过提供 mock 文件来 mock CJSnode_module包。
仅 Spy
在大多数情况下,您应该将 mock 模块注册为仅 Spy,通过将 spy 选项设置为 true。这会保持原始模块的功能不变,同时仍然允许您在需要时修改其行为并在测试中进行断言。
例如,如果您想 spy getUserFromSession 函数和 uuid 包中的 uuidv4 函数,您可以在您的 .storybook/preview.js|ts 文件中调用 sb.mock 实用函数。
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, vue3-vite, sveltekit)
import type { Preview } from '@storybook/your-framework';
import { sb } from 'storybook/test';
// 👇 Automatically spies on all exports from the `lib/session` local module
sb.mock(import('../lib/session.ts'), { spy: true });
// 👇 Automatically spies on all exports from the `uuid` package in `node_modules`
sb.mock(import('uuid'), { spy: true });
const preview: Preview = {
// ...
};
export default preview;如果您需要 mock 一个具有更深导入路径的外部模块(例如 lodash-es/add),请使用该路径注册 mock。
然后,您可以 控制这些模块的行为并在您的 stories 中进行断言,例如检查函数是否被调用或以什么参数被调用。
完全自动 Mock 的模块
对于需要阻止原始模块功能执行的情况,将 spy 选项设置为 false(或省略它,因为它是默认值)。这将自动用 Vitest mock 函数 替换模块的所有导出,使您能够控制其行为并进行断言,同时确保原始功能永远不会运行。
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, vue3-vite, sveltekit)
import type { Preview } from '@storybook/your-framework';
import { sb } from 'storybook/test';
// 👇 Automatically replaces all exports from the `lib/session` local module with mock functions
sb.mock(import('../lib/session.ts'));
// 👇 Automatically replaces all exports from the `uuid` package in `node_modules` with mock functions
sb.mock(import('uuid'));
const preview: Preview = {
// ...
};
export default preview;完全自动 mock 的模块不会执行其导出的函数,但模块仍会被评估,包括其依赖项。这意味着如果模块有副作用(例如,修改全局状态、记录到控制台等),这些副作用仍然会发生。类似地,为在服务器上运行而编写的模块将尝试在浏览器中进行评估。如果您想完全阻止原始模块的代码运行,您应该改用 mock 文件。
然后,您可以像使用仅 Spy 方法一样,控制这些模块的行为并在您的 stories 中进行断言。
Mock 文件
如果您想 mock 具有更复杂行为的模块,或者想在多个 stories 中重用 mock 的行为,您可以创建一个 mock 文件。该文件应放置在要 mock 的模块旁边的 __mocks__ 目录中,并且应导出与原始模块相同的命名导出。
例如,要 mock lib 目录中的 session 模块,请在 lib/__mocks__ 目录中创建一个名为 session.js|ts 的文件。
export function getUserFromSession() {
return { name: 'Mocked User' };
}对于 node_modules 中的包,请在项目根目录中创建一个 __mocks__ 目录并在此处创建 mock 文件。例如,要 mock uuid 包,请在 __mocks__ 目录中创建一个名为 uuid.js 的文件。
export function v4() {
return '1234-5678-90ab-cdef';
}如果您需要 mock 一个具有更深导入路径的外部模块(例如 lodash-es/add),请在项目根目录中创建一个相应的 mock 文件(例如 __mocks__/lodash-es/add.js)。
您的项目根目录的确定方式取决于您的 builder。
Vite 项目
根目录 __mocks__ 目录应放置在项目 Vite 配置中定义的 root 目录(通常是 process.cwd())。如果不可用,则默认为包含 .storybook 目录的目录。
Webpack 项目
根目录 __mocks__ 目录应放置在项目 Webpack 配置中定义的 context 目录(通常是 process.cwd())。如果不可用,则默认为存储库根目录。
Mock 文件必须使用 JavaScript(而不是 TypeScript)编写,使用 ESModules(而不是 CJS)。
它们必须导出与原始模块相同的命名导出。如果您想 mock 一个默认导出,可以在 mock 文件中使用 export default。
然后,您可以使用 sb.mock 实用函数在您的 preview.js|ts 文件中注册这些 mock 文件。
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, vue3-vite, sveltekit)
import type { Preview } from '@storybook/your-framework';
import { sb } from 'storybook/test';
// 👇 Replaces imports of this module with imports to `../lib/__mocks__/session.ts`
sb.mock(import('../lib/session.ts'));
// 👇 Replaces imports of this module with imports to `../__mocks__/uuid.ts`
sb.mock(import('uuid'));
const preview: Preview = {
// ...
};
export default preview;请注意,注册自动 mock 模块和 mock 文件的 API 是相同的。唯一的区别是 sb.mock 将首先在适当的目录中查找 mock 文件,然后自动 mock 该模块。
在 stories 中使用自动 Mock 的模块
所有注册的自动 mock 模块在您的 stories 中都以相同的方式使用。您可以控制其行为,例如定义其返回值,并对模块进行断言。
// Replace your-framework with the name of your framework (e.g. react-vite, vue3-vite, etc.)
import type { Meta, StoryObj } from '@storybook/your-framework';
import { expect, mocked } from 'storybook/test';
import { AuthButton } from './AuthButton';
import { v4 as uuidv4 } from 'uuid';
import { getUserFromSession } from '../lib/session';
const meta = {
component: AuthButton,
// 👇 This will run before each story is rendered
beforeEach: async () => {
// 👇 Force known, consistent behavior for mocked modules
mocked(uuidv4).mockReturnValue('1234-5678-90ab-cdef');
mocked(getUserFromSession).mockReturnValue({ name: 'John Doe' });
},
} satisfies Meta<typeof AuthButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LogIn: Story = {
play: async ({ canvas, userEvent }) => {
const button = canvas.getByRole('button', { name: 'Sign in' });
userEvent.click(button);
// Assert that the getUserFromSession function was called
expect(getUserFromSession).toHaveBeenCalled();
},
};使用 sb.mock 实用函数创建的 mock 函数是完整的 Vitest mock 函数,这意味着您可以使用它们提供的所有方法。一些最有用的方法包括:
| 方法 | 描述 |
|---|---|
mockReturnValue(value) | 设置 mock 函数的返回值。 |
mockResolvedValue(value) | 设置 mock 异步函数解析到的值。 |
mockImplementation(fn) | 为 mock 函数设置自定义实现。 |
如果您正在 使用 TypeScript 编写 stories,您可以使用 storybook/test 中的 mocked 实用函数来确保 mock 函数在您的 stories 中被正确类型化。此实用函数是 Vitest vi.mocked 函数的类型安全包装器。
工作原理
Storybook 的自动 Mocking 是基于 Vitest 的 Mocking 引擎构建的。行为会根据您是在开发模式还是构建模式进行调整。
开发模式
在开发模式下,Mocking 依赖于 Vite 的模块图无效化。当添加、更改或删除 mock 时(无论是在 .storybook/preview.js|ts 还是 __mocks__ 目录中),插件都会智能地使所有受影响的模块无效并触发热重载。这提供了快速且交互式的开发体验。
开发和构建模式
- 构建时分析:一个新的 Vite 插件 viteMockPlugin 在构建过程中会扫描
.storybook/preview.js|ts中的所有sb.mock()调用。 - Mock 处理
__mocks__重定向:如果在顶层__mocks__目录中找到相应的 mock 文件,该文件将被加载并由 Vite 进行转换。- 自动 Mocking 和 Spy:如果没有找到
__mocks__文件,原始模块的代码将在构建时进行转换,以用 mock 或 spy 替换其导出。
- 无运行时开销:由于所有 Mocking 决策和转换都在构建时完成,因此最终构建的应用程序无需性能损失或复杂的拦截逻辑。Mocked 模块将直接替换原始模块进行打包。
与 Vitest Mocking 的比较
虽然此功能使用了 Vitest 的 Mocking 引擎,但 Storybook 中的实现有一些关键区别:
- 作用域:Mock 是全局的,并且仅在
.storybook/preview.js|ts中定义。与 Vitest 不同,您不能在单个 story 文件中调用sb.mock()。 - 设计上是静态的:所有 Mocking 决策都在构建时最终确定。这使得系统健壮且高性能,但不如 Vitest 的逐个测试 Mocking 功能动态。没有
sb.unmock()或等效项,因为模块图在生产构建中是固定的。 - 运行时 Mocking:虽然模块交换是静态的,但您仍然可以在 Play function 或
beforeEachhook 中控制 mock 函数的行为(例如mocked(myFunction).mockReturnValue('new value'))。 - 无工厂函数:
sb.mock()API 不接受工厂函数作为第二个参数(例如sb.mock('path', () => ({...})))。这是因为所有 Mocking 决策都在构建时解析,而工厂则在运行时执行。
替代方法
如果自动 Mocking不适合您的项目,还有两种替代方法可以在 Storybook 中 mock 模块:子路径导入和builder 别名。这些方法需要更多的设置,但提供与自动 Mocking 类似的功能,使您能够控制 stories 中模块的行为。
子路径导入
您可以使用 Node 功能 子路径导入 来 mock 模块。子路径导入允许您为项目中的模块定义自定义路径,这些路径可用于将原始模块替换为 mock 文件。它们适用于 Vite 和 Webpack builder。
Mock 文件
要 mock 模块,请创建一个与要 mock 的模块同名且位于同一目录下的文件。例如,要 mock 一个名为 session 的模块,请在旁边创建一个名为 session.mock.js|ts 的文件,具有以下特征:
- 它必须使用相对导入导入原始模块。
- 使用子路径或别名导入将导致它导入自身。
- 它应该重新导出原始模块的所有导出。
- 它应该使用
fn实用函数来 mock 原始模块中的任何必要功能。 - 它应该使用
mockName方法来确保在代码压缩时名称得以保留。 - 它不应引入可能影响其他测试或组件的副作用。Mock 文件应该是隔离的,并且只影响它们正在 mock 的模块。
这是一个名为 session 的模块的 mock 文件示例:
import { fn } from 'storybook/test';
import * as actual from './session';
export * from './session';
export const getUserFromSession = fn(actual.getUserFromSession).mockName('getUserFromSession');当您使用 fn 实用函数来 mock 模块时,您会创建完整的 Vitest mock 函数。请参阅下方以获取如何使用 mock 模块在 stories 中进行示例。
外部模块的 Mock 文件
您不能直接 mock 像 uuid 或 node:fs 这样的外部模块。相反,您必须将其包装在自己的模块中,然后像其他内部模块一样 mock 它。例如,对于 uuid,您可以这样做:
import { v4 } from 'uuid';
export const uuidv4 = v4;并创建一个包装器的 mock:
import { fn } from 'storybook/test';
import * as actual from './uuid';
export const uuidv4 = fn(actual.uuidv4).mockName('uuidv4');配置
要配置子路径导入,请在项目 package.json 文件中定义 imports 属性。此属性将子路径映射到实际文件路径。以下示例为四个内部模块配置了子路径导入:
{
"imports": {
"#api": {
// storybook condition applies to Storybook
"storybook": "./api.mock.ts",
"default": "./api.ts",
},
"#app/actions": {
"storybook": "./app/actions.mock.ts",
"default": "./app/actions.ts",
},
"#lib/session": {
"storybook": "./lib/session.mock.ts",
"default": "./lib/session.ts",
},
"#lib/db": {
// test condition applies to test environments *and* Storybook
"test": "./lib/db.mock.ts",
"default": "./lib/db.ts",
},
"#*": ["./*", "./*.ts", "./*.tsx"],
},
}此配置有三个值得注意的方面:
首先,每个子路径都必须以 # 开头,以区别于常规模块路径。#* 条目是一个通配符,它将所有子路径映射到根目录。
其次,键的顺序很重要。default 键应该放在最后。
第三,请注意每个模块条目中的storybook、test 和 default 键。storybook 值用于在 Storybook 中加载 mock 文件时导入它,而 default 值用于在您的项目中加载原始模块时导入它。test 条件也在 Storybook 中使用,这允许您在 Storybook 和其他测试中使用相同的配置。
在 package 配置到位后,您可以更新组件文件以使用子路径导入:
// ➖ Remove this line
// import { getUserFromSession } from '../../lib/session';
// ➕ Add this line
import { getUserFromSession } from '#lib/session';
// ...rest of the file只有当 TypeScript 配置中的 moduleResolution 属性设置为 'Bundler'、'NodeNext' 或 'Node16' 时,子路径导入才能被正确解析和类型化。
如果您当前使用的是 'node',那是为使用低于 v10 的 Node.js 版本的项目准备的。使用现代代码编写的项目可能不需要使用 'node'。
Storybook 推荐使用 TSConfig 备忘单来指导您设置 TypeScript 配置。
在 stories 中使用子路径导入
当您使用 fn 实用函数来 mock 模块时,您会创建完整的 Vitest mock 函数,它们具有许多可用方法。一些最有用的方法包括:
| 方法 | 描述 |
|---|---|
mockReturnValue(value) | 设置 mock 函数的返回值。 |
mockResolvedValue(value) | 设置 mock 异步函数解析到的值。 |
mockImplementation(fn) | 为 mock 函数设置自定义实现。 |
在这里,我们在一个 story 上定义了 beforeEach(它将在 story 渲染之前运行),以设置 Page 组件使用的 getUserFromSession 函数的 mock 返回值。
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { mocked } from 'storybook/test';
// 👇 Automocked module resolves to '../lib/__mocks__/session'
import { getUserFromSession } from '../lib/session';
import { Page } from './Page';
const meta = {
component: Page,
} satisfies Meta<typeof Page>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
async beforeEach() {
// 👇 Set the return value for the getUserFromSession function
mocked(getUserFromSession).mockReturnValue({ id: '1', name: 'Alice' });
},
};如果您正在 使用 TypeScript 编写 stories,则必须使用完整的 mock 文件名导入您的 mock 模块,才能在 stories 中正确键入函数。您的组件文件不需要这样做。这就是子路径导入或builder 别名的作用。
Spy on Mocked Modules
fn 实用函数还会 spy 原始模块的函数,您可以使用它来断言测试中的行为。例如,您可以使用 交互式测试 来验证函数是否被调用并带有特定参数。
例如,此 story 检查当用户单击保存按钮时 saveNote 函数是否被调用。
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
import { expect } from 'storybook/test';
// 👇 Automocked module resolves to '../app/__mocks__/actions'
import { saveNote } from '../app/actions';
import { createNotes } from '../app/mocks/notes';
import NoteUI from './note-ui';
const meta = { component: NoteUI } satisfies Meta<typeof NoteUI>;
export default meta;
type Story = StoryObj<typeof meta>;
const notes = createNotes();
export const SaveFlow: Story = {
name: 'Save Flow ▶',
args: {
isEditing: true,
note: notes[0],
},
play: async ({ canvas, userEvent }) => {
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();
},
};Builder 别名
如果您的项目无法使用自动 Mocking或子路径导入,您可以配置您的 Storybook builder 将模块别名指向mock 文件。这将指示 builder 在打包 Storybook stories 时用 mock 文件替换该模块。
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs-vite, vue3-vite, etc.
import type { StorybookConfig } from '@storybook/your-framework';
const config: StorybookConfig = {
framework: '@storybook/your-framework',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
viteFinal: async (config) => {
if (config.resolve) {
config.resolve.alias = {
...config.resolve?.alias,
// 👇 External module
lodash: import.meta.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api': import.meta.resolve('./api.mock.ts'),
'@/app/actions': import.meta.resolve('./app/actions.mock.ts'),
'@/lib/session': import.meta.resolve('./lib/session.mock.ts'),
'@/lib/db': import.meta.resolve('./lib/db.mock.ts'),
};
}
return config;
},
};
export default config;在 stories 中使用别名模块的方式与在 stories 中使用子路径导入类似,但您使用别名而不是子路径来导入模块。
常见场景
设置和清理
在 story 渲染之前,您可以使用异步 beforeEach 函数执行任何必要的设置(例如,配置 mock 行为)。此函数可以在 story、组件(将在文件中的所有 stories 中运行)或项目(定义在 .storybook/preview.js|ts 中,将在项目中所有 stories 中运行)级别定义。
您还可以从 beforeEach 返回一个清理函数,它将在 story 卸载后被调用。这对于取消订阅观察者等任务很有用。
不需要使用清理函数来恢复 fn() mocks,因为 Storybook 会在渲染 story 之前自动完成此操作。有关更多信息,请参阅parameters.test.restoreMocks API。
这是一个使用 mockdate 包来 mock Date 对象,并在 story 卸载时重置它的示例。
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
import MockDate from 'mockdate';
import { Page } from './Page';
const meta = {
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();
};
},
} satisfies Meta<typeof Page>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
async play({ canvas }) {
// ... This will run with the mocked Date
},
};故障排除
收到 exports is not defined 错误
Webpack 项目在使用自动 Mocking时可能会遇到 exports is not defined 错误。这通常是由尝试 mock 具有 CommonJS (CJS) 入口点的模块引起的。Webpack 的自动 Mocking 仅适用于仅具有 ESModules (ESM) 入口点的模块,因此您必须使用 mock 文件 来 mock CJS 模块。
Mocking 与其他测试工具的冲突
如果您已经使用其他测试工具(例如 Jest)设置了 Mocking,当使用 Storybook 的 Mocking 系统时,您可能会遇到冲突。这些冲突可能导致意外行为、错误或不正确的 mocks,因为两个工具都尝试 mock 同一个模块。在共享 mock 文件或配置时,这是与其他测试工具之间的一个已知问题。要解决此情况,我们建议您验证哪个工具负责 mock 特定模块,并确保配置不重叠以避免任何冲突。
