Docs
Storybook Docs

组件测试

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

¥As you build more complex UIs like pages, components become responsible for more than just rendering the UI. They fetch data and manage state. Component tests allow you to verify these functional aspects of UIs.

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

¥In a nutshell, you start by supplying the appropriate props for the initial state of a component. Then simulate user behavior such as clicks and form entries. Finally, check whether the UI and component state update correctly.

在 Storybook 中,这种熟悉的工作流程发生在你的浏览器中。这使得调试故障变得更容易,因为你在开发组件的同一环境中运行测试:浏览器。

¥In Storybook, this familiar workflow happens in your browser. That makes it easier to debug failures because you're running tests in the same environment as you develop components: the browser.

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

¥How does component testing in Storybook work?

你首先编写 story 来设置组件的初始状态。然后使用播放函数模拟用户行为。最后,使用测试运行器确认组件正确渲染并且你的组件使用 play 函数测试通过。测试运行器可以通过命令行或在 CI 中运行。

¥You start by writing a story to set up the component's initial state. Then simulate user behavior using the play function. Finally, use the test-runner to confirm that the component renders correctly and that your component tests with the play function pass. The test runner can run via the command line or in CI.

  • play 函数是一小段代码,在故事完成渲染后运行。你可以使用它来测试用户工作流程。

    ¥The play function is a small snippet of code that runs after a story finishes rendering. You can use this to test user workflows.

  • 测试是使用来自 @storybook/test 包的 Storybook 检测版本的 Vitest测试库 编写的。

    ¥The test is written using Storybook-instrumented versions of Vitest and Testing Library coming from the @storybook/test package.

  • @storybook/addon-interactions 在 Storybook 中将测试可视化,并提供回放界面,方便基于浏览器的调试。

    ¥@storybook/addon-interactions visualizes the test in Storybook and provides a playback interface for convenient browser-based debugging.

  • @storybook/test-runner 是一个独立的实用程序 - 由 JestPlaywright 提供支持 - 可执行所有交互测试并捕获损坏的故事。

    ¥@storybook/test-runner is a standalone utility—powered by Jest and Playwright—that executes all of your interactions tests and catches broken stories.

    • 实验性的 Vitest 插件 也可用,它将你的故事转换为 Vitest 测试并在浏览器中运行它们。

      ¥The experimental Vitest plugin is also available, which transforms your stories into Vitest tests and runs them in a browser.

设置交互插件

¥Set up the interactions addon

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

¥To enable the full component testing experience with Storybook, you'll need to take additional steps to set it up properly. We recommend you go through the test runner documentation before proceeding with the rest of the required configuration.

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

¥Run the following command to install the interactions addon and related dependencies.

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

更新你的 Storybook 配置(在 .storybook/main.js|ts 中)以包含交互插件。

¥Update your Storybook configuration (in .storybook/main.js|ts) to include the interactions addon.

.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;

编写组件测试

¥Write a component test

测试本身是在与故事相关的 play 函数内定义的。以下是如何使用 Storybook 和 play 函数设置组件测试的示例:

¥The test itself is defined inside a play function connected to a story. Here's an example of how to set up a component test with Storybook and the play function:

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.js.org/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.js.org/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 中加载后,它会模拟用户的行为并验证底层逻辑。

¥Once the story loads in the UI, it simulates the user's behavior and verifies the underlying logic.

在组件渲染之前运行代码

¥Run code before the component gets rendered

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

¥You can execute code before rendering by using the mount function in the play method.

这是一个使用 mockdate 包模拟 Date 的示例,这是一种使你的故事以一致状态渲染的有用方法。

¥Here's an example of using the mockdate package to mock the Date, a useful way to make your story render in a consistent state.

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 函数有两个要求:

¥There are two requirements to use the mount function:

  1. 你必须从 context(传递给你的 play 函数的参数)中解构 mount 属性。这可确保 Storybook 不会在播放函数开始之前开始渲染故事。

    ¥You must destructure the mount property from the context (the argument passed to your play function). This makes sure that Storybook does not start rendering the story before the play function begins.

  2. 你的 Storybook 框架或构建器必须配置为转换为 ES2017 或更新版本。这是因为解构语句和 async/await 用法会被转译掉,从而阻止 Storybook 识别你对 mount 的使用。

    ¥Your Storybook framework or builder must be configured to transpile to ES2017 or newer. This is because destructuring statements and async/await usages are otherwise transpiled away, which prevents Storybook from recognizing your usage of mount.

