Storybook 交互测试入门:用 play 函数模拟点击并断言组件行为
2026/9/8 19:48:14 网站建设 项目流程

Storybook 交互测试入门:用 play 函数模拟点击并断言组件行为

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

本篇技术指南围绕 Storybook 中最简单、最典型的交互测试示例展开:为一个“点击按钮打开对话框”的组件编写play函数,模拟用户点击、再断言role="dialog"元素出现在文档中。该示例源自本仓库文档代码片段 docs/_snippets/interaction-test-simple.md,被完整引用在 docs/writing-tests/index.mdx 与 docs/writing-tests/interaction-testing.mdx 中,是理解 Storybook 组件测试体系的最小闭环。读完本文,你将掌握play函数的三大核心 API(canvas查询、userEvent模拟、expect断言),并能在 Angular、React、Vue、Svelte、Web Components 等不同框架下、以 CSF 3、CSF Next、Svelte CSF 等多种文件形态写出等价的交互测试。

交互测试的定位:把 Story 从“渲染用例”升级为“行为用例”

在 Storybook 的测试体系中,每个 story 天然就是一个渲染测试(render test):只要组件能在给定参数和上下文下成功渲染,该 story 即通过;一旦渲染抛错即失败。但这种测试只能验证组件“静态地”呈现,无法验证其交互逻辑。

交互测试(interaction test)正是在此基础上的延伸。它以 story 为骨架,将组件放入特定初始状态,再通过 story 中定义的play函数模拟用户行为——点击、输入、提交表单等——最后对 DOM 结果或函数调用做出断言(详见 docs/writing-tests/interaction-testing.mdx 对“Writing interaction tests”的说明)。

从 docs/writing-tests/index.mdx 对各类测试类型的定位看,交互测试处于承上启下的位置:它比渲染测试更深入,又是快照、视觉、无障碍等其他测试类型的基础范式。代码片段interaction-test-simple.md展示的正是其中最精简的一课——一个名为Opens的 story,点击 “Open Modal” 按钮后断言弹窗出现。

最小示例:Opens 故事的三个组成部分

以 React 风格的 CSF 3 写法为例,交互测试的核心逻辑如下(取自 docs/_snippets/interaction-test-simple.md):

import type { Meta, StoryObj } from '@storybook/your-framework'; import { expect } from 'storybook/test'; import { Dialog } from './Dialog'; const meta = { component: Dialog, } satisfies Meta<typeof Dialog>; export default meta; type Story = StoryObj<typeof meta>; export const Opens: Story = { play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, };

这段代码虽短,却完整包含了交互测试的三个核心动作,值得逐个拆解。

第一步:用canvas查询目标元素

canvasplay函数 context 中暴露的一个可查询对象,代表正在测试的 story 渲染出的界面。你可以把它当作当前 story 的“作用域 DOM”,在其上调用 Testing Library 风格的查询方法来定位要交互或断言的元素。

查询方法遵循<类型><主题>命名约定。类型决定匹配个数与等待行为(详见 docs/writing-tests/interaction-testing.mdx 的查询表格):

类型0 个匹配1 个匹配>1 个匹配是否等待
getBy...抛错返回元素抛错
queryBy...返回null返回元素抛错
findBy...抛错返回元素抛错
getAllBy...抛错返回数组返回数组
queryAllBy...返回[]返回数组返回数组
findAllBy...抛错返回数组返回数组

主题部分常用的有ByRole(按可访问角色查找,如buttondialog)、ByLabelTextByPlaceholderTextByTextByDisplayValueByAltTextByTitleByTestId。示例中的两处查询都使用了ByRole——这符合 Testing Library 的推荐优先级:优先像真实用户那样通过可访问角色与可访问名称定位元素,data-testid应留作最后手段。

需要留意 CSF 3 与 CSF Next 在查询选项上的细微差异:CSF 3 的 Angular 及通用版本用{ name: 'Open Modal' }匹配按钮的 accessible name,而 CSF Next 示例中 Angular 版本则用{ text: 'Open Modal' }。此外,若组件依赖 Shadow DOM,需借助shadow-dom-testing-library在 .storybook/preview 中注册后才能使用。

第二步:用userEvent模拟用户行为

定位到按钮后,通过userEvent.click(button)触发点击。userEvent直接取自 Testing Library,在play函数内以参数形式提供,它的语义是“模拟真实用户的操作序列”,而非仅派发一个孤立的 DOM 事件。常用方法包括clickdblClickhoverunhovertabtypekeyboardselectOptionsdeselectOptionsclear等,完整方法清单可参考user-event文档,本仓库的交互测试指南也整理了速查表(见 docs/writing-tests/interaction-testing.mdx)。

