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 with fn" 一节中明确指出:
当你的组件调用某个函数时,你可以用来自 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 path="interaction-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 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();
},
};
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'), '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();
},
};
2.3 逐段拆解
整个示例可以拆成三个正交的部分,正好对应交互测试的三个能力来源:
(1)meta 与 args 中埋 spy
const meta = {
component: LoginForm,
args: {
onSubmit: fn(),
},
} satisfies Meta<typeof LoginForm>;
fn() 来自 storybook/test。它是 Vitest 的 mock 函数工具在 Storybook 测试上下文中的入口,具备"原样调用后可记录调用次数、调用参数、返回值"等 spy 能力。因为 onSubmit 是组件 props 的一部分,把它放进 meta.args 意味着所有 story 都会默认拿到这个 spy;如果某个 story 需要特殊行为,可以在 story 级别覆写该 arg。
(2)play 中查询 + 模拟交互
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' }));
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 Components
LoginForm 示例在不同框架下组件形态不同,代码只有 import 与组件引用方式有差异,核心三步(args 埋 spy → userEvent 模拟 → expect 断言)完全一致。
3.1 Angular(组件类 + @Output 事件)
Angular 组件通常通过 @Output 暴露事件,对应片段中 import 自 @storybook/angular、组件来自 ./login-form.component:
import type { Meta, StoryObj } from '@storybook/angular';
import { fn, expect } from 'storybook/test';
import { LoginForm } from './login-form.component';
const meta: Meta<LoginForm> = {
component: LoginForm,
args: {
// 👇 Use `fn` to spy on the onSubmit arg
onSubmit: fn(),
},
};
export default meta;
type Story = StoryObj<LoginForm>;
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();
},
};
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'), '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();
},
});
3.3 Svelte
Svelte 有两种主流写法:Svelte CSF(defineMeta + <Story> 组件)与常规 CSF 3。
Svelte CSF 下,fn 与 expect 仍来自 storybook/test,defineMeta 来自 @storybook/addon-svelte-csf:
<script 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
name="FilledForm"
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();
}}
/>
如果使用常规 CSF 3,则与通用写法相同,只需把 renderer 包替换为实际框架包(例如 sveltekit 或 svelte-vite),组件以默认导入方式引入 ./LoginForm.svelte,同样支持 TS 与 JS 两种形态。
3.4 Web Components(自定义元素)
Web Components 场景下 component 不再是一个类,而是自定义元素名 'demo-login-form',同时 renderer 包固定为 @storybook/web-components-vite:
import 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'), '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();
},
};
如果组件使用 Shadow DOM,canvas 上的普通查询无法穿透 shadow 边界,需要借助 shadow-dom-testing-library 提供 findByShadowRole、getByShadowText 等可穿透查询,并在 .storybook/preview 文件 中完成配置。
3.5 各框架适配差异速查
| 适配点 | React | Vue | Angular | Svelte | Web Components |
|---|---|---|---|---|---|
| renderer import 来源 | react-vite 等 |
vue3-vite 等 |
@storybook/angular |
sveltekit / svelte-vite |
@storybook/web-components-vite |
| 组件引用 | 命名导入 ./LoginForm |
默认导入 ./LoginForm.vue |
类导入 ./login-form.component |
默认导入 ./LoginForm.svelte |
自定义元素名 'demo-login-form' |
| 是否有专属 CSF | CSF 3 / CSF Next | CSF 3 / CSF Next | CSF 3 / CSF Next | Svelte CSF / CSF 3 | CSF 3 / CSF Next |
onSubmit spy 位置 |
args |
args |
args |
defineMeta 的 args |
args |
四、CSF Next 组合式写法:preview.meta + meta.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'), '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();
},
});
这种写法的要点是:
preview.meta({ ... })创建元数据对象,语义上等同于传统meta,但可以关联到项目级preview配置;meta.story({ ... })返回一个 story 对象,替代"具名导出 +StoryObj类型标注";- 适用于 React、Vue、Angular、Web Components 等 renderer,TS 与 JS 皆可,组件引用差异与上表一致(如 Vue 版
import LoginForm from './LoginForm.vue')。
无论采用传统 CSF 3 还是 CSF Next,"用 fn() 包住回调 arg、用 args 取出断言"这一核心手法是通用的,迁移成本极低。
五、原理纵深:这个 Spy 是如何被自动清理的
很多读者会担心一个问题:onSubmit: fn() 创建的是测试全局状态,多个 story / 多次运行之间会不会互相污染?
答案是不会。官方文档 docs/writing-tests/interaction-testing.mdx 给出了一条明确的保证:
不需要手动 restore
fn()创建的 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.mdx):
- Vitest 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
- 相关 API:docs/api/parameters.mdx(
parameters.test.restoreMocks) - 测试运行基础设施:code/addons/vitest
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00