文件
Storybook 文件

組件測試

當您建構更複雜的使用者介面(例如頁面)時,組件的職責不只是渲染使用者介面,它們還負責提取資料和管理狀態。組件測試可讓您驗證使用者介面的這些功能性方面。

簡而言之,您首先為組件的初始狀態提供適當的 props。然後模擬使用者行為,例如點擊和表單輸入。最後,檢查使用者介面和組件狀態是否正確更新。

在 Storybook 中,這個熟悉的工作流程在您的瀏覽器中進行。這讓偵錯錯誤變得更容易,因為您在與開發組件相同的環境(瀏覽器)中執行測試。

Storybook 中的組件測試如何運作?

您首先撰寫 story 以設定組件的初始狀態。然後使用 play 函數模擬使用者行為。最後,使用 test-runner 確認組件是否正確渲染,以及您的組件測試(使用 play 函數)是否通過。測試執行器可以透過命令列或在 CI 中執行。

  • play 函數是一小段程式碼,會在 story 完成渲染後執行。您可以使用它來測試使用者工作流程。
  • 該測試是使用 Storybook 工具化版本 VitestTesting Library 撰寫的,它們來自 @storybook/test 套件。
  • @storybook/addon-interactions 可視化 Storybook 中的測試,並提供播放介面,以便於進行基於瀏覽器的偵錯。
  • @storybook/test-runner 是一個獨立的實用程式—由 JestPlaywright 提供支援—它會執行您的所有互動測試並捕獲損壞的 stories。
    • 實驗性的 Vitest 插件 也可用,它可以將您的 stories 轉換為 Vitest 測試並在瀏覽器中執行它們。

設定 interactions 插件

若要使用 Storybook 啟用完整的組件測試體驗,您需要採取額外步驟來正確設定它。我們建議您先閱讀 測試執行器文件,再繼續進行其餘的必要設定。

執行以下命令以安裝 interactions 插件和相關依賴項。

npm install @storybook/test @storybook/addon-interactions --save-dev

更新您的 Storybook 設定(在 .storybook/main.js|ts 中)以包含 interactions 插件。

.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)'],
  addons: [
    // Other Storybook addons
    '@storybook/addon-interactions', // 👈 Register the addon
  ],
};
 
export default config;

撰寫組件測試

測試本身定義在連接到 story 的 play 函數內。以下是如何使用 Storybook 和 play 函數設定組件測試的範例

LoginForm.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { userEvent, within, expect } from '@storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta: Meta<typeof LoginForm> = {
  component: LoginForm,
};
 
export default meta;
type Story = StoryObj<typeof LoginForm>;
 
export const EmptyForm: Story = {};
 
