加入直播会议:周四美国东部时间上午 11 点,Storybook 9 版本发布与问答
文档
Storybook Docs

交互测试

在 Storybook 中,交互测试是作为 story 的一部分构建的。该 story 使用必要的 props 和上下文来渲染组件,使其处于初始状态。然后,您可以使用 play 函数来模拟用户行为,例如点击、输入和提交表单,然后对最终结果进行断言。

您可以使用 Storybook UI 中的 Interactions 面板预览和调试交互测试。可以使用 Vitest 插件自动化这些测试,从而可以在 Storybook、终端或 CI 环境中运行项目测试。

编写交互测试

您编写的每个 story 都可以进行渲染测试。渲染测试是交互测试的一个简单版本,仅测试组件在给定状态下成功渲染的能力。这对于相对简单、静态的组件(如 Button)来说效果很好。但对于更复杂、交互式的组件,您可以做得更多。

您可以使用 play 函数模拟用户行为,并对 DOM 结构或函数调用等功能方面进行断言。测试组件时,将运行 play 函数,并验证其中的所有断言。

在此示例中,EmptyForm story 测试 LoginForm 组件的渲染,而 FilledForm story 测试表单提交

LoginForm.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } from 'storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta = {
  component: LoginForm,
} satisfies Meta<typeof LoginForm>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const EmptyForm: Story = {};
 
export const FilledForm: Story = {
  play: async ({ canvas, userEvent }) => {
    // 👇 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();
  },
};

Storybook with a LoginForm component and passing interactions in the Interactions panel

该代码示例中有很多内容,下面我们逐一介绍 API。

查询 canvas

canvas 是一个可查询的元素,包含正在测试的 story,作为 play 函数的参数提供。您可以使用 canvas 查找要与之交互或断言的特定元素。所有查询方法都直接来自 Testing Library,形式为 <type><subject>

可用类型汇总在下表中,并在 Testing Library 文档中有详细说明

查询类型0 匹配1 匹配>1 匹配已等待
单个元素
getBy...抛出错误返回元素抛出错误
queryBy...返回 null返回元素抛出错误
findBy...抛出错误返回元素抛出错误
多个元素
getAllBy...抛出错误返回数组返回数组
queryAllBy...返回 []返回数组返回数组
findAllBy...抛出错误返回数组返回数组

主题列表如下,包含其完整的 Testing Library 文档链接

  1. ByRole — 按其可访问的角色查找元素
  2. ByLabelText — 按其关联的标签文本查找元素
  3. ByPlaceholderText — 按其占位符值查找元素
  4. ByText — 按其包含的文本查找元素
  5. ByDisplayValue — 按其当前值查找 inputtextareaselect 元素
  6. ByAltText — 查找具有给定 alt 属性值的元素
  7. ByTitle — 查找具有给定 title 属性值的元素
  8. ByTestId — 查找具有给定 data-testid 属性值的元素

请注意此列表的顺序!我们(以及 Testing Libary)强烈建议您以类似于真实用户与您的 UI 交互的方式查询元素。例如,通过可访问的角色查找元素有助于确保大多数人可以使用您的组件。而使用 data-testid 应该是最后的手段,只有在尝试了所有其他方法之后才使用。

总而言之,一些典型的查询可能如下所示

// Find the first element with a role of button with the accessible name "Submit"
await canvas.findByRole('button', { name: 'Submit' });
 
// Get the first element with the text "An example heading"
canvas.getByText('An example heading');
 
// Get all elements with the role of listitem
canvas.getAllByRole('listitem');

使用 userEvent 模拟行为

查询元素后,您可能需要与它们交互以测试组件的行为。为此,我们使用 userEvent 实用程序,它作为 play 函数的参数提供。此实用程序模拟用户与组件的交互,例如点击按钮、在输入框中输入和选择选项。

userEvent 上有许多可用方法,user-event 文档中对此有详细说明。下表将重点介绍一些常用方法。

