文件
Storybook 文件

Play 功能

觀看教學影片

Play 函數是在 story 渲染後執行的小段程式碼。讓您與元件互動,並測試原本需要使用者介入的情境。

設定 interactions 附加元件

我們建議您在開始使用 play 函數撰寫 stories 之前,先安裝 Storybook 的 addon-interactions。它是完美的搭配,包含一組方便的 UI 控制項,讓您掌控執行流程。您可以隨時暫停、繼續、倒轉和逐步執行每個 interaction。同時也為您提供易於使用的偵錯工具,以解決潛在問題。

執行以下命令以安裝附加元件和所需的依賴項。

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;

使用 play 函數撰寫 stories

Storybook 的 play 函數是在 story 渲染完成後執行的小段程式碼。在 addon-interactions 的輔助下,它讓您能夠建立元件互動和測試情境,這些情境在沒有使用者介入的情況下是不可能的。例如,如果您正在開發註冊表單並想要驗證它,您可以使用 play 函數撰寫以下 story

RegistrationForm.stories.ts|tsx
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { userEvent, within } from '@storybook/test';
 
import { RegistrationForm } from './RegistrationForm';
 
const meta: Meta<typeof RegistrationForm> = {
  component: RegistrationForm,
};
 
export default meta;
type Story = StoryObj<typeof RegistrationForm>;
 
