文档
Storybook 文档

Play 函数

观看视频教程

Play 函数是在故事渲染后执行的小代码段。它使您能够与组件交互并测试原本需要用户干预才能执行的场景。

设置交互插件

我们建议在开始使用 play 函数编写故事之前安装 Storybook 的 addon-interactions。它是 play 函数的完美补充,它提供了一套方便的 UI 控件,使您可以控制执行流程。您可以随时暂停、恢复、倒回和逐步执行每个交互。它还提供了一个易于使用的调试器,可以帮助您解决潜在的问题。

运行以下命令以安装插件和必要的依赖项。

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

更新您的 Storybook 配置(位于 .storybook/main.js|ts)以包含交互插件。

.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 函数编写故事

Storybook 的 play 函数是在故事渲染完成后运行的小代码段。在 addon-interactions 的帮助下,它允许您构建组件交互并测试原本无法在没有用户干预的情况下完成的场景。例如,如果您正在开发一个注册表单并想要验证它,您可以使用 play 函数编写以下故事

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.org.cn/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, '[email protected]', {
      delay: 100,
    });
 
    const passwordInput = canvas.getByLabelText('password', {
      selector: 'input',
    });
 
    await userEvent.type(passwordInput, 'ExamplePassword', {
      delay: 100,
    });
    // See https://storybook.org.cn/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 渲染完故事后,它会执行 play 函数中定义的步骤,与组件交互并填写表单信息。所有这些操作都不需要用户干预。如果您查看 Interactions 面板,您将看到逐步执行的流程。

组合故事

得益于 组件故事格式(一种基于 ES6 模块的文件格式),您也可以将您的 play 函数组合在一起,就像其他现有 Storybook 功能(例如 参数)一样。例如,如果您想要验证组件的特定工作流程,您可以编写以下故事

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.org.cn/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');
  },
};

通过将故事组合在一起,您可以重新创建整个组件工作流程,并在减少需要编写的样板代码的同时发现潜在的问题。

使用事件

大多数现代 UI 都专注于交互(例如,单击按钮、选择选项、勾选复选框),为最终用户提供丰富的体验。使用 play 函数,您可以将相同级别的交互集成到您的故事中。

常见的组件交互类型是按钮点击。如果您需要在故事中重现它,您可以将故事的 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.org.cn/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.org.cn/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.org.cn/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    await fireEvent.click(canvas.getByTestId('data-testid'));
  },
};

当 Storybook 加载故事并执行函数时,它会与组件交互并触发按钮点击,就像用户会做的一样。

除了点击事件,你还可以使用 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>;
 
// Function to emulate pausing between interactions
function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
 
/* See https://storybook.org.cn/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 函数中引入延迟来模拟用户交互,并断言提供的 value 是否有效

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.org.cn/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 加载故事时,它会与组件进行交互,填写其输入并触发定义的任何验证逻辑。

你还可以使用 play 函数来根据特定交互验证元素是否存在。例如,如果你正在处理一个组件,并且想要检查用户输入错误信息会发生什么。在这种情况下,你可以编写以下故事

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.org.cn/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.org.cn/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 函数来根据查询(例如,角色、文本内容)查找元素。例如

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.org.cn/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.org.cn/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 加载故事时,play 函数开始执行并查询 DOM 树,期望元素在故事渲染时可用。如果你的测试中出现故障,你将能够快速验证其根本原因。

否则,如果组件不可立即使用,例如,由于 play 函数中定义的先前步骤或某些异步行为,你可以调整你的故事并等待 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.org.cn/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 });
  },
};

使用画布

默认情况下,你在 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>;
 
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('another-element'));
  },
};

将这些更改应用到你的故事可以提供性能提升以及使用 addon-interactions 的错误处理改进。