第三步:用expect对结果断言

点击之后,测试要验证期望行为是否发生:

await expect(canvas.getByRole('dialog')).toBeInTheDocument();

expectstorybook/test模块导入,它合并了两类能力:Vitest 自带断言(如toHaveBeenCalledtoHaveBeenCalledWith)与@testing-library/jest-dom的 DOM 断言(如toBeInTheDocumenttoBeVisibletoHaveAttribute)。示例断言的核心含义是:dialog 元素已经进入 DOM,即“点击按钮后弹窗打开”这一用户可见结果成立。

注意:userEventexpect调用在play内都应await。这一要求不仅是语法惯例——只有被 await 的调用才会被逐个记录到 Interactions 面板中,供你在 UI 里逐步回放与调试。

同一测试,多种文件形态

interaction-test-simple.md的价值在于:它把上面这段逻辑翻译成了 Storybook 支持的每一种主流书写形态,覆盖 Angular、通用(common)、Svelte、Web Components、React、Vue 等渲染器,以及 CSF 3、CSF Next、Svelte CSF 三种文件约定。实际项目中应根据自己的框架与 CSF 版本来对号入座。

Angular:声明式组件与类组件

Angular 的 CSF 3 写法将组件元信息置于metaMeta<Dialog>StoryObj<Dialog>提供了强类型约束:

import type { Meta, StoryObj } from '@storybook/angular'; import { expect } from 'storybook/test'; import { Dialog } from './dialog.component'; const meta: Meta<Dialog> = { component: Dialog, }; export default meta; type Story = StoryObj<Dialog>; export const Opens: Story = { play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, };

CSF Next 的 Angular 形态则显式导入项目级.storybook/preview,通过preview.meta(...)创建 meta、用meta.story(...)声明带类型的 story(注意它查询按钮时使用的是text选项):

import { fn, expect } from 'storybook/test'; import preview from '../.storybook/preview'; import { Dialog } from './dialog.component'; const meta = preview.meta({ component: Dialog, }); export const Opens = meta.story({ play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { text: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, });

通用框架(common)CSF 3 写法

对于未单独列出的框架,文档给出了“替换your-framework占位符”的通用模板,分别提供 TS 与 JS 两种形态:

// 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 { expect } from 'storybook/test'; import { Dialog } from './Dialog'; const meta = { component: Dialog, } satisfies Meta<typeof Dialog>; export default meta; type Story = StoryObj<typeof meta>; export const Opens: Story = { play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, };
import { expect } from 'storybook/test'; import { Dialog } from './Dialog'; export default { component: Dialog, }; export const Opens = { play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, };

对比可见:TS 形态借助satisfies Meta<typeof Dialog>StoryObj<typeof meta>获得组件 props、args 的自动推导;JS 形态则更自由、也更贴近无类型项目的速写风格。

Svelte:.stories.svelte单文件约定

Svelte 用户有两种选择。其一是在.stories.ts中沿用标准 CSF 3(等价于通用模板,导入Dialog.svelte)。其二是 Svelte 特有的Svelte CSF:把 meta 定义放在<script module>中,由@storybook/addon-svelte-csfdefineMeta返回的Story组件来声明每个故事,play以内联属性形式传入:

<script module> import { defineMeta } from '@storybook/addon-svelte-csf'; import Dialog from './Dialog.svelte'; const { Story } = defineMeta({ component: Dialog, }); </script> <Story name="Opens" play={async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }} />

与之等价、保持标准 CSF 3 的.stories.ts/.stories.js写法同样可用:

// Replace your-framework with the framework you are using, e.g. sveltekit or svelte-vite import type { Meta, StoryObj } from '@storybook/your-framework'; import { expect } from 'storybook/test'; import Dialog from './Dialog.svelte'; const meta = { component: Dialog, } satisfies Meta<typeof Dialog>; export default meta; type Story = StoryObj<typeof meta>; export const Opens: Story = { play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, };

Web Components:以自定义元素名充当 component

Web Components 没有类组件可引用,因此 meta 的component字段直接使用自定义元素标签字符串(如'demo-dialog'):

import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { expect } from 'storybook/test'; const meta: Meta = { component: 'demo-dialog', }; export default meta; type Story = StoryObj; export const Opens: Story = { play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, };

JS 形态省略了Meta/StoryObj类型标注;CSF Next 形态则同样经由preview.meta/meta.story收敛:

import { fn, expect } from 'storybook/test'; import preview from '../.storybook/preview'; const meta = preview.meta({ component: 'demo-dialog', }); export const Opens = meta.story({ play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, });

CSF Next:React 与 Vue 形态

