模擬模組
元件也可能依賴導入到元件檔案中的模組。這些模組可能來自外部套件或專案內部。在 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 模擬函式。請參閱下方的章節,以取得如何在您的 stories 中使用模擬模組的範例。
外部模組的模擬檔案
您無法直接模擬外部模組,例如 uuid
或 node: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 套件的一項功能,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"],
},
}
此設定有三個值得注意的方面
首先,每個子路徑都必須以 #
開頭,以將其與常規模組路徑區分開來。#*
條目是一個 catch-all,它將所有子路徑映射到根目錄。
其次,鍵的順序很重要。default
鍵應放在最後。
第三,請注意每個模組條目中的 storybook
、test
和 default
鍵。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 Cheat Sheet 來指導您設定 TypeScript 設定。
建置器別名
如果您的專案無法使用子路徑導入,您可以設定您的 Storybook 建置器,將模組別名指向模擬檔案。這將指示建置器在捆綁您的 Storybook stories 時,將模組替換為模擬檔案。
// 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
函式設定模擬傳回值
// 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
函式
// 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 卸載時重設它的範例。
// 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
},
};