模拟模块
组件也可能依赖于导入到组件文件中的模块。这些模块可以来自外部包或项目内部。在 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
  },
};