在 rend 之前创建模拟数据

¥Create mock data before rendering

你还可以使用 mount 创建要传递给组件的模拟数据。为此,请首先在 play 函数中创建数据,然后使用配置了该数据的组件调用 mount 函数。在此示例中,我们创建一个模拟 note 并将其 id 传递给 Page 组件,我们用它调用 mount

¥You can also use mount to create mock data that you want to pass to the component. To do so, first create your data in the play function and then call the mount function with a component configured with that data. In this example, we create a mock note and pass its id to the Page component, which we call mount with.

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

¥When you call mount() with no arguments, the component is rendered using the story’s render function, whether the implicit default or the explicit custom definition.

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

¥When you mount a specific component inside the mount function like in the example above, the story’s render function will be ignored. This is why you must forward the args to the component.

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

¥Run code before each story in a file

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

¥Sometimes you might need to run the same code before each story in a file. For instance, you might need to set up the initial state of the component or modules. You can do this by adding an asynchronous beforeEach function to the component meta.

你可以从 beforeEach 函数返回一个清理函数,该函数将在每个故事之后、故事重新挂载或离开时运行。

¥You can return a cleanup function from the beforeEach function, which will run after each story, when the story is remounted or navigated away from.

通常,你应该重置 预览文件的 beforeAllbeforeEach 功能 中的组件和模块状态,以确保它适用于你的整个项目。但是,如果组件的需求特别独特,你可以使用组件元 beforeEach 中返回的清理函数根据需要重置状态。

¥Generally, you should reset component and module state in the preview file's beforeAll or beforeEach functions, to ensure it applies to your entire project. However, if a component's needs are particularly unique, you can use the returned cleanup function in the component meta beforeEach to reset the state as needed.

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

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

¥Set up or reset state for all tests

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

¥When you alter a component's state, it's important to reset that state before rendering another story to maintain isolation between tests.

重置状态有两种选项,beforeAllbeforeEach

¥There are two options for resetting state, beforeAll and beforeEach.

beforeAll

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

¥The beforeAll function in the preview file (.storybook/preview.js|ts) will run once before any stories in the project and will not re-run between stories. Beyond its initial run when kicking off a test run, it will not run again unless the preview file is updated. This is a good place to bootstrap your project or run any setup that your entire project depends on, as in the example below.

你可以从 beforeAll 函数返回一个清理函数,该函数将在重新运行 beforeAll 函数之前或在测试运行器中的拆卸过程中运行。

¥You can return a cleanup function from the beforeAll function, which will run before re-running the beforeAll function or during the teardown process in the test runner.

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

¥Unlike beforeAll, which runs only once, the beforeEach function in the preview file (.storybook/preview.js|ts) will run before each story in the project. This is best used for resetting state or modules that are used by all or most of your stories. In the example below, we use it to reset the mocked Date.

你可以从 beforeEach 函数返回一个清理函数,该函数将在每个故事之后、故事重新挂载或离开时运行。

¥You can return a cleanup function from the beforeEach function, which will run after each story, when the story is remounted or navigated away from.

.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

¥It is not necessary to restore fn() mocks, as Storybook will already do that automatically before rendering a story. See the parameters.test.restoreMocks API for more information.

用户事件的 API

¥API for user-events

在底层,Storybook 的 @storybook/test 包提供了测试库的 user-events API。如果你熟悉 测试库,那么你应该熟悉 Storybook。

¥Under the hood, Storybook’s @storybook/test package provides Testing Library’s user-events APIs. If you’re familiar with Testing Library, you should be at home in Storybook.

以下是用户事件的简略 API。有关更多信息,请查看 官方用户事件文档

¥Below is an abridged API for user-event. For more, check out the official user-event docs.

用户事件描述
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 进行断言测试

¥Assert tests with Vitest's APIs