/*
 * 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);
 
    const emailInput = canvas.getByLabelText('email', {
      selector: 'input',
    });
 
    await userEvent.type(emailInput, 'example-email@email.com', {
      delay: 100,
    });
 
    const passwordInput = canvas.getByLabelText('password', {
      selector: 'input',
    });
 
    await userEvent.type(passwordInput, 'ExamplePassword', {
      delay: 100,
    });
    // See https://storybook.dev.org.tw/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    const submitButton = canvas.getByRole('button');
 
    await userEvent.click(submitButton);
  },
};

請參閱元件測試文件,以取得可用 API 事件的概觀。

當 Storybook 完成 story 渲染時,它會執行在 play 函數中定義的步驟,與元件互動並填寫表單資訊。所有這些都不需要使用者介入。如果您查看您的 Interactions 面板,您會看到逐步流程。

組合 stories

感謝 元件 Story 格式,一種基於 ES6 模組的檔案格式,您也可以組合您的 play 函數,類似於其他現有的 Storybook 功能 (例如,args)。例如,如果您想要驗證元件的特定工作流程,您可以撰寫以下 stories

MyComponent.stories.ts|tsx
// 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 FirstStory: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    await userEvent.type(canvas.getByTestId('an-element'), 'example-value');
  },
};
 
export const SecondStory: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    await userEvent.type(canvas.getByTestId('other-element'), 'another value');
  },
};
 
export const CombinedStories: Story = {
  play: async ({ context, canvasElement }) => {
    const canvas = within(canvasElement);
 
    // Runs the FirstStory and Second story play function before running this story's play function
    await FirstStory.play(context);
    await SecondStory.play(context);
    await userEvent.type(canvas.getByTestId('another-element'), 'random value');
  },
};

透過組合 stories,您正在重新建立整個元件工作流程,並且可以發現潛在問題,同時減少您需要撰寫的樣板程式碼。

使用事件

大多數現代 UI 的建構都著重於互動 (例如,點擊按鈕、選擇選項、勾選核取方塊),為終端使用者提供豐富的體驗。透過 play 函數,您可以將相同等級的互動融入您的 stories 中。

常見的元件互動類型是按鈕點擊。如果您需要在您的 story 中重現它,您可以將您的 story 的 play 函數定義如下

MyComponent.stories.ts|tsx
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { fireEvent, 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 ClickExample: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // 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'));
  },
};
 
export const FireEventExample: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // See https://storybook.dev.org.tw/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    await fireEvent.click(canvas.getByTestId('data-testid'));
  },
};

當 Storybook 載入 story 並執行函數時,它會與元件互動並觸發按鈕點擊,類似於使用者會做的事情。

除了點擊事件之外,您還可以使用 play 函數編寫其他事件的腳本。例如,如果您的元件包含一個具有各種選項的 select,您可以撰寫以下 story 並測試每個情境

MyComponent.stories.ts|tsx
// 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>;
 
// Function to emulate pausing between interactions
function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
 
/* 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 ExampleChangeEvent: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    const select = canvas.getByRole('listbox');
 
    await userEvent.selectOptions(select, ['One Item']);
    await sleep(2000);
 
    await userEvent.selectOptions(select, ['Another Item']);
    await sleep(2000);
 
    await userEvent.selectOptions(select, ['Yet another item']);
  },
};

除了事件之外,您還可以基於其他類型的非同步方法,使用 play 函數建立互動。例如,假設您正在開發一個實作了驗證邏輯的元件 (例如,電子郵件驗證、密碼強度)。在這種情況下,您可以在您的 play 函數中引入延遲,以模擬使用者互動並斷言提供的數值是否有效。

MyComponent.stories.ts|tsx
// 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 DelayedStory: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    const exampleElement = canvas.getByLabelText('example-element');
 
    // The delay option sets the amount of milliseconds between characters being typed
    await userEvent.type(exampleElement, 'random string', {
      delay: 100,
    });
 
    const AnotherExampleElement = canvas.getByLabelText('another-example-element');
    await userEvent.type(AnotherExampleElement, 'another random string', {
      delay: 100,
    });
  },
};

當 Storybook 載入 story 時,它會與元件互動,填寫其輸入並觸發任何定義的驗證邏輯。

您也可以使用 play 函數來驗證基於特定互動的元素是否存在。例如,如果您正在開發一個元件,並且想要檢查如果使用者輸入錯誤資訊會發生什麼事。在這種情況下,您可以撰寫以下 story

MyComponent.stories.ts|tsx
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { userEvent, waitFor, 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 ExampleAsyncStory: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    const Input = canvas.getByLabelText('Username', {
      selector: 'input',
    });
 
    await userEvent.type(Input, 'WrongInput', {
      delay: 100,
    });
 
    // See https://storybook.dev.org.tw/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    const Submit = canvas.getByRole('button');
    await userEvent.click(Submit);
 
    await waitFor(async () => {
      await userEvent.hover(canvas.getByTestId('error'));
    });
  },
};

查詢元素

如果需要,您也可以調整您的 play 函數,以根據查詢 (例如,role、文字內容) 尋找元素。例如

MyComponent.stories.ts|tsx
// 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 ExampleWithRole: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // 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', { name: / button label/i }));
  },
};

您可以在 Testing Library 文件中閱讀更多關於查詢元素的資訊。

當 Storybook 載入 story 時,play 函數開始執行並查詢 DOM 樹,期望在 story 渲染時元素可用。如果您的測試中出現失敗,您將能夠快速驗證其根本原因。

否則,如果元件不是立即可用,例如,由於在您的 play 函數中定義的前一個步驟或某些非同步行為,您可以調整您的 story 並等待 DOM 樹的變更發生,然後再查詢元素。例如

MyComponent.stories.ts|tsx
// 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 AsyncExample: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // Other steps
 
    // Waits for the component to be rendered before querying the element
    await canvas.findByRole('button', { name: / button label/i });
  },
};

使用 Canvas

預設情況下,您在 play 函數中撰寫的每個 interaction 都將從 Canvas 的頂層元素開始執行。這對於較小的元件 (例如,按鈕、核取方塊、文字輸入) 是可以接受的,但對於複雜的元件 (例如,表單、頁面) 或多個 stories 來說,可能會效率低下。為了適應這種情況,您可以調整您的 interactions,使其從元件的根目錄開始執行。例如

MyComponent.stories.ts|tsx
// 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>;
 
export const ExampleStory: Story = {
  play: async ({ canvasElement }) => {
    // Assigns canvas to the component root element
    const canvas = within(canvasElement);
 
    // Starts querying from the component's root element
    await userEvent.type(canvas.getByTestId('example-element'), 'something');
    await userEvent.click(canvas.getByRole('button'));
  },
};

將這些變更應用於您的 stories 可以提供效能提升,並改善使用 addon-interactions 的錯誤處理。