CSF Next(实验性写法)在 React 与 Vue 中的骨架高度一致,差异仅在组件导入路径(.tsxvs.vue)与文件扩展名。以 React TS 与 Vue JS 为例:

import { fn, expect } from 'storybook/test'; import preview from '../.storybook/preview'; import { Dialog } from './Dialog'; const meta = preview.meta({ component: Dialog, }); export const Opens = meta.story({ play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, });
import { fn, expect } from 'storybook/test'; import preview from '../.storybook/preview'; import Dialog from './Dialog.vue'; const meta = preview.meta({ component: Dialog, }); export const Opens = meta.story({ play: async ({ canvas, userEvent }) => { // Click on a button and assert that a dialog appears const button = canvas.getByRole('button', { name: 'Open Modal' }); await userEvent.click(button); await expect(canvas.getByRole('dialog')).toBeInTheDocument(); }, });

CSF Next 有两处值得注意:其一,从../.storybook/preview导入preview,意味着该示例文件假定位于与.storybook同级的组件目录下;其二,即使在这个简单示例中它也导入了fn(尽管此处未使用),这是因为 CSF Next 场景通常紧接着要借助fn对回调参数做 spy,从而断言“onClick 被调用且携带了正确参数”这类更深层行为。

play 在 Storybook 运行时中是如何被执行的

理解 play 的执行时机有助于定位“为什么断言不生效”或“点击还没发生就报错”的问题。在 Storybook 的预览运行时(preview-web)中,story 的渲染与测试生命周期由 StoryRender 驱动。

从 StoryRender.ts 的实现可以看到,渲染流程会在满足“自动播放开启(autoplay)且需要重新挂载”的条件下进入play阶段:它通过this.runPhase(abortSignal, 'playing', async () => playFunction(context))把 story 切换到playing阶段,再以包含canvasuserEventargsmount等对象的完整 context 调用playFunction。也就是说:

  • 组件先以默认 args 渲染进真实浏览器环境,随后才执行play中的行为模拟;
  • 交互测试必须开启自动播放(autoplay),这是测试与 storybook 预览的默认形态之一;
  • play内部抛出的断言错误会被阶段机制捕获,最终反映为该 story 的测试失败状态,并在 Interactions 面板中精确定位到出错的步骤。

同一个执行链路也被组件测试工具与“可移植 stories”(portable stories)复用,因而用同一份 story 编写的交互测试既可以在 Storybook 网页界面运行,也可以交给 Vitest/Jest 之类的独立测试运行器执行。

运行与调试交互测试

interaction-test-simple.md只负责“怎么写”,而运行与调试路径则落在 Storybook 的测试运行体系上(详见 docs/writing-tests/index.mdx 与 docs/writing-tests/interaction-testing.mdx)。

  • 在 Storybook UI 中调试:Interactions 面板会逐条回放 play 函数中的每个步骤(由于步骤被await,它们才会被完整记录)。面板提供暂停、恢复、回退与单步执行控件,失败的断言会直接高亮在对应步骤上,配合“以最小复现路径分享 URL”的能力,可以快速把失败样例交付给团队。
  • 用 Vitest addon 自动化:安装并配置 Vitest addon(项目需基于 Vite)后,每个 story 会被自动转换成真实的 Vitest 测试,并通过浏览器模式(默认基于 Playwright)渲染执行。你可以在测试组件中一键运行,也可以把它接进编辑器扩展、终端与 CI——例如在package.json中声明"test-storybook": "vitest --project=storybook"脚本并配置 CI workflow。
  • 测试运行器(test-runner):不使用 Vitest addon 的项目,可退而使用基于 Jest + Playwright 的 test-runner 在终端与 CI 中执行同一套交互测试。

若 play 中需要更复杂的前置能力,本仓库的片段库还提供了进阶示例可继续阅读:interaction-test-complex.md(含fn间谍与网络 mock)、login-form-with-play-function.md(表单填写与提交断言)、mount-basic.md(渲染前 mock 时间),以及完整指南 docs/writing-tests/interaction-testing.mdx。

小结

Opens这则示例揭示了 Storybook 交互测试的完整思维模型:story 提供组件的状态与语境,play 函数在此之上模拟用户并校验结果。无论你使用 Angular、React、Vue、Svelte 还是 Web Components,无论项目停留在成熟的 CSF 3、正在尝试 CSF Next,还是拥抱 Svelte 专属的 Svelte CSF,测试语义都保持一致——getByRole定位、userEvent.click触发、expect(...).toBeInTheDocument()验证。掌握这套最小闭环后,即可沿着渲染测试 → 交互测试 → 端到端测试的路径,逐步为组件建立真正贴近用户行为的行为保障网。

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询