Storybook 的 @storybook/test 还提供来自 Vitest 的 API,例如 expectvi.fn。这些 API 可以改善你的测试体验,帮助你断言是否已调用函数、DOM 中是否存在元素等等。如果你习惯于使用 JestVitest 等测试包中的 expect,则可以以相同的方式编写组件测试。

¥Storybook’s @storybook/test also provides APIs from Vitest, such as expect and vi.fn. These APIs improve your testing experience, helping you assert whether a function has been called, if an element exists in the DOM, and much more. If you are used to expect from testing packages such as Jest or Vitest, you can write component tests in much the same way.

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.js.org/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 函数的分组交互

¥Group interactions with the step function

对于复杂流程,使用 step 函数将相关交互集组合在一起是值得的。这允许你提供描述一组交互的自定义​​标签:

¥For complex flows, it can be worthwhile to group sets of related interactions together using the step function. This allows you to provide a custom label that describes a set of interactions:

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

这将显示嵌套在可折叠组中的交互:

¥This will show your interactions nested in a collapsible group:

Component testing with labeled steps

模拟模块

¥Mocked modules

如果你的组件依赖于导入到组件文件中的模块,你可以模拟这些模块以控制和断言它们的行为。这在 模拟模块 指南中有详细说明。

¥If your component depends on modules that are imported into the component file, you can mock those modules to control and assert on their behavior. This is detailed in the mocking modules guide.

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

¥You can then import the mocked module (which has all of the helpful methods of a Vitest mocked function) into your story and use it to assert on the behavior of your component:

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

交互式调试器

¥Interactive debugger

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

¥If you check your interactions panel, you'll see the step-by-step flow. It also offers a handy set of UI controls to pause, resume, rewind, and step through each interaction.

复制品的永久链接

¥Permalinks for reproductions

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

¥The play function is executed after the story is rendered. If there’s an error, it’ll be shown in the interaction addon panel to help with debugging.

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

¥Since Storybook is a webapp, anyone with the URL can reproduce the error with the same detailed information without any additional environment configuration or tooling required.

Component testing with an error

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

¥Streamline component testing further by automatically publishing Storybook in pull requests. That gives teams a universal reference point to test and debug stories.

使用测试运行器执行测试

¥Execute tests with the test-runner

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

¥Storybook only runs the component test when you're viewing a story. Therefore, you'd have to go through each story to run all your checks. As your Storybook grows, it becomes unrealistic to review each change manually. Storybook test-runner automates the process by running all tests for you. To execute the test-runner, open a new terminal window and run the following command:

npm run test-storybook

Component test with test runner

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

¥If you need, you can provide additional flags to the test-runner. Read the documentation to learn more.

自动化

¥Automate

一旦你准备好将代码推送到拉取请求中,你将需要使用持续集成 (CI) 服务自动运行所有检查,然后再进行合并。阅读我们的 documentation,获取有关设置 CI 环境以运行测试的详细指南。

¥Once you're ready to push your code into a pull request, you'll want to automatically run all your checks using a Continuous Integration (CI) service before merging it. Read our documentation for a detailed guide on setting up a CI environment to run tests.

故障排除

¥Troubleshooting

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

¥What’s the difference between component tests and visual tests?

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

¥Component tests can be expensive to maintain when applied wholesale to every component. We recommend combining them with other methods like visual testing for comprehensive coverage with less maintenance work.

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

¥What's the difference between component tests and using Jest + Testing Library alone?

组件测试将 Jest 和测试库集成到 Storybook 中。最大的好处是能够在真实浏览器中查看你正在测试的组件。这可以帮助你直观地进行调试,而不是在命令行中获取(假)DOM 的转储或达到 JSDOM 模拟浏览器功能的限制。将故事和测试放在一个文件中也比将它们分散在多个文件中更方便。

¥Component tests integrate Jest and Testing Library into Storybook. The biggest benefit is the ability to view the component you're testing in a real browser. That helps you debug visually, instead of getting a dump of the (fake) DOM in the command line or hitting the limitations of how JSDOM mocks browser functionality. It's also more convenient to keep stories and tests together in one file than having them spread across files.

了解其他 UI 测试

¥Learn about other UI tests