ARTICLE DETAIL

资讯详情

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

Storybook 交互测试进阶:用 `storybook/test` 的 `fn()` 对组件回调(如 `onSubmit`)做 Spy 并断言

Storybook 交互测试进阶:用 `storybook/test` 的 `fn()` 对组件回调(如 `onSubmit`)做 Spy 并断言 Storybook 交互测试进阶用storybook/test的fn()对组件回调如onSubmit做 Spy 并断言组件交互测试的难点往往不在渲染成功而在回调真的被调用了、参数是否正确。Storybook 官方文档给出了一种非常实用的模式在 story 的meta.args里用fn()把一个回调型参数包成 spy然后在play函数中用userEvent模拟真实用户输入最后用expect(args.onSubmit).toHaveBeenCalled()断言该回调是否被触发。本文以官方示例中的LoginForm登录表单为核心案例完整讲解这种回调级交互测试在 CSF 3、CSF Next 与 Svelte CSF 三种写法下的实现并结合当前仓库源码说明其原理与配套工具链。读完本文你将能够在 Storybook 中为任意接收回调 props/Output 的组件写出可断言、可调试、可进 CI 的交互测试。一、背景为什么要在args里直接放fn()在 Storybook 中交互测试Interaction tests是构建在 story 基础之上的story 以指定状态渲染组件然后通过 play 函数 模拟点击、输入、提交表单等用户行为并对最终结果做出断言。官方文档在 docs/writing-tests/interaction-testing.mdx 的 Spying on functions withfn 一节中明确指出当你的组件调用某个函数时你可以用来自 Vitest、并通过storybook/test模块提供的fn工具来 spy 该函数从而对它的行为进行断言。大多数情况下你会把fn作为 story 的一个arg值使用然后在测试中访问这个arg。这段话点出了该模式的两个关键设计fn()直接作为args中的值。例如args: { onSubmit: fn() }。对于LoginForm这类受控组件onSubmit是父组件传入的回调Storybook 会把这一组 args 注入组件相当于父组件只传了一个记录型 spy不会去真正提交网络请求或跳转页面。在 play 函数里通过 context 的args拿到同一个 spy。play 函数收到的{ args, canvas, userEvent }与渲染时使用的 args 是同一份因此expect(args.onSubmit).toHaveBeenCalled()才能观察到组件内部触发回调的记录。本示例对应的组件为登录表单LoginForm完整代码片段存放在 docs/_snippets/interaction-test-fn-mock-spy.md被 docs/writing-tests/interaction-testing.mdx 以CodeSnippets pathinteraction-test-fn-mock-spy.md /的方式引用并在所有主流 renderer 下给出等价写法。下文先给出一份最小可读的完整实现再逐段拆解。二、经典 CSF 3 写法通用框架2.1 TypeScript 版本官方片段以your-framework占位的方式给出了与框架无关的模板迁移到实际项目时替换为你的框架包名如react-vite、vue3-vite、svelte-vite等// 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 Metatypeof LoginForm; export default meta; type Story StoryObjtypeof meta; export const FilledForm: Story { play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }, };2.2 JavaScript 版本去掉类型标注后逻辑完全一致import { fn, expect } from storybook/test; import { LoginForm } from ./LoginForm; export default { component: LoginForm, args: { // Use fn to spy on the onSubmit arg onSubmit: fn(), }, }; export const FilledForm { play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }, };2.3 逐段拆解整个示例可以拆成三个正交的部分正好对应交互测试的三个能力来源1meta与args中埋 spyconst meta { component: LoginForm, args: { onSubmit: fn(), }, } satisfies Metatypeof LoginForm;fn()来自storybook/test。它是 Vitest 的 mock 函数工具在 Storybook 测试上下文中的入口具备原样调用后可记录调用次数、调用参数、返回值等 spy 能力。因为onSubmit是组件 props 的一部分把它放进meta.args意味着所有 story 都会默认拿到这个 spy如果某个 story 需要特殊行为可以在 story 级别覆写该 arg。2play中查询 模拟交互play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.com); await userEvent.type(canvas.getByLabelText(Password), a-random-password); await userEvent.click(canvas.getByRole(button, { name: Log in }));canvas是包含被测 story 的可查询作用域查询方法全部来自 Testing Library形式为类型主体例如getByLabelText按 label 找输入框、getByRole按无障碍角色找按钮userEvent用于模拟真实用户行为本例用到type向输入框写入文本和click点击按钮官方文档 docs/writing-tests/interaction-testing.mdx 强调play 函数中的userEvent方法必须await这样才能被 Interactions 面板正确记录和调试。按最接近真实用户的原则这里用ByRole/ByLabelText定位元素而不是退而求其次使用data-testid后者应作为最后手段。3对 spy 做断言await expect(args.onSubmit).toHaveBeenCalled();expect同样来自storybook/test它合并了 Vitest 的expect与testing-library/jest-dom的自定义匹配器。toHaveBeenCalled()断言该被监听函数至少被调用过一次如果需要进一步校验参数可以改用toHaveBeenCalledWith(...)。在 play 函数内expect调用同样建议await以便在测试面板中稳定追踪。三、按框架适配Angular、Vue、Svelte 与 Web ComponentsLoginForm示例在不同框架下组件形态不同代码只有 import 与组件引用方式有差异核心三步args 埋 spy → userEvent 模拟 → expect 断言完全一致。3.1 Angular组件类 Output事件Angular 组件通常通过Output暴露事件对应片段中 import 自storybook/angular、组件来自./login-form.componentimport type { Meta, StoryObj } from storybook/angular; import { fn, expect } from storybook/test; import { LoginForm } from ./login-form.component; const meta: MetaLoginForm { component: LoginForm, args: { // Use fn to spy on the onSubmit arg onSubmit: fn(), }, }; export default meta; type Story StoryObjLoginForm; export const FilledForm: Story { play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }, };3.2 Vue单文件组件Vue 版本的差异仅在组件导入方式默认导入.vue单文件组件import { fn, expect } from storybook/test; import preview from ../.storybook/preview; import LoginForm from ./LoginForm.vue; const meta preview.meta({ component: LoginForm, args: { // Use fn to spy on the onSubmit arg onSubmit: fn(), }, }); export const FilledForm meta.story({ play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }, });3.3 SvelteSvelte 有两种主流写法Svelte CSFdefineMetaStory组件与常规CSF 3。Svelte CSF 下fn与expect仍来自storybook/testdefineMeta来自storybook/addon-svelte-csfscript module import { defineMeta } from storybook/addon-svelte-csf; import LoginForm from ./LoginForm.svelte; const { Story } defineMeta({ component: LoginForm, args: { // Use fn to spy on the onSubmit arg onSubmit: fn(), }, }); /script Story nameFilledForm play{async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }} /如果使用常规 CSF 3则与通用写法相同只需把 renderer 包替换为实际框架包例如sveltekit或svelte-vite组件以默认导入方式引入./LoginForm.svelte同样支持 TS 与 JS 两种形态。3.4 Web Components自定义元素Web Components 场景下component不再是一个类而是自定义元素名demo-login-form同时 renderer 包固定为storybook/web-components-viteimport type { Meta, StoryObj } from storybook/web-components-vite; import { fn, expect } from storybook/test; const meta: Meta { component: demo-login-form, args: { // Use fn to spy on the onSubmit arg onSubmit: fn(), }, }; export default meta; type Story StoryObj; export const FilledForm: Story { play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }, };如果组件使用 Shadow DOMcanvas上的普通查询无法穿透 shadow 边界需要借助shadow-dom-testing-library提供findByShadowRole、getByShadowText等可穿透查询并在 .storybook/preview 文件 中完成配置。3.5 各框架适配差异速查适配点ReactVueAngularSvelteWeb Componentsrenderer import 来源react-vite等vue3-vite等storybook/angularsveltekit/svelte-vitestorybook/web-components-vite组件引用命名导入./LoginForm默认导入./LoginForm.vue类导入./login-form.component默认导入./LoginForm.svelte自定义元素名demo-login-form是否有专属 CSFCSF 3 / CSF NextCSF 3 / CSF NextCSF 3 / CSF NextSvelte CSF / CSF 3CSF 3 / CSF NextonSubmitspy 位置argsargsargsdefineMeta的argsargs四、CSF Next 组合式写法preview.metameta.story除了传统的export default meta 具名导出 story当前仓库文档还同时维护了一套新的组合式 API在官方代码片段中以CSF Next 标注它通过从../.storybook/preview导入preview来获得类型安全与自动补全。以 React 为例import { fn, expect } from storybook/test; import preview from ../.storybook/preview; import { LoginForm } from ./LoginForm; const meta preview.meta({ component: LoginForm, args: { // Use fn to spy on the onSubmit arg onSubmit: fn(), }, }); export const FilledForm meta.story({ play: async ({ args, canvas, userEvent }) { await userEvent.type(canvas.getByLabelText(Email), emailprovider.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(); }, });这种写法的要点是preview.meta({ ... })创建元数据对象语义上等同于传统meta但可以关联到项目级preview配置meta.story({ ... })返回一个 story 对象替代具名导出 StoryObj类型标注适用于 React、Vue、Angular、Web Components 等 rendererTS 与 JS 皆可组件引用差异与上表一致如 Vue 版import LoginForm from ./LoginForm.vue。无论采用传统 CSF 3 还是 CSF Next用fn()包住回调 arg、用args取出断言这一核心手法是通用的迁移成本极低。五、原理纵深这个 Spy 是如何被自动清理的很多读者会担心一个问题onSubmit: fn()创建的是测试全局状态多个 story / 多次运行之间会不会互相污染答案是不会。官方文档 docs/writing-tests/interaction-testing.mdx 给出了一条明确的保证不需要手动 restorefn()创建的 mock因为 Storybook 会在渲染每个 story 之前自动完成清理。参见parameters.test.restoreMocksAPI。也就是说每个 story 渲染前 mock 都会被自动恢复restore从而保证测试间隔离。如果需要在项目层面手动控制这一行为可以查看parameters.test.restoreMocks参数在同一个仓库中该测试基础设施位于 code/addons/vitest 目录下——它负责把 Vitest 与 Storybook 打通为 story 提供测试运行环境、Interactions 面板数据以及断言工具链的注入。从实现结构上可以推断fn/expect/userEvent等 API 均统一由storybook/test模块对外导出屏蔽了不同 renderer 的底层差异这也是为什么各框架示例代码的 import 语句如此一致。六、从能测到跑起来运行与调试6.1 在 Storybook UI 中运行写好上面的 story 后打开 Storybook 的Interactions面板即可看到 play 函数逐步执行的过程type → click → expect每一步都可暂停、恢复、回放、单步。若断言失败失败点会直接标红并显示在面板中无需额外搭建环境即可通过 URL 复现。6.2 在终端 / CI 中自动化官方提供两条自动化路径见 docs/writing-tests/interaction-testing.mdxVitest addon可在 Storybook UI、编辑器、终端 CLI 与 CI 环境中运行。当前仓库的 addon 实现即位于 code/addons/vitest其测试运行器基于 Vitest可与项目已有的单测配置共用test-runner面向不方便使用 Vitest addon 的场景在终端或 CI 中批量执行所有 story 的 play 函数与断言。需要说明的是本文所有代码与命令均基于当前仓库Storybook 主分支快照涉及的具体版本号与包名请以你实际安装的storybook/storybook/test/ 各框架 renderer 包的版本为准。6.3 推荐组合官方在 Troubleshooting 一节给出建议交互测试若对所有组件无差别铺开维护成本会偏高更推荐与视觉测试Visual testing、快照测试等互补手段组合用最少维护成本换取全面覆盖见 docs/writing-tests/interaction-testing.mdx。交互测试相对Vitest Testing Library 裸测的核心优势在于组件运行在真实浏览器环境的 Storybook 中可以可视化调试而不是只能看到 JSDOM 伪造 DOM 的命令行输出且 story 与测试天然同文件存放比散落各处的测试更易维护。七、小结回调级交互测试的黄金三步回顾整个LoginForm案例把监听组件回调提炼为可复用的三步模板埋点import { fn } from storybook/test在meta.args或 Svelte CSF 的defineMeta、CSF Next 的preview.meta中把需要监听的回调写成onSubmit: fn()交互在play函数中利用canvas查询元素、userEvent模拟真实用户操作注意await断言通过args拿到同一个 spy用await expect(args.onSubmit).toHaveBeenCalled()或toHaveBeenCalledWith(...)验证组件确实触发了回调。这套模式同样适用于更复杂的场景——当组件依赖的模块需要在模块层被 mock 时可参考官方针对mock 模块 断言其行为的姊妹示例当组件内部状态变化如本案例中点击 Log in 后应把 submitted 置为 true需要验证时则可在expect中断言 DOM 状态。掌握fn()回调监听后你的 Storybook 交互测试将不只停留在能渲染而是真正覆盖到组件与外界的每一次握手。附文中涉及的仓库文件本文主示例docs/_snippets/interaction-test-fn-mock-spy.md所属指南文档docs/writing-tests/interaction-testing.mdx配套基础示例docs/_snippets/login-form-with-play-function.md相关 APIdocs/api/parameters.mdxparameters.test.restoreMocks测试运行基础设施code/addons/vitest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表