Docs
Storybook Docs

模拟模块

组件还可以依赖于导入到组件文件中的模块。这些可以来自外部包或项目内部。在 Storybook 中渲染这些组件或测试它们时,你可能希望模拟这些模块来控制它们的行为。

¥Components can also depend on modules that are imported into the component file. These can be from external packages or internal to your project. When rendering those components in Storybook or testing them, you may want to mock those modules to control their behavior.

如果你更喜欢通过示例学习,我们使用此处描述的模拟策略创建了一个 综合演示项目

¥If you prefer learning by example, we created a comprehensive demo project using the mocking strategies described here.

在 Storybook 中模拟模块有两种主要方法。它们都涉及创建一个模拟文件来替换原始模块。两种方法之间的区别在于如何将模拟文件导入组件。

¥There are two primary approaches to mocking modules in Storybook. They both involve creating a mock file to replace the original module. The difference between the two approaches is how you import the mock file into your component.

对于任何一种方法,都不支持模拟模块的相对导入。

¥For either approach, relative imports of the mocked module are not supported.

模拟文件

¥Mock files

要模拟模块,请创建一个与要模拟的模块同名且位于同一目录中的文件。例如,要模拟名为 session 的模块,请在其旁边创建一个名为 session.mock.js|ts 的文件,并具有一些特性:

¥To mock a module, create a file with the same name and in the same directory as the module you want to mock. For example, to mock a module named session, create a file next to it named session.mock.js|ts, with a few characteristics:

  • 它必须使用相对导入来导入原始模块。

    ¥It must import the original module using a relative import.

    • 使用子路径或别名导入会导致它导入自身。

      ¥Using a subpath or alias import would result in it importing itself.

  • 它应该重新导出原始模块的所有导出。

    ¥It should re-export all exports from the original module.

  • 它应该使用 fn 实用程序来模拟原始模块中的任何必要功能。

    ¥It should use the fn utility to mock any necessary functionality from the original module.

  • 它应该使用 mockName 方法来确保在最小化时保留名称

    ¥It should use the mockName method to ensure the name is preserved when minified

  • 它不应该引入可能影响其他测试或组件的副作用。模拟文件应隔离,并且仅影响它们正在模拟的模块。

    ¥It should not introduce side effects that could affect other tests or components. Mock files should be isolated and only affect the module they are mocking.

以下是名为 session 的模块的模拟文件的示例:

¥Here's an example of a mock file for a module named session:

lib/session.mock.ts
import { fn } from '@storybook/test';
import * as actual from './session';
 
export * from './session';
export const getUserFromSession = fn(actual.getUserFromSession).mockName('getUserFromSession');

当你使用 fn 实用程序模拟模块时,你将创建完整的 Vitest 模拟函数。有关如何在故事中使用模拟模块的示例,请参阅 below

¥When you use the fn utility to mock a module, you create full Vitest mock functions. See below for examples of how you can use a mocked module in your stories.

外部模块的模拟文件

¥Mock files for external modules

你不能直接模拟外部模块,如 uuidnode:fs。相反,你必须将其封装在你自己的模块中,你可以像任何其他内部模块一样模拟它。例如,使用 uuid,你可以执行以下操作:

¥You can't directly mock an external module like uuid or node:fs. Instead, you must wrap it in your own module, which you can mock like any other internal one. For example, with uuid, you could do the following:

// lib/uuid.ts
import { v4 } from 'uuid';
 
export const uuidv4 = v4;

并为封装器创建一个模拟:

¥And create a mock for the wrapper:

// lib/uuid.mock.ts
import { fn } from '@storybook/test';
 
import * as actual from './uuid';
 
export const uuidv4 = fn(actual.uuidv4).mockName('uuidv4');

子路径导入

¥Subpath imports

模拟模块的推荐方法是使用 子路径导入,这是 Node 包的一项功能,ViteWebpack 均支持该功能。

¥The recommended method for mocking modules is to use subpath imports, a feature of Node packages that is supported by both Vite and Webpack.

要配置子路径导入,请在项目的 package.json 文件中定义 imports 属性。此属性将子路径映射到实际文件路径。以下示例为四个内部模块配置子路径导入:

¥To configure subpath imports, you define the imports property in your project's package.json file. This property maps the subpath to the actual file path. The example below configures subpath imports for four internal modules:

package.json
{
  "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"]
  }
}

此配置有三个方面值得注意:

¥There are three aspects to this configuration worth noting:

首先,每个子路径都必须以 # 开头,以将其与常规模块路径区分开来。#* 条目是一个将所有子路径映射到根目录的万能条目。

¥First, each subpath must begin with #, to differentiate it from a regular module path. The #* entry is a catch-all that maps all subpaths to the root directory.

其次,密钥的顺序很重要。default 键应该放在最后。

¥Second, the order of the keys is important. The default key should come last.

