文档
Storybook 文档

组件测试

当您构建更复杂的 UI(如页面)时,组件的功能不仅仅是渲染 UI。它们还会获取数据和管理状态。组件测试允许您验证 UI 的这些功能方面。

简而言之,您首先为组件的初始状态提供适当的 props。然后模拟用户行为,例如点击和表单条目。最后,检查 UI 和组件状态是否正确更新。

在 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.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);
 
    // 👇 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.org.cn/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 加载到 UI 中,它将模拟用户的行为并验证底层逻辑。

在组件渲染之前运行代码

您可以使用 play 方法中的 mount 函数在渲染之前执行代码。

这是一个使用 mockdate 包来 mock 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 的使用。

在渲染之前创建 mock 数据

您还可以使用 mount 来创建要传递给组件的 mock 数据。为此,首先在 play 函数中创建您的数据,然后使用配置有该数据的组件调用 mount 函数。在此示例中,我们创建一个 mock 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 函数内部 mount 特定组件时,story 的渲染函数将被忽略。这就是为什么您必须将 args 转发到组件的原因。

在文件中每个 story 之前运行代码

有时您可能需要在文件中每个 story 之前运行相同的代码。例如,您可能需要设置组件或模块的初始状态。您可以通过向组件 meta 添加异步 beforeEach 函数来执行此操作。

您可以从 beforeEach 函数返回一个清理函数,该函数将在每个 story 之后,当 story 重新 mount 或导航离开时运行。

通常,您应该在 预览文件的 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 函数之前或测试运行器中的 teardown 过程中运行。

.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 使用的状态或模块。在下面的示例中,我们使用它来重置 mock 的 Date。

您可以从 beforeEach 函数返回一个清理函数,该函数将在每个 story 之后,当 story 重新 mount 或导航离开时运行。

.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() mocks,因为 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.org.cn/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.org.cn/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

Mock 模块

如果您的组件依赖于导入到组件文件中的模块,您可以 mock 这些模块来控制和断言它们的行为。这在 mock 模块 指南中详细介绍。

然后,您可以将 mock 模块(它具有 Vitest mock 函数 的所有有用方法)导入到您的 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 面板,您将看到逐步流程。它还提供了一组方便的 UI 控件,用于暂停、恢复、倒回和单步执行每个交互。

play 函数在 story 渲染后执行。如果出现错误,它将显示在 interaction 插件面板中,以帮助进行调试。

由于 Storybook 是一个 webapp,因此任何拥有 URL 的人都可以复现具有相同详细信息的错误,而无需任何额外的环境配置或工具。

Component testing with an error

通过自动发布 Storybook 在 pull requests 中,进一步简化组件测试。这为团队提供了一个通用的参考点来测试和调试 stories。

使用 test-runner 执行测试

Storybook 仅在您查看 story 时运行组件测试。因此,您必须遍历每个 story 才能运行所有检查。随着您的 Storybook 增长,手动审查每个更改变得不现实。Storybook test-runner 通过为您运行所有测试来自动化该过程。要执行 test-runner,请打开一个新的终端窗口并运行以下命令

npm run test-storybook

Component test with test runner

如果需要,您可以为 test-runner 提供其他标志。阅读文档以了解更多信息。

自动化

一旦您准备好将代码推送到 pull request,您将希望在合并之前使用持续集成 (CI) 服务自动运行所有检查。阅读我们的文档,以获取有关设置 CI 环境以运行测试的详细指南。

故障排除

组件测试和视觉测试之间有什么区别?

当组件测试大范围应用于每个组件时,维护成本可能很高。我们建议将它们与其他方法(如视觉测试)结合使用,以实现全面的覆盖,同时减少维护工作。

组件测试与单独使用 Jest + Testing Library 有什么区别?

组件测试将 Jest 和 Testing Library 集成到 Storybook 中。最大的好处是能够在真实的浏览器中查看您正在测试的组件。这可以帮助您进行可视化调试,而不是在命令行中获得(伪造的)DOM 转储,或者遇到 JSDOM mock 浏览器功能的限制。将 stories 和测试放在一个文件中也比将它们分散在多个文件中更方便。

了解其他 UI 测试