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

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

2026-09-07 12:20:57作者:冯梦姬Eddie

组件交互测试的难点往往不在"渲染成功",而在"回调真的被调用了、参数是否正确"。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

这段话点出了该模式的两个关键设计:

  1. fn() 直接作为 args 中的值。例如 args: { onSubmit: fn() }。对于 LoginForm 这类受控组件,onSubmit 是父组件传入的回调;Storybook 会把这一组 args 注入组件,相当于"父组件只传了一个记录型 spy",不会去真正提交网络请求或跳转页面。
  2. 在 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-vitevue3-vitesvelte-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)metaargs 中埋 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 CSFdefineMeta + <Story> 组件)与常规 CSF 3

Svelte CSF 下,fnexpect 仍来自 storybook/testdefineMeta 来自 @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 包替换为实际框架包(例如 sveltekitsvelte-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 提供 findByShadowRolegetByShadowText 等可穿透查询,并在 .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 defineMetaargs 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.restoreMocks API

也就是说,每个 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 案例,把"监听组件回调"提炼为可复用的三步模板:

  1. 埋点import { fn } from 'storybook/test',在 meta.args(或 Svelte CSF 的 defineMeta、CSF Next 的 preview.meta)中把需要监听的回调写成 onSubmit: fn()
  2. 交互:在 play 函数中利用 canvas 查询元素、userEvent 模拟真实用户操作(注意 await);
  3. 断言:通过 args 拿到同一个 spy,用 await expect(args.onSubmit).toHaveBeenCalled()(或 toHaveBeenCalledWith(...))验证组件确实触发了回调。

这套模式同样适用于更复杂的场景——当组件依赖的模块需要在模块层被 mock 时,可参考官方针对"mock 模块 + 断言其行为"的姊妹示例;当组件内部状态变化(如本案例中"点击 Log in 后应把 submitted 置为 true")需要验证时,则可在 expect 中断言 DOM 状态。掌握 fn() 回调监听后,你的 Storybook 交互测试将不只停留在"能渲染",而是真正覆盖到"组件与外界的每一次握手"。

附:文中涉及的仓库文件

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
918
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.6 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
517
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389