第三,请注意每个模块条目中的 storybooktestdefault 键。storybook 值用于在 Storybook 中加载时导入模拟文件,而 default 值用于在项目中加载时导入原始模块。test 条件也在 Storybook 中使用,它允许你在 Storybook 和其他测试中使用相同的配置。

¥Third, note the storybook, test, and default keys in each module's entry. The storybook value is used to import the mock file when loaded in Storybook, while the default value is used to import the original module when loaded in your project. The test condition is also used within Storybook, which allows you to use the same configuration in Storybook and your other tests.

使用包配置后,你可以更新组件文件以使用子路径导入:

¥With the package configuration in place, you can then update your component file to use the subpath import:

// AuthButton.ts
// ➖ 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' 时,子路径导入才会被正确解析和输入。

¥Subpath imports will only be correctly resolved and typed when the moduleResolution property is set to 'Bundler', 'NodeNext', or 'Node16' in your TypeScript configuration.

如果你当前正在使用 'node',则该版本适用于使用 v10 之前的 Node.js 版本的项目。用现代代码编写的项目可能不需要使用 'node'

¥If you are currently using 'node', that is intended for projects using a Node.js version older than v10. Projects written with modern code likely do not need to use 'node'.

Storybook 建议使用 TSConfig 备忘单 来指导你设置 TypeScript 配置。

¥Storybook recommends the TSConfig Cheat Sheet for guidance on setting up your TypeScript configuration.

构建器别名

¥Builder aliases

如果你的项目无法使用 子路径导入,你可以配置 Storybook 构建器以将模块别名为模拟文件。这将指示构建器在打包 Storybook 故事时用模拟文件替换模块。

¥If your project is unable to use subpath imports, you can configure your Storybook builder to alias the module to the mock file. This will instruct the builder to replace the module with the mock file when bundling your Storybook stories.

.storybook/main.ts
// 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;

在故事中使用模拟模块

¥Using mocked modules in stories

当你使用 fn 实用程序模拟模块时,你将创建具有许多有用方法的完整 Vitest 模拟函数。例如,你可以使用 mockReturnValue 方法为模拟函数设置返回值,或使用 mockImplementation 来定义自定义实现。

¥When you use the fn utility to mock a module, you create full Vitest mock functions which have many useful methods. For example, you can use the mockReturnValue method to set a return value for the mocked function or mockImplementation to define a custom implementation.

在这里,我们在故事中定义 beforeEach(将在故事渲染之前运行),以设置页面使用的 getUserFromSession 函数的模拟返回值组件:

¥Here, we define beforeEach on a story (which will run before the story is rendered) to set a mocked return value for the getUserFromSession function used by the Page component:

Page.stories.ts
// 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 写你的故事,你必须使用完整的模拟文件名导入模拟模块,以便在故事中正确输入函数。你不需要在组件文件中执行此操作。这就是 子路径导入构建器别名 的用途。

¥If you are writing your stories in TypeScript, you must import your mock modules using the full mocked file name to have the functions correctly typed in your stories. You do not need to do this in your component files. That's what the subpath import or builder alias is for.

监视模拟模块

¥Spying on mocked modules

fn 实用程序还会监视原始模块的功能,你可以使用它在测试中断言它们的行为。例如,你可以使用 组件测试 来验证是否使用特定参数调用了函数。

¥The fn utility also spies on the original module's functions, which you can use to assert their behavior in your tests. For example, you can use component tests to verify that a function was called with specific arguments.

例如,此故事检查用户单击保存按钮时是否调用了 saveNote 函数:

¥For example, this story checks that the saveNote function was called when the user clicks the save button:

NoteUI.stories.ts
// 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();
  },
};

设置和清理

¥Setting up and cleaning up

在故事渲染之前,你可以使用异步 beforeEach 函数执行所需的任何设置(例如,配置模拟行为)。此函数可以在故事、组件(将为文件中的所有故事运行)或项目(在 .storybook/preview.js|ts 中定义,将为项目中的所有故事运行)中定义。

¥Before the story renders, you can use the asynchronous beforeEach function to perform any setup you need (e.g., configure the mock behavior). This function can be defined at the story, component (which will run for all stories in the file), or project (defined in .storybook/preview.js|ts, which will run for all stories in the project).

你还可以从 beforeEach 返回一个清理函数,该函数将在你的故事卸载后调用。这对于取消订阅观察者等任务很有用。

¥You can also return a cleanup function from beforeEach which will be called after your story unmounts. This is useful for tasks like unsubscribing observers, etc.

无需使用清理功能恢复 fn() 模拟,因为 Storybook 在渲染故事之前会自动执行此操作。有关更多信息,请参阅 parameters.test.restoreMocks API

¥It is not necessary to restore fn() mocks with the cleanup function, as Storybook will already do that automatically before rendering a story. See the parameters.test.restoreMocks API for more information.

以下是使用 mockdate 包模拟 Date 并在故事卸载时重置它的示例。

¥Here's an example of using the mockdate package to mock the Date and reset it when the story unmounts.

Page.stories.ts
// 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
  },
};