加入直播会话:美国东部时间周四上午 11 点,Storybook 9 发布及 AMA
文档
Storybook Docs

模拟模块

组件也可能依赖于导入到组件文件中的模块。这些模块可以来自外部包或项目内部。在 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 模拟函数。请参阅下方,了解如何在 Story 中使用模拟模块的示例。

外部模块的模拟文件

您无法直接模拟 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

只有在 TypeScript 配置中将 moduleResolution 属性设置为 'Bundler''NodeNext''Node16' 时,子路径导入才能正确解析和类型化。

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

Storybook 推荐阅读 TSConfig 速查表,以获取有关设置 TypeScript 配置的指导。

构建器别名

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

.storybook/main.ts
// 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 函数设置模拟返回值:

Page.stories.ts
// 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 函数是否被调用:

NoteUI.stories.ts
// 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 卸载时重置它的示例。

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