十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Storybook 复杂交互测试实战:用 fn、beforeEach 与 play 驱动真实表单组件

Storybook 复杂交互测试实战:用 fn、beforeEach 与 play 驱动真实表单组件 Storybook 复杂交互测试实战用 fn、beforeEach 与 play 驱动真实表单组件本篇以 Storybook 官方文档中interaction-test-complex代码示例docs/_snippets/interaction-test-complex.md为核心完整拆解一个面向真实业务表单EventForm的复杂交互测试如何在 Story 的args中注入fn()间谍函数、如何在渲染前用beforeEach准备 Mock 数据、如何用 Testing Library 风格的canvas查询与userEvent模拟用户完整操作流程并最终通过expect断言被调用的函数参数。读完本文你将掌握一套可复制的带依赖模拟 用户行为验证的 Storybook 组件测试范式并能在 CSF 3、实验性的 CSF Next 及 Svelte CSF 等多种语法中自由迁移。从简单到复杂示例在文档体系中的位置Storybook 把测试称为组件测试component tests它在真实浏览器中渲染单个组件既能像单元测试一样 mock 与操作内部实现又能像端到端测试一样模拟真实用户行为。测试向导文档 docs/writing-tests/index.mdx 将测试分为渲染测试render test、交互测试、可访问性、视觉与快照等类型其中interaction-test-complex.md被 index.mdx 作为更复杂示例引用Heres a more complex example, which includes spying and mocking via thefnutility.也就是说该片段是 交互测试完整指南 中fn间谍与beforeEach渲染前准备等章节的配套完整示例供 Angular、React、Vue、Svelte、Web Components 各渲染器复用。与之对应的更入门版本是 interaction-test-simple.md它只演示点击按钮并断言弹窗出现这一最基础交互而复杂版本进一步覆盖了异步数据 Mock、列表渲染断言、表单输入与提交回调参数校验是理解真实交互测试工程化的关键样本。完整示例为 EventForm 编写 Submits 测试示例围绕一个虚构的活动报名表单组件EventForm展开组件挂载后通过getUsers获取一批用户并渲染为列表用户填写活动标题并点击Plan event提交随后组件调用onSubmit回调传入{ name, userCount, data }。Submits这条 Story 要验证的正是整条用户链路。下面是面向任意框架的标准 CSF 3 版本原文renderercommon的 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 { users } from ../mocks/users; import { EventForm } from ./EventForm; const meta { component: EventForm, } satisfies Metatypeof EventForm; export default meta; type Story StoryObjtypeof meta; export const Submits: Story { // Mock functions so we can manipulate and spy on them args: { getUsers: fn(), onSubmit: fn(), }, beforeEach: async ({ args }) { // Manipulate getUsers mock to return mocked value args.getUsers.mockResolvedValue(users); }, play: async ({ args, canvas, userEvent }) { const usersList canvas.getAllByRole(listitem); await expect(usersList).toHaveLength(4); await expect(canvas.getAllByText(VIP)).toHaveLength(2); const titleInput await canvas.findByLabelText(Enter a title for your event); await userEvent.type(titleInput, Holiday party); const submitButton canvas.getByRole(button, { name: Plan event }); await userEvent.click(submitButton); // Spy on onSubmit to verify that it is called correctly await expect(args.onSubmit).toHaveBeenCalledWith({ name: Holiday party, userCount: 4, data: expect.anything(), }); }, };对应纯 JavaScript 的 CSF 3 版本逻辑完全一致只是去掉类型标注改用export default { component: EventForm }与export const Submits { ... }的写法。下面把示例中的每条关键技法逐层拆解。第一步把fn()写进 args让组件打给间谍示例中最重要的一步是用storybook/test提供的fn()作为 args 的默认值import { fn, expect } from storybook/test; // ... args: { getUsers: fn(), onSubmit: fn(), },storybook/test是 Storybook 对 Vitest 测试工具的统一再导出入口在源码模板与真实用例中大量使用例如 code/core/template/stories/component-test.basics.stories.ts 中同样以onSuccess: fn()注入回调并断言其是否被调用。这里有两个关键设计组件收到的是真实函数fn()返回的是一个可被调用、可被断言、可被编程控制的间谍函数。当它被当作 args 传入时组件内部调用getUsers()或onSubmit(...)都会落到这个间谍上而不是落到空的或未定义的 prop 上从而避免因依赖缺失导致渲染崩溃。后续既能在beforeEach中编程、也能在play中断言注释 Mock functions so we can manipulate and spy on them 精准概括了用途——既能操纵返回值manipulate也能观察调用spy。这种模式与完整指南中 fn 间谍章节 及配套片段 interaction-test-fn-mock-spy.md 一脉相承绝大多数情况下都应把fn当作 Story 的arg值然后在测试中取出同一个arg使用。第二步用 story 级beforeEach在渲染前准备 Mock 数据Submits在 Story 上直接声明了一个异步beforeEachbeforeEach: async ({ args }) { // Manipulate getUsers mock to return mocked value args.getUsers.mockResolvedValue(users); },beforeEach从context中解构出args再调用args.getUsers.mockResolvedValue(users)让获取用户这一异步依赖立刻返回文档中预置的 4 条users假数据。注释 ManipulategetUsersmock to return mocked value 点出了目的在组件渲染并真正发起用户列表请求之前先把间谍的行为确定下来从而让测试具有确定性的初始状态。关于执行时序code/core/template/stories/before-each.stories.ts 给出了可验证的调用顺序该模板用loaders先向context.foo写入初始值beforeEach再追加内容最后play断言[bar, baz]说明在一条 Story 的生命周期里loaders → beforeEach → play依序执行beforeEach天然适合做 play 之前的依赖与状态装配。在交互测试指南中还有更进一步的说明对于 Angular 渲染器为 Story 定义的异步beforeEach可用于在组件渲染前执行代码其他渲染器则常通过play内的mount控制渲染时机。此外文档提醒fn()创建的 Mock 无需手动还原Storybook 会在渲染每条 Story 之前自动清理对应parameters.test.restoreMocks行为因此跨 Story 之间不会出现上一次的调用记录污染下一次断言的问题。第三步用 canvas 查询断言渲染结果play解构出的canvas是被测 Story 的可查询根节点其上的所有查询方法都直接来自 Testing Library命名遵循类型主体的规则例如getAllByRole、findByLabelText。示例在输入操作前先校验了列表的初始渲染const usersList canvas.getAllByRole(listitem); await expect(usersList).toHaveLength(4); await expect(canvas.getAllByText(VIP)).toHaveLength(2);getAllByRole(listitem)断言页面中共有 4 个列表项间接证明getUsers返回的 4 条 Mock 用户被成功渲染getAllByText(VIP)断言其中出现 2 处 VIP 文本验证了数据的细节呈现。选择查询主体时Storybook 沿用了 Testing Library 的推荐优先级优先用可访问角色ByRole、标签文本ByLabelText、占位符、文本、显示值、alt、title把data-testid作为最后手段。查询类型的行为差异如下查询类型0 个匹配1 个匹配1 个匹配是否需要等待getBy...抛错返回元素抛错否queryBy...返回null返回元素抛错否findBy...抛错返回元素抛错是getAllBy...抛错返回数组返回数组否queryAllBy...返回[]返回数组返回数组否findAllBy...抛错返回数组返回数组是本例中findByLabelText之所以用find前缀是因为在真实渲染流程中带 label 的输入框可能涉及异步任务如列表数据加载后表单才出现findBy*会内置轮询等待而getByRole/getAllByRole用于断言此刻已同步存在于 DOM 中的元素。第四步用 userEvent 模拟真实用户操作找到目标元素后示例用userEvent完整模拟了输入标题 → 点击提交const titleInput await canvas.findByLabelText(Enter a title for your event); await userEvent.type(titleInput, Holiday party); const submitButton canvas.getByRole(button, { name: Plan event }); await userEvent.click(submitButton);userEvent是play的参数之一负责模拟点击、键入、hover、tab、选择下拉项等真实交互。示例提醒我们一个硬性规范userEvent的每个方法都必须await这既保证交互按真实节奏异步推进也确保每一步能在 Interactions 面板中被正确记录与回放。常用方法包括click、dblClick、hover、type、keyboard、selectOptions、clear等。本例还展示了基于无障碍可访问名称定位按钮的写法——getByRole(button, { name: Plan event })直接按用户看到的按钮文字锁定目标是最贴近真实用户视角的定位方式。第五步断言 onSubmit 收到预期参数交互完成后示例通过间谍断言了回调的输入质量// Spy on onSubmit to verify that it is called correctly await expect(args.onSubmit).toHaveBeenCalledWith({ name: Holiday party, userCount: 4, data: expect.anything(), });这里一次断言同时覆盖了三个事实用户输入的标题被正确回传name: Holiday party、组件从列表推断出的参与者数量正确userCount: 4、以及存在一个不便于精确匹配的附加对象参数data: expect.anything()。expect.anything()是 Vitest 提供的部分匹配能力用于只校验确定知道的字段、放行无法稳定预测的字段这是复杂真实场景下非常实用的断言策略。expect通过storybook/test导出同时聚合了 Vitest 的断言方法与testing-library/jest-dom的自定义匹配器如toBeInTheDocument()、toBeVisible()、toHaveAttribute()等。与userEvent一样expect调用在 play 内也必须await从而能被 Interactions 面板正确记录。除本例用到的toHaveLength、toHaveBeenCalledWith外交互测试中高频匹配器还包括toBeInTheDocument()元素存在于 DOM、toBeVisible()、toHaveAttribute()、toHaveBeenCalled()等Storybook 还提供配套的 ESLint 规则如await-interactions、use-storybook-expect在编码期强制这些规范。运行、调试与自动化交互测试是 Story 的一部分因此运行方式与渲染测试完全统一Storybook UI 内运行如果项目使用了 Vitest addon详见 docs/writing-tests/integrations/vitest-addon/index.mdx可展开侧边栏底部的 testing widget 点击Run component tests或在 Story/文件夹的右键菜单中触发终端与 CI通过 CLI 或 CI 工作流执行相关指南见 docs/writing-tests/in-ci.mdx无法使用 Vitest addon 时可改用 test-runner 在终端或 CI 中运行同样的交互测试。调试方面Storybook 的Interactions 面板会把play中的每一条userEvent/expect以步骤化列表呈现支持暂停、恢复、回退与单步执行能精确定位失败发生在哪一次点击或哪一个断言上。对复杂的多阶段流程还可以用step函数把一组交互折叠成带标签的分组让失败信息更易读。同一测试在更多语法与框架中的写法该示例的意义还在于同一份Submits逻辑在 Storybook 的多种文件格式与渲染器中都有等价写法阅读时只需关注语法差异、不必重复学习测试语义。实验性 CSF Next 格式preview.meta / meta.story在较新版本中文档以CSF Next 标签提供了一种借助../.storybook/preview显式声明 meta 的写法以 React 为例import { fn, expect } from storybook/test; import preview from ../.storybook/preview; import { users } from ../mocks/users; import { EventForm } from ./EventForm; const meta preview.meta({ component: EventForm, }); export const Submits meta.story({ // Mock functions so we can manipulate and spy on them args: { getUsers: fn(), onSubmit: fn(), }, beforeEach: async ({ args }) { // Manipulate getUsers mock to return mocked value args.getUsers.mockResolvedValue(users); }, play: async ({ args, canvas, userEvent }) { const usersList canvas.getAllByRole(listitem); await expect(usersList).toHaveLength(4); await expect(canvas.getAllByText(VIP)).toHaveLength(2); const titleInput await canvas.findByLabelText(Enter a title for your event); await userEvent.type(titleInput, Holiday party); const submitButton canvas.getByRole(button, { name: Plan event }); await userEvent.click(submitButton); // Spy on onSubmit to verify that it is called correctly await expect(args.onSubmit).toHaveBeenCalledWith({ name: Holiday party, userCount: 4, data: expect.anything(), }); }, });差异仅在于preview.meta()从全局 preview 导出 meta再用meta.story()包裹 Story 对象。该写法在源码中属于实验性演进方向实际生效能力请以你所用 Storybook 版本的官方说明为准。Angular、Vue、Web Components 的 CSF Next 变体除组件导入与类型外结构完全相同均存在于原片段的对应代码标签中。Angularrenderer 专属类型 渲染前 beforeEachAngular 版本使用storybook/angular的类型并以组件类引用组件import type { Meta, StoryObj } from storybook/angular; import { fn, expect } from storybook/test; import { users } from ../mocks/users; import { EventForm } from ./event-form.component; const meta: MetaEventForm { component: EventForm, }; export default meta; type Story StoryObjEventForm; export const Submits: Story { // Mock functions so we can manipulate and spy on them args: { getUsers: fn(), onSubmit: fn(), }, beforeEach: async ({ args }) { // Manipulate getUsers mock to return mocked value args.getUsers.mockResolvedValue(users); }, play: async ({ args, canvas, userEvent }) { const usersList canvas.getAllByRole(listitem); await expect(usersList).toHaveLength(4); await expect(canvas.getAllByText(VIP)).toHaveLength(2); const titleInput await canvas.findByLabelText(Enter a title for your event); await userEvent.type(titleInput, Holiday party); const submitButton canvas.getByRole(button, { name: Plan event }); await userEvent.click(submitButton); // Spy on onSubmit to verify that it is called correctly await expect(args.onSubmit).toHaveBeenCalledWith({ name: Holiday party, userCount: 4, data: expect.anything(), }); }, };注意 Angular 模板对EventForm的导入来自./event-form.component与组件装饰器的命名约定一致Angular 也是示例里异步beforeEach于组件渲染前执行这一语义的直接受益者——组件初始化如构造/生命周期内发起用户请求之前即可注入假数据。Svelte CSF基于 addon-svelte-csf 的声明式写法Svelte 项目既可以用标准的 CSF 3 文件也可以使用storybook/addon-svelte-csf提供的.stories.svelte单文件语法。后者用defineMeta定义 meta用Story组件承载 Storyscript module import { defineMeta } from storybook/addon-svelte-csf; import { fn, expect } from storybook/test; import { users } from ../mocks/users; import EventForm from ./EventForm.svelte; const { Story } defineMeta({ component: EventForm, }); /script Story nameSubmits args{{ // Mock functions so we can manipulate and spy on them getUsers: fn(), onSubmit: fn(), }} beforeEach{async ({ args }) { // Manipulate getUsers mock to return mocked value args.getUsers.mockResolvedValue(users); }} play{async ({ args, canvas, userEvent }) { const usersList canvas.getAllByRole(listitem); await expect(usersList).toHaveLength(4); await expect(canvas.getAllByText(VIP)).toHaveLength(2); const titleInput await canvas.findByLabelText(Enter a title for your event); await userEvent.type(titleInput, Holiday party); const submitButton canvas.getByRole(button, { name: Plan event }); await userEvent.click(submitButton); // Spy on onSubmit to verify that it is called correctly await expect(args.onSubmit).toHaveBeenCalledWith({ name: Holiday party, userCount: 4, data: expect.anything(), }); }} /可以看到即便文件语法与组件形态完全不同args、beforeEach、play这套测试语义契约依然原样保留——这正是把测试嵌入 Story 抽象层的价值所在。Svelte 同时还提供了纯 CSF 3EventForm.stories.ts的等价写法。Web Components以自定义元素标签作为 componentWeb Components 渲染器中component不再指向类而是指向自定义元素名如demo-event-form其余保持一致import type { Meta, StoryObj } from storybook/web-components-vite; import { fn, expect } from storybook/test; import { users } from ../mocks/users; const meta: Meta { component: demo-event-form, }; export default meta; type Story StoryObj; export const Submits: Story { // Mock functions so we can manipulate and spy on them args: { getUsers: fn(), onSubmit: fn(), }, beforeEach: async ({ args }) { // Manipulate getUsers mock to return mocked value args.getUsers.mockResolvedValue(users); }, play: async ({ args, canvas, userEvent }) { const usersList canvas.getAllByRole(listitem); await expect(usersList).toHaveLength(4); await expect(canvas.getAllByText(VIP)).toHaveLength(2); const titleInput await canvas.findByLabelText(Enter a title for your event); await userEvent.type(titleInput, Holiday party); const submitButton canvas.getByRole(button, { name: Plan event }); await userEvent.click(submitButton); // Spy on onSubmit to verify that it is called correctly await expect(args.onSubmit).toHaveBeenCalledWith({ name: Holiday party, userCount: 4, data: expect.anything(), }); }, };若被测组件使用 Shadow DOM指南还建议引入shadow-dom-testing-library在.storybook/preview中配置后将查询替换为findByShadowRole、getByShadowText等穿透 Shadow 边界的版本。变体总览综合原片段可归纳出如下变体矩阵所有变体的args/beforeEach/play逻辑保持一致格式目标渲染器语法要点CSF 3TScommon框架通配、Angular、Svelte、Web Componentssatisfies MetaStoryObjAngular 用组件类、Web Components 用元素名CSF 3JScommon、Svelte、Web Components去掉类型层export default { component } 具名对象CSF Next Angular、React、Vue、Web Componentspreview.meta()meta.story()Svelte CSFSveltedefineMetaStory name args beforeEach play在 docs/_snippets/interaction-test-complex.md 中可逐一对照每种 tab 的完整原文。小结interaction-test-complex示例虽短却浓缩了 Storybook 组件测试的全部关键机制把fn()放进args以获得可操纵、可断言的依赖替身用 Story 级beforeEach在测试执行前铺设异步 Mock用 Testing Library 风格查询验证列表渲染细节用userEvent.type/click还原真实操作最后用toHaveBeenCalledWith加expect.anything()对业务回调参数做精准断言。将它应用到自己的组件时请保持每条userEvent与expect都await优先用可访问角色查询Mock 交给fn()、状态清理交给 Storybook 自动 restore这几条纪律即可在浏览器中获得既贴近真实用户又具备单元测试精度的交互测试体验。进一步阅读仓库内路径交互测试完整方法论docs/writing-tests/interaction-testing.mdx简单交互示例docs/_snippets/interaction-test-simple.mdfn 间谍配套片段docs/_snippets/interaction-test-fn-mock-spy.md渲染前/后钩子片段docs/_snippets/before-each-in-meta-mock-date.md、docs/_snippets/after-each-in-meta.md运行方式Vitest addon 文档、CI 指南、test-runner源码佐证beforeEach生命周期顺序 code/core/template/stories/before-each.stories.ts、fn()注入 args 的真实模板 code/core/template/stories/component-test.basics.stories.ts创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表