模拟模块
组件也可以依赖于导入到组件文件的模块。这些模块可以来自外部包,也可以来自项目内部。在 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 模拟函数。请参阅 以下内容,了解如何在故事中使用模拟模块的示例。
外部模块的模拟文件
您无法直接模拟像 uuid
或 node:fs
这样的外部模块。相反,您必须将其包装在您自己的模块中,然后像任何其他内部模块一样模拟它。例如,对于 uuid
,您可以执行以下操作
// lib/uuid.ts
import { v4 } from 'uuid';
export const uuidv4 = v4;
并为包装器创建一个模拟
// lib/uuid.mock.ts
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 和其他测试中使用相同的配置。
配置好包后,您可以更新组件文件以使用子路径导入
// AuthButton.ts
// ➖ Remove this line
// import { getUserFromSession } from '../../lib/session';
// ➕ Add this line
import { getUserFromSession } from '#lib/session';
// ... rest of the file
只有当 moduleResolution
属性 在您的 TypeScript 配置中设置为 'Bundler'
、'NodeNext'
或 'Node16'
时,子路径导入才会被正确解析并类型化。
如果您目前使用的是 'node'
,则它是针对使用低于 v10 版本的 Node.js 的项目。使用现代代码编写的项目可能不需要使用 'node'
。
Storybook 建议使用 TSConfig Cheat Sheet 来指导您设置 TypeScript 配置。
构建器别名
如果您的项目无法使用子路径导入,您可以配置您的 Storybook 构建器将模块别名到模拟文件。这将指示构建器在捆绑 Storybook 故事时用模拟文件替换模块。
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
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;
在故事中使用模拟模块
当您使用fn
实用程序来模拟模块时,您创建了完整的Vitest 模拟函数,它们具有许多有用的方法。例如,您可以使用mockReturnValue
方法为模拟函数设置返回值,或使用mockImplementation
来定义自定义实现。
在这里,我们在故事上定义了beforeEach
(将在故事渲染之前运行),以设置getUserFromSession
函数的模拟返回值,该函数由 Page 组件使用。
// Replace your-renderer with the name of your renderer (e.g. react, vue3)
import type { Meta, StoryObj } from '@storybook/your-renderer';
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import { getUserFromSession } from '#api/session.mock';
import { Page } from './Page';
const meta: Meta<typeof Page> = {
component: Page,
};
export default meta;
type Story = StoryObj<typeof Page>;
export const Default: Story = {
async beforeEach() {
// 👇 Set the return value for the getUserFromSession function
getUserFromSession.mockReturnValue({ id: '1', name: 'Alice' });
},
};
如果您是用 TypeScript 编写故事,您必须使用完整的模拟文件名导入模拟模块,以便在您的故事中正确地对函数进行类型化。您不需要在组件文件中这样做。这就是子路径导入或构建器别名的作用。
监视模拟模块
fn
实用程序还会监视原始模块的函数,您可以使用它来断言测试中的行为。例如,您可以使用组件测试来验证某个函数是否使用特定参数被调用。
例如,这个故事检查当用户单击保存按钮时,是否调用了saveNote
函数。
// Replace your-renderer with the name of your renderer (e.g. react, vue3)
import type { Meta, StoryObj } from '@storybook/your-renderer';
import { expect, userEvent, within } 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';
const meta: Meta<typeof NoteUI> = {
title: 'Mocked/NoteUI',
component: NoteUI,
};
export default meta;
type Story = StoryObj<typeof NoteUI>;
const notes = createNotes();
export const SaveFlow: Story = {
name: 'Save Flow ▶',
args: {
isEditing: true,
note: notes[0],
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
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();
},
};
设置和清理
在故事渲染之前,您可以使用异步beforeEach
函数来执行所需的任何设置(例如,配置模拟行为)。此函数可以在故事、组件(将为文件中的所有故事运行)或项目(在.storybook/preview.js|ts
中定义,将为项目中的所有故事运行)中定义。
您还可以从beforeEach
中返回一个清理函数,该函数将在您的故事卸载后被调用。这对于诸如取消订阅观察者等任务很有用。
使用清理函数不需要恢复fn()
模拟,因为 Storybook 已经在渲染故事之前自动执行了此操作。有关更多信息,请参阅parameters.test.restoreMocks
API。
以下是如何使用mockdate
包来模拟Date
并在故事卸载时重置它的示例。
// Replace your-renderer with the name of your renderer (e.g. react, vue3)
import type { Meta, StoryObj } from '@storybook/your-renderer';
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: Meta<typeof Page> = {
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();
};
},
};
export default meta;
type Story = StoryObj<typeof Page>;
export const Default: Story = {
async play({ canvasElement }) {
// ... This will run with the mocked Date
},
};