文档
Storybook 文档

组件测试

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

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

在 Storybook 中,这个熟悉的流程在你的浏览器中发生。这使得调试错误更容易,因为你在与开发组件相同的环境(浏览器)中运行测试。

Storybook 中的组件测试是如何工作的?

你首先编写一个 故事 来设置组件的初始状态。然后使用 play 函数模拟用户行为。最后,使用 测试运行器 确认组件是否渲染正确,以及你的组件测试是否通过 play 函数。测试运行器可以通过命令行或 CI 运行。

  • play 函数是一小段代码,在故事渲染完成后运行。你可以用它来测试用户工作流程。
  • 测试使用来自 @storybook/test 包的 Storybook 改造版的 VitestTesting Library 编写。
  • @storybook/addon-interactions 可视化 Storybook 中的测试,并提供一个播放界面,方便进行基于浏览器的调试。
  • @storybook/test-runner 是一个独立的工具,它由 JestPlaywright 提供支持,可以执行所有交互测试并捕获失效的故事。
    • 实验性的 Vitest 插件 也可用,它会将你的故事转换为 Vitest 测试并在浏览器中运行它们。

设置交互插件

要使用 Storybook 启用完整的组件测试体验,你需要采取额外的步骤来正确设置它。我们建议你在继续进行其他所需配置之前,先阅读 测试运行器文档

运行以下命令安装交互插件和相关依赖项。

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 函数设置组件测试的示例

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

故事加载到 UI 后,它会模拟用户的行为并验证底层逻辑。

在组件渲染之前运行代码

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

以下是如何使用 mockdate 包来模拟 Date 对象,这是一种使你的故事以一致状态呈现的有用方法。

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 函数开始之前不会开始渲染故事。
  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() 时,组件将使用故事的渲染函数进行渲染,无论使用 隐式默认 还是 显式自定义定义

当你在 mount 函数中像上面示例那样安装特定组件时,故事的渲染函数将被忽略。这就是为什么你必须将 args 转发给组件的原因。

在文件中的每个故事之前运行代码

有时你可能需要在文件中的每个故事之前运行相同的代码。例如,你可能需要设置组件或模块的初始状态。你可以通过在组件元数据中添加一个异步的 beforeEach 函数来实现。

你可以从 beforeEach 函数返回一个清理函数,该函数将在每个故事之后运行,当故事重新安装或从该故事导航时。

通常,你应该在 预览文件中的 beforeAllbeforeEach 函数 中重置组件和模块状态,以确保它适用于你的整个项目。但是,如果组件的需求非常独特,你可以使用组件元数据 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
  },
};

为所有测试设置或重置状态

当你 更改组件的状态 时,在渲染另一个故事之前重置该状态非常重要,以保持测试之间的隔离。

有两种重置状态的方法,beforeAllbeforeEach

beforeAll

预览文件(.storybook/preview.js|ts)中的 beforeAll 函数将在项目中的任何故事之前运行一次,并且不会在故事之间重新运行。除了在启动测试运行时进行初始运行之外,它不会再次运行,除非预览文件被更新。这是一个引导项目或运行你的整个项目依赖的任何设置的合适地方,如以下示例所示。

你可以从 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 函数将在项目中的每个故事之前运行。这最适合用于重置所有或大多数故事使用的状态或模块。在下面的示例中,我们使用它来重置模拟的 Date。

你可以从 beforeEach 函数返回一个清理函数,该函数将在每个故事之后运行,当故事重新安装或从该故事导航时。

.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 已经在渲染故事之前自动执行了此操作。有关更多信息,请参阅 parameters.test.restoreMocks API

用户事件 API

在幕后,Storybook 的 @storybook/test 包提供了 Testing Library 的 user-events API。如果你熟悉 Testing Library,那么你应该在 Storybook 中得心应手。

以下是以简化的形式呈现用户事件 API。有关更多内容,请查看 官方用户事件文档

用户事件描述
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取消选中选择元素的特定选项。
userEvent.deselectOptions(await within(canvasElement).getByRole('listbox'),'1');
hover将鼠标悬停在元素上。
userEvent.hover(await within(canvasElement).getByTestId('example-test'));
keyboard模拟键盘事件。
userEvent.keyboard(‘foo’);
selectOptions选中选择元素的指定选项或选项。
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'), '[email protected]');
      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'), '[email protected]');
      await userEvent.type(canvas.getByTestId('password'), 'supersecret');
    });
 
    await step('Submit form', async () => {
      await userEvent.click(canvas.getByRole('button'));
    });
  },
};

这将以可折叠组的形式显示你的交互。

Component testing with labeled steps

模拟模块

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

然后,您可以将模拟模块(具有 Vitest 模拟函数 的所有有用方法)导入到您的故事中,并使用它来断言您的组件的行为

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

交互式调试器

如果您检查您的交互面板,您将看到逐步流程。 它还提供了一组方便的 UI 控件来暂停、恢复、倒带和逐步执行每次交互。

在渲染故事后执行 play 函数。 如果出现错误,它将显示在交互添加面板中以帮助调试。

由于 Storybook 是一个 Web 应用程序,任何拥有 URL 的人都可以使用相同的详细信息来重现错误,而无需任何额外的环境配置或工具。

Component testing with an error

通过在拉取请求中自动 发布 Storybook 来进一步简化组件测试。 这为团队提供了一个通用参考点来测试和调试故事。

使用测试运行器执行测试

Storybook 仅在您查看故事时运行组件测试。 因此,您必须浏览每个故事才能运行所有检查。 随着您的 Storybook 的增长,手动检查每次更改变得不切实际。 Storybook 测试运行器 通过自动为您运行所有测试来实现自动化。 要执行测试运行器,请打开一个新的终端窗口并运行以下命令

npm run test-storybook

Component test with test runner

如果需要,您可以向测试运行器提供其他标志。 阅读文档以了解更多信息。

自动化

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

故障排除

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

当对每个组件进行整体应用时,组件测试的维护成本可能很高。 我们建议将它们与其他方法(如视觉测试)结合使用,以更少的维护工作获得全面的覆盖范围。

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

组件测试将 Jest 和 Testing Library 集成到 Storybook 中。 最大的好处是能够在真实浏览器中查看您正在测试的组件。 这有助于您进行视觉调试,而不是在命令行中获得(虚假)DOM 的转储或遇到 JSDOM 模拟浏览器功能的方式的限制。 将故事和测试保存在同一个文件中比将它们分散到不同的文件中更方便。

了解其他 UI 测试