方法描述
click点击元素,调用 click() 函数
await userEvent.click(<element>)
dblClick双击元素
await userEvent.dblClick(<element>)
hover悬停在元素上
await userEvent.hover(<element>)
unhover离开元素取消悬停
await userEvent.unhover(<element>)
tab按下 tab 键
await userEvent.tab()
type在输入框或文本域中写入文本
await userEvent.type(<element>, 'Some text');
keyboard模拟键盘事件
await userEvent.keyboard('{Shift}');
selectOptions选择 select 元素的指定选项
await userEvent.selectOptions(<element>, ['1','2']);
deselectOptions取消选择 select 元素的特定选项
await userEvent.deselectOptions(<element>, '1');
clear选择输入框或文本域中的文本并删除
await userEvent.clear(<element>);

userEvent 方法在 play 函数内应始终 await。这确保它们可以在 Interactions 面板中正确记录和调试。

使用 expect 进行断言

最后,在查询元素并模拟行为后,您可以对结果进行断言,这些断言在运行测试时得到验证。为此,我们使用 expect 实用程序,它可以通过 storybook/test 模块获取

import { expect } from 'storybook/test';

这里的 expect 实用程序结合了 Vitest 的 expect 以及来自 @testing-library/jest-dom 的方法(尽管名字如此,但在 Vitest 测试中也有效)。有许多许多方法可用。下表将重点介绍一些常用方法。

方法描述
toBeInTheDocument()检查元素是否在 DOM 中
await expect(<element>).toBeInTheDocument()
toBeVisible()检查元素对用户是否可见
await expect(<element>).toBeVisible()
toBeDisabled()检查元素是否被禁用
await expect(<element>).toBeDisabled()
toHaveBeenCalled()检查被监控的函数是否被调用
await expect(<function-spy>).toHaveBeenCalled()
toHaveBeenCalledWith()检查被监控的函数是否使用了特定参数被调用
await expect(<function-spy>).toHaveBeenCalledWith('example')

expect 调用在 play 函数内应始终 await。这确保它们可以在 Interactions 面板中正确记录和调试。

使用 fn 监控函数

当您的组件调用一个函数时,您可以使用 Vitest 的 fn 实用程序(通过 storybook/test 模块获取)监控该函数,并对其行为进行断言

import { fn } from 'storybook/test'

大多数情况下,您在编写 story 时将 fn 用作 arg 的值,然后在测试中访问该 arg

LoginForm.stories.ts
// Replace your-framework with the name of your framework (e.g. react-vite, vue3-vite, etc.)
import type { Meta, StoryObj } from '@storybook/your-framework';
import { fn, expect } from 'storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta = {
  component: LoginForm,
  args: {
    // 👇 Use `fn` to spy on the onSubmit arg
    onSubmit: fn(),
  },
} satisfies Meta<typeof LoginForm>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const FilledForm: Story = {
  play: async ({ args, canvas, userEvent }) => {
    await userEvent.type(canvas.getByLabelText('Email'), 'email@provider.com');
    await userEvent.type(canvas.getByLabelText('Password'), 'a-random-password');
    await userEvent.click(canvas.getByRole('button', { name: 'Log in' }));
 
    // 👇 Now we can assert that the onSubmit arg was called
    await expect(args.onSubmit).toHaveBeenCalled();
  },
};

在组件渲染之前运行代码

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

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

在渲染前创建模拟数据

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

Page.stories.tsx
// Replace your-framework with the framework you are using, e.g., react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
// 👇 Must include the `.mock` portion of filename to have mocks typed correctly
import db from '#lib/db.mock';
import { Page } from './Page';
 
const meta = { component: Page } satisfies Meta<typeof Page>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const Default: Story = {
  play: async ({ mount, args, userEvent }) => {
    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 函数内挂载特定组件时(如上例所示),story 的渲染函数将被忽略。这就是为什么您必须将 args 转发给组件的原因。

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

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

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

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

Page.stories.ts
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
 
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 = {
  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();
    };
  },
} satisfies Meta<typeof Page>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const Default: Story = {
  async play({ canvas }) {
    // ... This will run with the mocked Date
  },
};

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

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

