文档
Storybook 文档

模拟模块

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

如果您喜欢通过示例学习,我们创建了一个 完整的演示项目,该项目使用此处描述的模拟策略。

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

对于这两种方法,都不支持模拟模块的相对导入。

模拟文件

要模拟模块,请创建一个与您要模拟的模块同名且位于同一目录下的文件。例如,要模拟一个名为 session 的模块,请在它旁边创建一个名为 session.mock.js|ts 的文件,该文件具有以下几个特点

  • 它必须使用相对导入导入原始模块。
    • 使用子路径或别名导入会导致它导入自身。
  • 它应该重新导出原始模块中的所有导出。
  • 它应该使用 fn 工具模拟原始模块中的任何必要功能。
  • 它应该使用 mockName 方法确保在缩小代码时名称保留。
  • 它不应该引入可能影响其他测试或组件的副作用。模拟文件应该是隔离的,只影响它们模拟的模块。

以下是一个名为 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 模拟函数。请参阅 以下内容,了解如何在故事中使用模拟模块的示例。

外部模块的模拟文件

您无法直接模拟像 uuidnode: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 包的一个功能,由 ViteWebpack 都支持。

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

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"]
  }
}

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

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

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

第三,请注意每个模块条目中的 storybooktestdefaultstorybook 值用于在 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 故事时用模拟文件替换模块。

.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;

在故事中使用模拟模块

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

在这里,我们在故事上定义了beforeEach(将在故事渲染之前运行),以设置getUserFromSession函数的模拟返回值,该函数由 Page 组件使用。

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 编写故事,您必须使用完整的模拟文件名导入模拟模块,以便在您的故事中正确地对函数进行类型化。您不需要在组件文件中这样做。这就是子路径导入构建器别名的作用。

监视模拟模块

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

例如,这个故事检查当用户单击保存按钮时,是否调用了saveNote函数。

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();
  },
};

设置和清理

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

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

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

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

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
  },
};