/*
 * See https://storybook.dev.org.tw/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const FilledForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // 👇 Simulate interactions with the component
    await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
 
    await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
 
    // See https://storybook.dev.org.tw/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    await userEvent.click(canvas.getByRole('button'));
 
    // 👇 Assert DOM structure
    await expect(
      canvas.getByText(
        'Everything is perfect. Your account is ready and we should probably get you started!',
      ),
    ).toBeInTheDocument();
  },
};

一旦 story 在使用者介面中載入,它就會模擬使用者的行為並驗證底層邏輯。

在組件渲染之前執行程式碼

您可以使用 play 方法中的 mount 函數在渲染之前執行程式碼。

以下是使用 mockdate 套件模擬 Date 的範例,這是一種使您的 story 在一致狀態下渲染的實用方法。

Page.stories.ts
import MockDate from 'mockdate';
 
// ...rest of story file
 
export const ChristmasUI: Story = {
  async play({ mount }) {
    MockDate.set('2024-12-25');
    // 👇 Render the component with the mocked date
    await mount();
    // ...rest of test
  },
};

使用 mount 函數有兩個要求

  1. 必須context(傳遞到您的 play 函數的參數)中解構 mount 屬性。這可確保 Storybook 不會在 play 函數開始之前開始渲染 story。
  2. 您的 Storybook 框架或建構器必須設定為轉譯為 ES2017 或更新版本。這是因為解構語句和 async/await 用法否則會被轉譯掉,這會阻止 Storybook 識別您對 mount 的使用。

在渲染之前建立模擬資料

您也可以使用 mount 來建立您想要傳遞給組件的模擬資料。為此,首先在 play 函數中建立您的資料,然後使用配置了該資料的組件呼叫 mount 函數。在此範例中,我們建立一個模擬 note 並將其 id 傳遞給 Page 組件,我們使用 mount 呼叫它。

Page.stories.tsx
export const Default: Story = {
  play: async ({ mount, args }) => {
    const note = await db.note.create({
      data: { title: 'Mount inside of play' },
    });
 
    const canvas = await mount(
      // 👇 Pass data that is created inside of the play function to the component
      //   For example, a just-generated UUID
      <Page {...args} params={{ id: String(note.id) }} />,
    );
 
    await userEvent.click(await canvas.findByRole('menuitem', { name: /login to add/i }));
  },
  argTypes: {
    // 👇 Make the params prop un-controllable, as the value is always overriden in the play function.
    params: { control: { disable: true } },
  },
};

當您呼叫不帶參數的 mount() 時,組件將使用 story 的渲染函數進行渲染,無論是隱式預設還是顯式自訂定義

當您在 mount 函數內掛載特定組件(如上面的範例所示)時,story 的渲染函數將被忽略。這就是為什麼您必須將 args 轉發到組件的原因。

在檔案中的每個 story 之前執行程式碼

有時您可能需要在檔案中的每個 story 之前執行相同的程式碼。例如,您可能需要設定組件或模組的初始狀態。您可以透過將非同步 beforeEach 函數新增至組件 meta 來執行此操作。

您可以從 beforeEach 函數傳回一個清理函數,該函數將在每個 story 之後、當 story 重新掛載或導航離開時執行。

一般而言,您應該在 預覽檔案的 beforeAllbeforeEach 函數中重設組件和模組狀態,以確保它適用於您的整個專案。但是,如果組件的需求特別獨特,您可以使用組件 meta beforeEach 中傳回的清理函數來根據需要重設狀態。

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

設定或重設所有測試的狀態

當您變更組件的狀態時,在渲染另一個 story 之前重設該狀態以維持測試之間的隔離非常重要。

有兩個重設狀態的選項:beforeAllbeforeEach

beforeAll

預覽檔案 (.storybook/preview.js|ts) 中的 beforeAll 函數將在專案中的任何 story 之前執行一次,並且不會在 story 之間重新執行。除了在啟動測試執行時的初始執行之外,除非預覽檔案已更新,否則它不會再次執行。這是引導您的專案或執行您的整個專案所依賴的任何設定的好地方,如下面的範例所示。

您可以從 beforeAll 函數傳回一個清理函數,該函數將在重新執行 beforeAll 函數之前或在測試執行器中的關閉過程中執行。

.storybook/preview.ts
// Replace your-renderer with the renderer you are using (e.g., react, vue3, angular, etc.)
import { Preview } from '@storybook/your-renderer';
 
import { init } from '../project-bootstrap';
 
const preview: Preview = {
  async beforeAll() {
    await init();
  },
};
 
export default preview;

beforeEach

與僅執行一次的 beforeAll 不同,預覽檔案 (.storybook/preview.js|ts) 中的 beforeEach 函數將在專案中的每個 story 之前執行。這最適合用於重設所有或大多數 story 使用的狀態或模組。在下面的範例中,我們使用它來重設模擬的 Date。

您可以從 beforeEach 函數傳回一個清理函數,該函數將在每個 story 之後、當 story 重新掛載或導航離開時執行。

.storybook/preview.ts
// Replace your-renderer with the renderer you are using (e.g., react, vue3, angular, etc.)
import { Preview } from '@storybook/your-renderer';
import MockDate from 'mockdate';
 
const preview: Preview = {
  async beforeEach() {
    MockDate.reset();
  },
};
 
export default preview;

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

user-events 的 API

在幕後,Storybook 的 @storybook/test 套件提供了 Testing Library 的 user-events API。如果您熟悉 Testing Library,您應該會在 Storybook 中感到賓至如歸。

以下是 user-event 的簡略 API。如需更多資訊,請查看官方 user-event 文件

使用者事件描述
clear選取輸入框或文字區域內的文字並將其刪除
userEvent.clear(await within(canvasElement).getByRole('myinput'));
click點擊元素,呼叫 click() 函數
userEvent.click(await within(canvasElement).getByText('mycheckbox'));
dblClick點擊元素兩次
userEvent.dblClick(await within(canvasElement).getByText('mycheckbox'));
deselectOptions從 select 元素的特定選項中移除選取
userEvent.deselectOptions(await within(canvasElement).getByRole('listbox'),'1');
hover懸停元素
userEvent.hover(await within(canvasElement).getByTestId('example-test'));
keyboard模擬鍵盤事件
userEvent.keyboard(‘foo’);
selectOptions選取 select 元素的指定選項或多個選項
userEvent.selectOptions(await within(canvasElement).getByRole('listbox'),['1','2']);
type在輸入框或文字區域內寫入文字
userEvent.type(await within(canvasElement).getByRole('my-input'),'Some text');
unhover取消懸停元素
userEvent.unhover(await within(canvasElement).getByLabelText(/Example/i));

使用 Vitest 的 API 斷言測試

Storybook 的 @storybook/test 也提供來自 Vitest 的 API,例如 expectvi.fn。這些 API 改善了您的測試體驗,協助您斷言函數是否已被呼叫、DOM 中是否存在元素等等。如果您習慣使用來自 JestVitest 等測試套件的 expect,則可以使用非常相似的方式撰寫組件測試。

Form.stories.ts|tsx
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { userEvent, waitFor, within, expect, fn } from '@storybook/test';
 
import { Form } from './Form';
 
const meta: Meta<typeof Form> = {
  component: Form,
  args: {
    // 👇 Use `fn` to spy on the onSubmit arg
    onSubmit: fn(),
  },
};
 
export default meta;
type Story = StoryObj<typeof Form>;
 
/*
 * See https://storybook.dev.org.tw/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const Submitted: Story = {
  play: async ({ args, canvasElement, step }) => {
    const canvas = within(canvasElement);
 
    await step('Enter credentials', async () => {
      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
    });
 
    await step('Submit form', async () => {
      await userEvent.click(canvas.getByRole('button'));
    });
 
    // 👇 Now we can assert that the onSubmit arg was called
    await waitFor(() => expect(args.onSubmit).toHaveBeenCalled());
  },
};

使用 step 函數將互動分組

對於複雜的流程,將一組相關的互動組合在一起(使用 step 函數)可能很有價值。這可讓您提供自訂標籤來描述一組互動

MyComponent.stories.ts
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { userEvent, within } from '@storybook/test';
 
import { MyComponent } from './MyComponent';
 
const meta: Meta<typeof MyComponent> = {
  component: MyComponent,
};
 
export default meta;
type Story = StoryObj<typeof MyComponent>;
 
/*
 * See https://storybook.dev.org.tw/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const Submitted: Story = {
  play: async ({ args, canvasElement, step }) => {
    const canvas = within(canvasElement);
 
    await step('Enter email and password', async () => {
      await userEvent.type(canvas.getByTestId('email'), 'hi@example.com');
      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
    });
 
    await step('Submit form', async () => {
      await userEvent.click(canvas.getByRole('button'));
    });
  },
};

這會將您的互動顯示在可摺疊的群組中

Component testing with labeled steps

模擬模組

如果您的組件依賴於匯入到組件檔案中的模組,您可以模擬這些模組以控制和斷言它們的行為。這在模擬模組指南中詳細說明。

然後,您可以將模擬模組(它具有 Vitest 模擬函數的所有有用方法)匯入到您的 story 中,並使用它來斷言組件的行為

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

互動式偵錯工具

如果您檢查您的 interactions 面板,您會看到逐步流程。它還提供了一組方便的使用者介面控制項,可用於暫停、恢復、倒帶和逐步執行每個互動。

play 函數在 story 渲染後執行。如果發生錯誤,它會顯示在 interaction 插件面板中,以協助偵錯。

由於 Storybook 是一個網路應用程式,因此任何擁有 URL 的人都可以重現錯誤,並獲得相同的詳細資訊,而無需任何額外的環境設定或工具。

Component testing with an error

透過自動發布 Storybook 在提取請求中,進一步簡化組件測試。這為團隊提供了一個通用的參考點,以測試和偵錯 stories。

使用 test-runner 執行測試

Storybook 僅在您檢視 story 時執行組件測試。因此,您必須遍歷每個 story 才能執行所有檢查。隨著您的 Storybook 不斷增長,手動檢閱每個變更變得不切實際。Storybook test-runner 透過為您執行所有測試來自動化此流程。若要執行 test-runner,請開啟新的終端機視窗並執行以下命令

npm run test-storybook

Component test with test runner

如果需要,您可以為 test-runner 提供其他標誌。請閱讀文件以了解更多資訊。

自動化

當您準備好將程式碼推送至提取請求時,您會希望在使用持續整合 (CI) 服務合併程式碼之前自動執行所有檢查。請閱讀我們的文件,以取得有關設定 CI 環境以執行測試的詳細指南。

疑難排解

組件測試和視覺化測試之間有什麼區別?

當組件測試全面應用於每個組件時,維護成本可能會很高。我們建議將它們與其他方法(例如視覺化測試)結合使用,以在減少維護工作量的同時實現全面覆蓋。

組件測試與單獨使用 Jest + Testing Library 有何不同?

組件測試將 Jest 和 Testing Library 整合到 Storybook 中。最大的好處是能夠在真實瀏覽器中檢視您正在測試的組件。這有助於您以視覺方式偵錯,而不是在命令列中取得(虛假的)DOM 轉儲,或遇到 JSDOM 如何模擬瀏覽器功能的限制。將 stories 和測試放在一個檔案中也比將它們分散在多個檔案中更方便。

了解其他使用者介面測試