文件
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 金鑰應該最後出現。

第三,請注意每個模組項目中的 storybooktestdefault 金鑰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

只有當您的 TypeScript 設定中的 moduleResolution 屬性設定為 'Bundler''NodeNext''Node16' 時,才能正確解析和鍵入子路徑匯入。

如果您目前使用的是 'node',則它是為使用低於 v10 的 Node.js 版本的專案而設計的。使用現代程式碼編寫的專案可能不需要使用 'node'

Storybook 建議使用 TSConfig 作弊表,以取得設定 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 (將在故事呈現之前執行),為 Page 元件使用的 getUserFromSession 函式設定模擬回傳值

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