模拟模块
组件也可能依赖于导入到组件文件中的模块。这些模块可以来自外部包或项目内部。在 Storybook 中渲染或测试这些组件时,您可能希望模拟这些模块以控制它们的行为。
如果您更喜欢通过示例学习,我们创建了一个全面的演示项目,使用了此处描述的模拟策略。
在 Storybook 中模拟模块主要有两种方法。它们都涉及创建一个模拟文件来替换原始模块。这两种方法的区别在于如何将模拟文件导入到组件中。
对于这两种方法,都不支持模拟模块的相对导入。
模拟文件
要模拟模块,请创建一个与要模拟的模块同名且位于同一目录中的文件。例如,要模拟名为 session
的模块,请在其旁边创建一个名为 session.mock.js|ts
的文件,并具有以下几个特点:
- 它必须使用相对导入来导入原始模块。
- 使用子路径或别名导入会导致它导入自身。
- 它应该重新导出原始模块中的所有导出。
- 它应该使用
fn
工具函数来模拟原始模块中的任何必要功能。 - 它应该使用
mockName
方法,以确保在压缩时名称得以保留 - 它不应引入可能影响其他测试或组件的副作用。模拟文件应是隔离的,并且只影响它们所模拟的模块。
这是一个名为 session
的模块的模拟文件示例:
import { fn } from 'storybook/test';
import * as actual from './session';
export * from './session';
export const getUserFromSession = fn(actual.getUserFromSession).mockName('getUserFromSession');
当您使用 fn
工具函数模拟模块时,您创建的是完整的 Vitest 模拟函数。请参阅下方,了解如何在 Story 中使用模拟模块的示例。
外部模块的模拟文件
您无法直接模拟 uuid
或 node:fs
等外部模块。相反,您必须将其包装在自己的模块中,然后像模拟任何其他内部模块一样对其进行模拟。例如,对于 uuid
,您可以执行以下操作:
import { v4 } from 'uuid';
export const uuidv4 = v4;
并为该包装器创建模拟:
import { fn } from 'storybook/test';
import * as actual from './uuid';
export const uuidv4 = fn(actual.uuidv4).mockName('uuidv4');
子路径导入
模拟模块的推荐方法是使用子路径导入,这是 Node 包的一项特性,受到 Vite 和 Webpack 的支持。
要配置子路径导入,请在项目的 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 中加载时导入模拟文件,而 default
值用于在项目中加载时导入原始模块。test
条件也在 Storybook 内部使用,这使您可以在 Storybook 和其他测试中使用相同的配置。
完成包配置后,您可以更新组件文件以使用子路径导入:
// ➖ 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'
,那适用于使用 Node.js v10 之前版本的项目。使用现代代码编写的项目可能不需要使用 'node'
。
Storybook 推荐阅读 TSConfig 速查表,以获取有关设置 TypeScript 配置的指导。
构建器别名
如果您的项目无法使用子路径导入,您可以配置 Storybook 构建器将模块别名为模拟文件。这将指示构建器在打包 Storybook Story 时用模拟文件替换该模块。
// 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: require.resolve('./lodash.mock'),
// 👇 Internal modules
'@/api': path.resolve(__dirname, './api.mock.ts'),
'@/app/actions': path.resolve(__dirname, './app/actions.mock.ts'),
'@/lib/session': path.resolve(__dirname, './lib/session.mock.ts'),
'@/lib/db': path.resolve(__dirname, './lib/db.mock.ts'),
};
}
return config;
},
};
export default config;
在 Story 中使用模拟模块
当您使用 fn
工具函数模拟模块时,您创建的是完整的 Vitest 模拟函数,它们有许多实用的方法。例如,您可以使用 mockReturnValue
方法为模拟函数设置返回值,或使用 mockImplementation
定义自定义实现。
这里,我们在 Story 上定义 beforeEach
函数(它将在 Story 渲染之前运行),为 Page 组件使用的 getUserFromSession
函数设置模拟返回值:
// 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';
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { getUserFromSession } from '#api/session.mock';
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
getUserFromSession.mockReturnValue({ id: '1', name: 'Alice' });
},
};
如果您使用 TypeScript 编写 Story,则必须使用完整的模拟文件名导入模拟模块,以便在 Story 中正确进行类型检查。在组件文件中则不需要这样做。这正是子路径导入或构建器别名的作用所在。
监听模拟模块
fn
工具函数还可以监听原始模块的函数,您可以使用它来断言其在测试中的行为。例如,您可以使用交互测试来验证函数是否以特定参数被调用。
例如,此 Story 检查当用户点击保存按钮时,saveNote
函数是否被调用:
// Replace your-framework with svelte-vite or sveltekit
import type { Meta, StoryObj } from '@storybook/your-framework';
import { expect } from 'storybook/test';
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { saveNote } from '#app/actions.mock';
import { createNotes } from '#mocks/notes';
import NoteUI from './note-ui.svelte';
const meta = {
title: 'Mocked/NoteUI',
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();
},
};
设置与清理
在 Story 渲染之前,您可以使用异步的 beforeEach
函数来执行任何所需的设置(例如,配置模拟行为)。此函数可以在 Story、组件(将运行该文件中所有 Story)或项目(定义在 .storybook/preview.js|ts
中,将运行项目中所有 Story)级别定义。
您还可以从 beforeEach
返回一个清理函数,该函数将在 Story 卸载后调用。这对于取消订阅观察者等任务非常有用。
使用清理函数恢复 fn()
模拟是非必要的,因为 Storybook 在渲染 Story 之前会自动执行此操作。有关更多信息,请参阅parameters.test.restoreMocks
API。
这是一个使用 mockdate
包模拟 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';
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { getUserFromSession } from '#api/session.mock';
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
},
};