文件
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 模擬函式。請參閱下方的章節,以取得如何在您的 stories 中使用模擬模組的範例。

外部模組的模擬檔案

您無法直接模擬外部模組,例如 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"],
  },
}

此設定有三個值得注意的方面

首先,每個子路徑都必須以 # 開頭,以將其與常規模組路徑區分開來。#* 條目是一個 catch-all,它將所有子路徑映射到根目錄。

其次,鍵的順序很重要。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',那是為了使用舊於 v10 的 Node.js 版本的專案而設計的。使用現代程式碼編寫的專案可能不需要使用 'node'

Storybook 建議使用 TSConfig Cheat Sheet 來指導您設定 TypeScript 設定。

建置器別名

如果您的專案無法使用子路徑導入,您可以設定您的 Storybook 建置器,將模組別名指向模擬檔案。這將指示建置器在捆綁您的 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;

在 stories 中使用模擬模組

當您使用 fn 工具來模擬模組時,您將建立完整的 Vitest 模擬函式,它們具有許多有用的方法。例如,您可以使用 mockReturnValue 方法為模擬函式設定傳回值,或使用 mockImplementation 來定義自訂實作。

在這裡,我們在 story 上定義 beforeEach(它將在 story 渲染之前執行),以為 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 撰寫您的 stories,您必須使用完整的模擬檔案名稱來導入您的模擬模組,以便在您的 stories 中正確輸入函式類型。您不需要在您的元件檔案中執行此操作。這就是 子路徑導入建置器別名 的用途。

監聽模擬模組

fn 工具也會監聽原始模組的函式,您可以使用它來斷言它們在測試中的行為。例如,您可以使用元件測試來驗證函式是否使用特定參數呼叫。

例如,這個 story 檢查當使用者點擊儲存按鈕時,是否呼叫了 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();
  },
};

設定與清除

在 story 渲染之前,您可以使用非同步 beforeEach 函式來執行您需要的任何設定(例如,設定模擬行為)。此函式可以在 story、元件(將為檔案中的所有 stories 執行)或專案層級定義(在 .storybook/preview.js|ts 中定義,將為專案中的所有 stories 執行)。

您也可以從 beforeEach 傳回一個清除函式,該函式將在您的 story 卸載後呼叫。這對於取消訂閱觀察器等任務很有用。

沒有 必要使用清除函式來還原 fn() 模擬,因為 Storybook 會在渲染 story 之前自動執行此操作。請參閱 parameters.test.restoreMocks API 以取得更多資訊。

以下是使用 mockdate 套件來模擬 Date,並在 story 卸載時重設它的範例。

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