交互测试
在 Storybook 中,交互测试是作为 story 的一部分构建的。该 story 使用必要的 props 和上下文来渲染组件,使其处于初始状态。然后,您可以使用 play 函数来模拟用户行为,例如点击、输入和提交表单,然后对最终结果进行断言。
您可以使用 Storybook UI 中的 Interactions 面板预览和调试交互测试。可以使用 Vitest 插件自动化这些测试,从而可以在 Storybook、终端或 CI 环境中运行项目测试。
编写交互测试
您编写的每个 story 都可以进行渲染测试。渲染测试是交互测试的一个简单版本,仅测试组件在给定状态下成功渲染的能力。这对于相对简单、静态的组件(如 Button)来说效果很好。但对于更复杂、交互式的组件,您可以做得更多。
您可以使用 play
函数模拟用户行为,并对 DOM 结构或函数调用等功能方面进行断言。测试组件时,将运行 play 函数,并验证其中的所有断言。
在此示例中,EmptyForm story 测试 LoginForm 组件的渲染,而 FilledForm story 测试表单提交
// 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();
},
};
该代码示例中有很多内容,下面我们逐一介绍 API。
查询 canvas
canvas
是一个可查询的元素,包含正在测试的 story,作为 play 函数的参数提供。您可以使用 canvas
查找要与之交互或断言的特定元素。所有查询方法都直接来自 Testing Library,形式为 <type><subject>
。
可用类型汇总在下表中,并在 Testing Library 文档中有详细说明
查询类型 | 0 匹配 | 1 匹配 | >1 匹配 | 已等待 |
---|---|---|---|---|
单个元素 | ||||
getBy... | 抛出错误 | 返回元素 | 抛出错误 | 否 |
queryBy... | 返回 null | 返回元素 | 抛出错误 | 否 |
findBy... | 抛出错误 | 返回元素 | 抛出错误 | 是 |
多个元素 | ||||
getAllBy... | 抛出错误 | 返回数组 | 返回数组 | 否 |
queryAllBy... | 返回 [] | 返回数组 | 返回数组 | 否 |
findAllBy... | 抛出错误 | 返回数组 | 返回数组 | 是 |
主题列表如下,包含其完整的 Testing Library 文档链接
ByRole
— 按其可访问的角色查找元素ByLabelText
— 按其关联的标签文本查找元素ByPlaceholderText
— 按其占位符值查找元素ByText
— 按其包含的文本查找元素ByDisplayValue
— 按其当前值查找input
、textarea
或select
元素ByAltText
— 查找具有给定alt
属性值的元素ByTitle
— 查找具有给定title
属性值的元素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
// 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 在一致状态下渲染的有用方法。
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
函数有两个要求
- 您必须从
context
(传递给 play 函数的参数)中解构 mount 属性。这确保 Storybook 不会在 play 函数开始之前开始渲染 story。 - 您的 Storybook 框架或构建器必须配置为转译到 ES2017 或更新版本。这是因为否则解构语句和 async/await 用法会被转译掉,从而阻止 Storybook 识别您对
mount
的使用。
在渲染前创建模拟数据
您还可以使用 mount
创建要传递给组件的模拟数据。为此,首先在 play 函数中创建数据,然后使用配置了该数据的组件调用 mount
函数。在此示例中,我们创建一个模拟的 note
,并将其 id
传递给 Page 组件,我们使用该组件调用 mount
。
// 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 } },
},
};
在文件中每个 story 运行之前运行代码
有时您可能需要在文件中每个 story 运行之前运行相同的代码。例如,您可能需要设置组件或模块的初始状态。您可以通过将异步 beforeEach
函数添加到组件 meta 来实现此目的。
您可以从 beforeEach
函数返回一个 cleanup 函数,该函数将在每个 story 之后运行,即当 story 重新挂载或导航离开时。
通常,您应该在预览文件中的 beforeAll
或 beforeEach
函数中重置组件和模块状态,以确保它适用于您的整个项目。但是,如果组件的需求特别独特,您可以在组件 meta 的 beforeEach
中使用返回的 cleanup 函数根据需要重置状态。
// 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 之前重置该状态以保持测试之间的隔离非常重要。
重置状态有两种选项:beforeAll
和 beforeEach
。
beforeAll
预览文件 (.storybook/preview.js|ts
) 中的 beforeAll
函数将在项目中的任何 stories 运行之前运行一次,并且在 stories 之间不会重复运行。除了启动测试运行时的初始运行外,除非预览文件更新,否则它不会再次运行。这是引导项目或运行整个项目依赖的任何设置的好地方,如下例所示。
您可以从 beforeAll
函数返回一个 cleanup 函数,该函数将在重新运行 beforeAll
函数之前或在测试运行器中的 teardown 过程中运行。
// 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 重新挂载或导航离开时。
// 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 函数将相关的交互分组在一起可能是值得的。这允许您提供一个自定义标签来描述一组交互
// ...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'));
});
},
};
这会将您的交互显示在一个可折叠的组中
模拟模块
如果您的组件依赖于导入到组件文件中的模块,您可以模拟这些模块来控制其行为并进行断言。这在模拟模块指南中有详细说明。然后,您可以将模拟的模块(它具有 Vitest 模拟函数的所有有用方法)导入到您的 story 中,并使用它来断言组件的行为
// 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-runner,您可以在终端或 CI 环境中运行交互测试。
调试交互测试
如果您查看 Interactions 面板,您将看到 play 函数中为每个 story 定义的逐步流程。它还提供了一组方便的 UI 控件,用于暂停、恢复、回退和逐步执行每个交互。
任何测试失败也会在这里显示,从而轻松快速地精确定位失败点。在此示例中,缺少在按下登录按钮后设置 submitted
状态的逻辑。
用于重现的永久链接
由于 Storybook 是一个 Web 应用程序,任何拥有 URL 的人都可以用相同的详细信息重现失败,而无需任何额外的环境配置或工具。
通过在 pull request 中自动发布 Storybook,进一步简化交互测试。这为团队提供了一个通用的参考点来测试和调试 stories。
自动化
当您使用 Vitest 插件运行测试时,自动化这些测试就像在 CI 环境中运行测试一样简单。有关更多信息,请参阅在 CI 中进行测试的指南。
如果您无法使用 Vitest 插件,您仍然可以使用 test-runner 在 CI 中运行测试。
故障排除
交互测试和可视化测试有什么区别?
交互测试如果应用于每个组件,维护成本可能会很高。我们建议将其与其他方法(如可视化测试)结合使用,以实现全面覆盖并减少维护工作。
交互测试与单独使用 Vitest + Testing Library 有什么区别?
交互测试将 Vitest 和 Testing Library 集成到 Storybook 中。最大的好处是能够在真实浏览器中查看正在测试的组件。这有助于您进行可视化调试,而不是在命令行中获取(伪造的)DOM 转储或遇到 JSDOM 模拟浏览器功能的限制。将 stories 和测试放在一个文件中也比将它们分散在多个文件中更方便。
更多测试资源