重置状态有两种选项:beforeAllbeforeEach

beforeAll

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

您可以从 beforeAll 函数返回一个 cleanup 函数,该函数将在重新运行 beforeAll 函数之前或在测试运行器中的 teardown 过程中运行。

.storybook/preview.ts
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import { Preview } from '@storybook/your-framework';
 
import { init } from '../project-bootstrap';
 
const preview: Preview = {
  async beforeAll() {
    await init();
  },
};
 
export default preview;

beforeEach

与只运行一次的 beforeAll 不同,预览文件 (.storybook/preview.js|ts) 中的 beforeEach 函数将在项目中的每个 story 运行之前运行。这最适合用于重置所有或大多数 stories 使用的状态或模块。在下面的示例中,我们使用它来重置模拟的 Date。

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

.storybook/preview.ts
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
import { Preview } from '@storybook/your-framework';
import MockDate from 'mockdate';
 
const preview: Preview = {
  async beforeEach() {
    MockDate.reset();
  },
};
 
export default preview;

无需恢复 fn() 模拟,因为 Storybook 会在渲染 story 之前自动完成此操作。有关更多信息,请参阅 parameters.test.restoreMocks API

使用 step 函数对交互进行分组

对于复杂的流程,使用 step 函数将相关的交互分组在一起可能是值得的。这允许您提供一个自定义标签来描述一组交互

MyComponent.stories.ts
// ...rest of story file
 
export const Submitted: Story = {
  play: async ({ args, canvas, step }) => {
    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'));
    });
  },
};

这会将您的交互显示在一个可折叠的组中

Interaction testing with labeled steps

模拟模块

如果您的组件依赖于导入到组件文件中的模块,您可以模拟这些模块来控制其行为并进行断言。这在模拟模块指南中有详细说明。然后,您可以将模拟的模块(它具有 Vitest 模拟函数的所有有用方法)导入到您的 story 中,并使用它来断言组件的行为

NoteUI.stories.ts
// Replace your-framework with svelte-vite or sveltekit
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { expect } 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.svelte';
 
const meta = {
  title: 'Mocked/NoteUI',
  component: NoteUI,
} satisfies Meta<typeof NoteUI>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
const notes = createNotes();
 
export const SaveFlow: Story = {
  name: 'Save Flow ▶',
  args: {
    isEditing: true,
    note: notes[0],
  },
  play: async ({ canvas, userEvent }) => {
    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();
  },
};

运行交互测试

如果您正在使用 Vitest 插件,您可以通过以下方式运行交互测试

在 Storybook UI 中,您可以通过点击侧边栏展开的测试小部件中的运行组件测试按钮,或通过打开 story 或文件夹上的上下文菜单(三个点)并选择运行组件测试来运行交互测试。

Test widget, expanded, hovering run component tests button

如果您正在使用 test-runner,您可以在终端或 CI 环境中运行交互测试。

调试交互测试

如果您查看 Interactions 面板,您将看到 play 函数中为每个 story 定义的逐步流程。它还提供了一组方便的 UI 控件,用于暂停、恢复、回退和逐步执行每个交互。

任何测试失败也会在这里显示,从而轻松快速地精确定位失败点。在此示例中,缺少在按下登录按钮后设置 submitted 状态的逻辑。

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

Interaction testing with a failure

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

自动化

当您使用 Vitest 插件运行测试时,自动化这些测试就像在 CI 环境中运行测试一样简单。有关更多信息,请参阅在 CI 中进行测试的指南

如果您无法使用 Vitest 插件,您仍然可以使用 test-runner 在 CI 中运行测试。

故障排除

交互测试和可视化测试有什么区别?

交互测试如果应用于每个组件,维护成本可能会很高。我们建议将其与其他方法(如可视化测试)结合使用,以实现全面覆盖并减少维护工作。

交互测试与单独使用 Vitest + Testing Library 有什么区别?

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

更多测试资源