Storybook Play Function 实战指南:在 Story 中自动填充表单、模拟交互并验证组件行为

Storybook Play Function 实战指南:在 Story 中自动填充表单、模拟交互并验证组件行为 Storybook Play Function 实战指南在 Story 中自动填充表单、模拟交互并验证组件行为【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybookplay函数是 Storybook 在 Story 渲染完成后自动执行的脚本片段它让开发者无需人工干预即可驱动组件交互覆盖「需要真实用户操作才能触发」的场景——例如填写注册表单、点击提交按钮、打开弹窗等。本文基于当前仓库中 docs/_snippets/play-function.md 所演示的「注册表单填充与提交」示例结合 docs/writing-stories/play-function.mdx 的讲解与 Storybook 源码实现系统说明 play 函数的编写形态、canvas/screen查询机制、上下文能力以及如何在多框架、多种 CSF 语法下复用组合读完即可在你的组件库中落地交互型 Story。Play Function 是什么渲染之后自动执行的交互脚本Play函数是「在 Story 渲染完成后运行的一小段代码」。它让你能够与组件进行交互并测试那些原本需要用户介入才能发生的场景。在 Storybook 内部它是通过故事对象上的play字段声明、由渲染管线在rendering阶段之后自动调用的export type PlayFunctionTRenderer extends Renderer Renderer, TArgs Args ( context: PlayFunctionContextTRenderer, TArgs ) Promisevoid | void;这段类型定义位于 code/core/src/csf/story.ts#L307-L309。从中可以看到两个关键信息play 函数接收一个上下文对象context它承载组件实例、参数args、画布查询工具与用户事件工具函数可以返回void或Promisevoid也就是说它天然支持异步如await userEvent.type(...)实际运行时会按你在函数体内书写的顺序逐步执行。官方文档将PlayFunctionContext标注为StoryContext的废弃别名见 code/core/src/csf/story.ts#L294-L298二者指向同一套上下文结构。真正驱动 play 运行的是预览层渲染器 code/core/src/preview-api/modules/preview-web/render/StoryRender.ts它会先构建完整上下文当renderOptions.autoplay为true默认值且故事存在playFunction时进入playing阶段并执行playFunction(context)结束后切换为played。期间每一步都会被 instrumenter 记录因此在浏览器中打开Interactions交互面板可以看到逐步执行的可视化流水线。交互面板是 play 函数的最佳观察窗口Storybook 完成渲染后会自动执行 play 中定义的所有步骤并填充表单信息全程无需用户介入而你可以在 Interactions 面板中回放每一步交互。实战示例用 play 函数自动填充并提交注册表单假设你在开发一个注册表单组件RegistrationForm希望验证填写邮箱、密码后点击提交的完整流程。因为组件在 Storybook 中默认处于静态展示状态没有真实用户去操作它此时就可以写一个带play函数的 Story让 Storybook 在渲染结束后自动替用户完成整条交互链路。CSF 3 标准写法TypeScript / JavaScript对于绝大多数框架React、Vue、Angular、Svelte、Web Components 等通用场景可以把RegistrationForm.stories.ts|tsx写成下面这种 CSF 3 形态对应仓库示例中的commonrendererreact变体import type { Meta, StoryObj } from storybook/your-framework; import { RegistrationForm } from ./RegistrationForm; const meta { component: RegistrationForm, } satisfies Metatypeof RegistrationForm; export default meta; type Story StoryObjtypeof meta; export const FilledForm: Story { play: async ({ canvas, userEvent }) { const emailInput canvas.getByLabelText(email, { selector: input, }); await userEvent.type(emailInput, example-emailemail.com, { delay: 100, }); const passwordInput canvas.getByLabelText(password, { selector: input, }); await userEvent.type(passwordInput, ExamplePassword, { delay: 100, }); const submitButton canvas.getByRole(button); await userEvent.click(submitButton); }, };纯 JavaScript 版本.js|.jsx除了去掉类型标注外逻辑完全一致import { RegistrationForm } from ./RegistrationForm; export default { component: RegistrationForm, }; export const FilledForm { play: async ({ canvas, userEvent }) { const emailInput canvas.getByLabelText(email, { selector: input, }); await userEvent.type(emailInput, example-emailemail.com, { delay: 100, }); const passwordInput canvas.getByLabelText(password, { selector: input, }); await userEvent.type(passwordInput, ExamplePassword, { delay: 100, }); const submitButton canvas.getByRole(button); await userEvent.click(submitButton); }, };上例中storybook/your-framework是占位写法实际项目中请替换为你使用的框架包例如 React 生态的storybook/react-vite、storybook/nextjs或 Vue 生态的storybook/vue3-vite等。逐步拆解示例中的每个 API这一小段 play 函数浓缩了 Storybook 交互测试中最常用的四个工具用法canvas.getByLabelText(email, { selector: input })——canvas上挂载的是作用域化的 Testing Library 查询方法。getByLabelText通过label关联的文本找到对应控件selector: input是额外的过滤选项用于把查询结果收敛到指定标签的元素上如果组件内 label 与多个元素关联这个选项可以消除歧义。这与你在普通 Testing Library 测试里的用法完全一致。await userEvent.type(emailInput, ..., { delay: 100 })——userEvent来自testing-library/user-event的封装会在每个字符之间模拟真实的键盘输入事件。delay: 100单位毫秒让每次按键之间停顿 100ms用来模拟接近真实人类的输入节奏也让表单的受控状态有机会逐字符更新。由于 play 函数体支持异步这里必须await以确保后续步骤发生在输入真正完成之后。canvas.getByRole(button)——通过 ARIA role 查询按钮。getByRole是 Testing Library 推荐的可访问性优先查询方式即使按钮上没有data-testid只要语义正确即可稳定命中。如果页面上有多个按钮可以像前面一样传入{ name: ... }选项按可访问名称过滤例如{ name: Submit }。await userEvent.click(submitButton)——触发一次真实的点击事件。所有userEvent的调用都应await从而让 React/Vue/Angular 等框架完成一轮事件循环中的状态更新再接续断言或下一步操作。运行这个 Story 后组件会自动完成输入邮箱 → 输入密码 → 点击提交配合交互面板即可像看录屏一样逐步回放天然充当了可重复执行的手工测试替代品。多框架书写形态一览仓库示例为同一套逻辑提供了多种框架与多种 CSF 语法的完整对照分别以tabTitle区分核心差异只在于元信息的书写位置与组件的导入方式。下表汇总了你在RegistrationForm.stories.*各变体中可以选用的形态语法形态适用场景特征CSF 3export default 具名导出 StoryReact/Vue/Svelte/Angular/Web Components 等标准、推荐组件通过meta.component声明CSF Next preview.meta()meta.story()实验性新语法从.storybook/preview引入preview通过preview.meta()声明 meta、meta.story()定义故事Svelte CSFstorybook/addon-svelte-csfSvelte 项目在.stories.svelte中用defineMetaStory play{...} /书写Web Componentscomponent: demo-xxxWeb Componentsmeta不引用类而是注册自定义元素标签名字符串Angular组件按类导入Angular 项目的 CSF 3 写法来自 code/frameworks/angular 对应的渲染器形态需要从storybook/angular导入类型并把组件类放入metaimport type { Meta, StoryObj } from storybook/angular; import { RegistrationForm } from ./registration-form.component; const meta: MetaRegistrationForm { component: RegistrationForm, }; export default meta; type Story StoryObjRegistrationForm; export const FilledForm: Story { play: async ({ canvas, userEvent }) { const emailInput canvas.getByLabelText(email, { selector: input, }); await userEvent.type(emailInput, example-emailemail.com, { delay: 100, }); const passwordInput canvas.getByLabelText(password, { selector: input, }); await userEvent.type(passwordInput, ExamplePassword, { delay: 100, }); const submitButton canvas.getByRole(button); await userEvent.click(submitButton); }, };SvelteSvelte CSF 模板写法如果使用 Svelte 的官方 CSF 扩展storybook/addon-svelte-csf见 code/addons 与相关渲染器Story 文件是.stories.svelteplay 函数作为Story组件的play属性传入。Svelte CSF 中组件在script module里通过defineMeta声明交互逻辑则可以放在常规script或直接以内联表达式传给playscript module import { defineMeta } from storybook/addon-svelte-csf; import RegistrationForm from ./RegistrationForm.svelte; const { Story } defineMeta({ component: RegistrationForm, }); /script Story nameFilledForm play{async ({ canvas, userEvent }) { const emailInput canvas.getByLabelText(email, { selector: input, }); await userEvent.type(emailInput, example-emailemail.com, { delay: 100, }); const passwordInput canvas.getByLabelText(password, { selector: input, }); await userEvent.type(passwordInput, ExamplePassword, { delay: 100, }); const submitButton canvas.getByRole(button); await userEvent.click(submitButton); }} /该文件同时存在languagejs与languagets两个等价变体逻辑完全一致仅在文件脚本语言与可选类型标注上有区别。Svelte 项目若不想引入 addon也可以回到上文标准的 CSF 3 默认导出写法把RegistrationForm直接导入meta。Web Components以元素标签作为 componentWeb Components 渲染器不接收组件类而是接收自定义元素的标签名字符串示例中为demo-registration-formCSF 3 与 CSF Next 两种形态分别如下import type { Meta, StoryObj } from storybook/web-components-vite; const meta: Meta { component: demo-registration-form, }; export default meta; type Story StoryObj; export const FilledForm: Story { play: async ({ canvas, userEvent }) { const emailInput canvas.getByLabelText(email, { selector: input, }); await userEvent.type(emailInput, example-emailemail.com, { delay: 100, }); const passwordInput canvas.getByLabelText(password, { selector: input, }); await userEvent.type(passwordInput, ExamplePassword, { delay: 100, }); const submitButton canvas.getByRole(button); await userEvent.click(submitButton); }, };CSF Next实验性语法preview.meta()meta.story()仓库还在同一组示例中提供了标有 的CSF Next变体。它不再依赖export default meta与具名导出的松散约定而是从项目的.storybook/preview引入preview链式声明 meta 与 story。其结构如下import preview from ../.storybook/preview; import { RegistrationForm } from ./RegistrationForm; const meta preview.meta({ component: RegistrationForm, }); export const FilledForm meta.story({ play: async ({ canvas, userEvent }) { const emailInput canvas.getByLabelText(email, { selector: input, }); await userEvent.type(emailInput, example-emailemail.com, { delay: 100, }); const passwordInput canvas.getByLabelText(password, { selector: input, }); await userEvent.type(passwordInput, ExamplePassword, { delay: 100, }); const submitButton canvas.getByRole(button); await userEvent.click(submitButton); }, });同样的preview.meta()写法覆盖了 React.ts|.tsx与.js|.jsx、Vue.ts与.js、Angular、Web Components 等多个渲染器。CSF Next 属于实验性功能文档中以 标注试用前建议确认当前 Storybook 版本对其的支持状态并把../.storybook/preview的导入路径替换为你项目真实位置。理解传入 play 函数的上下文对象play 函数签名只接收一个context参数但它内部实际聚合了渲染管线中与当前故事相关的一整套状态与工具。核心字段由 code/core/src/csf/story.ts 中的StoryContext接口定义并在 StoryRender.ts 中完成组装常见成员包括字段说明canvas作用域化的 Testing Library 查询集合所有查询都从当前故事的根元素开始默认避开组件外内容userEvent基于userEvent.setup()的交互事件工具键盘输入、点击、双击、hover 等调用需要awaitcanvasElement当前故事渲染所挂载的真实 DOM 元素canvas的查询边界即以此元素为根mount用于手动挂载/重挂载组件的方法若 play 内部解构使用mount渲染器会自动接续playing阶段见 StoryRender.tsstep将一段子流程封装成有标签的步骤便于在交互面板中分组展示abortSignal渲染被取消如切换 Story时触发的信号供长流程中断args/argTypes/globals当前 Story 的参数与全局配置loadedloaders 加载的数据结果从源码角度看StoryRender.ts在rendering阶段先以空对象占位canvas: {}与userEvent: {}随后由各渲染器真正把绑定到canvasElement的查询方法和setup 后的 userEvent 实例注入上下文类型定义见 code/core/src/csf/story.ts#L270-L286。理解这一点有助于明白play 函数的上下文与你手写within()/userEvent.setup()的测试是等价的Storybook 替你完成了脚手架工作。查询边界的选择canvas还是screencanvas从组件根元素开始查询play 函数上下文中的canvas提供了 Testing Library 查询方法的作用域版本你可以像在常规测试里一样使用它们。其精确定义是从组件根元素开始查询所以下面的写法会把查询范围限定在MyComponent内部export const ExampleStory: Story { play: async ({ canvas, userEvent }) { // Starts querying from the components root element await userEvent.type(canvas.getByTestId(example-element), something); await userEvent.click(canvas.getByRole(button)); }, };完整多框架示例见 docs/_snippets/play-function-with-canvas.md。screen查询整个文档有些元素会渲染在 Story 根元素之外——最典型的是通过 portal/teleport 挂载到body的对话框、弹层、下拉浮层。它们不在canvasElement内部canvas查询无法命中。此时应改用storybook/test提供的screen对象它从整个document出发查询import { screen } from storybook/test; import type { Meta, StoryObj } from storybook/your-framework; import { Dialog } from ./Dialog; const meta { component: Dialog, } satisfies Metatypeof Dialog; export default meta; type Story StoryObjtypeof meta; export const Open: Story { play: async ({ canvas, userEvent }) { await userEvent.click(canvas.getByRole(button, { name: Open dialog })); // Starts querying from the document const dialog screen.getByRole(dialog); await expect(dialog).toBeVisible(); }, };这段示例完整版见 docs/_snippets/play-function-with-screen.md演示了两种查询边界的协作先用canvas点击触发弹窗的按钮再用screen在文档级定位由 portal 渲染出的dialog最后用从storybook/test导入的expect断言其可见。值得一提的是Storybook 对screen的使用位置做了贴心约束源码中 code/core/src/test/testing-library.ts#L26-L37 为screen包裹了一层 Proxy——当检测到页面处于viewModedocs即 Docs 模式时会告警提示同一页面渲染了多个 Storyscreen可能命中多个元素请改用 story context 中的canvas把查询作用域限定到每个 Story。这说明一个实践准则单 Story 视图story 模式screen可用Docs 模式 / 同一页面存在多个渲染实例时优先使用canvas。canvas 与 screen 对照速查场景推荐查询对象查询当前组件根元素内部的元素canvas.getBy*点击按钮/链接、填充表单canvas.getByRole/canvas.getByLabelText配合userEvent对话框、toast、下拉等 portal 元素storybook/test的screen.getByRole交互后断言元素状态await expect(...).toBeVisible()等expect由storybook/test导出实现在 code/core/src/test/index.tsstorybook/test本质上是从testing-library/dom与testing-library/user-event封装的统一入口screen、within、各类getBy*/findBy*查询、fireEvent、userEvent以及经 instrumenter 插桩可被交互面板追踪的expect都从该包导出具体实现见 code/core/src/test/testing-library.ts。组合与复用让多个 Story 的 play 函数串成完整工作流基于组件故事格式CSF的 ES6 模块化特性play 函数也可以像 args、decorators 等其他 Storybook 特性一样被组合复用。如果你希望验证一个横跨多个交互步骤的完整业务流程可以先把每一步写成独立 Story再创建一个组合 Story在其中依次手动调用前序 Story 的 play 函数import type { Meta, StoryObj } from storybook/your-framework; import { MyComponent } from ./MyComponent; const meta { component: MyComponent, } satisfies Metatypeof MyComponent; export default meta; type Story StoryObjtypeof meta; export const FirstStory: Story { play: async ({ canvas, userEvent }) { await userEvent.type(canvas.getByTestId(an-element), example-value); }, }; export const SecondStory: Story { play: async ({ canvas, userEvent }) { await userEvent.type(canvas.getByTestId(other-element), another value); }, }; export const CombinedStories: Story { play: async ({ context, canvas, userEvent }) { // Runs the FirstStory and Second story play function before running this storys play function await FirstStory.play(context); await SecondStory.play(context); await userEvent.type(canvas.getByTestId(another-element), random value); }, };多框架完整示例见 docs/_snippets/play-function-composition.md。组合的关键在于play: async ({ context, canvas, userEvent })中解构出的context它正是各个 Story play 函数要求的同款上下文对象直接透传给FirstStory.play(context)、SecondStory.play(context)即可让被复用的 play 函数感知当前组件实例与渲染环境。这种方式相当于把组件整个工作流在 Story 层面还原出来既能提前暴露流程中的潜在问题也大大减少了重复的交互样板代码。更进一步play 函数与交互测试的关系play 函数并不只服务于 Storybook UI 内的人工调试。由于它描述的是一段可自动执行的交互同样是 Storybook 交互测试Interaction Testing与组件测试的根基——play 中userEvent.type、userEvent.click、canvas.getByRole、expect等 API 的事件与断言语义与交互测试文档中给出的 API 概览一一对应可参见 docs/writing-tests/interaction-testing.mdx 中对可用交互事件的汇总。此外当前仓库的 ESLint 插件code/lib/eslint-plugin/docs/rules也收录了await-interactions、context-in-play-function等围绕 play 函数书写规范的规则用于在代码层面提醒开发者对异步交互保持await、并正确地消费 play 上下文可将其纳入工程规范以保持团队内交互型 Story 写法的一致性。小结围绕一份自动填充并提交注册表单的 Story 示例本文覆盖了 play 函数从入门到进阶的完整知识链条它本质上是渲染完成后自动运行的异步脚本通过 code/core/src/csf/story.ts 中定义的类型签名与 StoryRender.ts 中的渲染阶段被统一调度在不同框架中你只需把canvas.getByLabelText、userEvent.type、userEvent.click这套交互语料放入 CSF 3、Svelte CSF 或 CSF Next 任一书写形态中即可开箱即用。牢记两条核心准则能用canvas就优先canvas查询边界更稳定、Docs 模式兼容遇到 portal 渲染的弹层类元素再切换storybook/test的screen所有userEvent交互务必await并善用组合 Story 复用既有 play 逻辑。实践时多打开 Interactions 面板观察逐步回放会让整个交互链路一目了然。【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考