Storybook 交互测试进阶:用 play 函数中的 `mount` 在渲染前注入 Mock 数据与组件参数
本文基于 Storybook 官方交互测试文档中“渲染前创建 Mock 数据”的进阶用法展开,讲解如何在 play 函数中先构造依赖数据(例如通过自动 Mock 的数据库模块创建一条记录),再显式调用 mount 将组件连同这些运行时数据一起挂载渲染。读完本文,你将掌握 mount 带参调用与无参调用的行为差异、参数(args)必须手动转发的原因、如何配合 argTypes.control.disable 隐藏被覆盖的控件,以及在 React、Vue、Svelte、Angular 四种渲染器下的完整可运行写法。
文章的主体代码片段来自 docs/_snippets/mount-advanced.md,它在官方文档 interaction-testing.mdx 中被引用;文中将同时结合 Storybook 源码验证这些写法的底层原理。
适用场景:需要把“测试中现造的数据”传进组件
在交互测试中,play 函数会在 Story 渲染完成后执行,常规写法是:
export const Basic = {
async play({ mount }) {
// 先在渲染前做点事(例如设置 MockDate),再触发渲染
await mount();
// …后续交互与断言
},
};
当你在 play 函数中先创建了一份只存在于测试流程中的数据(比如调用 Mock 的 db.note.create(...) 得到一条新记录,其主键 id 要到运行时才会产生),又希望组件在初始渲染时就拿到这条数据时,就无法再使用默认的无参 mount()——因为默认渲染发生在 play 开始之前,数据尚未生成。此时就需要 mount 的高级用法:把一个“已经配置好数据”的组件显式传给 mount。
这种用法最典型的需求包括:把 play 中刚刚生成的 UUID、时间戳、数据库自增 ID、随机 key 等动态值作为 props 传入组件,使 Story 处于一个真实且可复现的初始状态。
核心代码:在 play 中造数据再 mount
以下来自 mount-advanced.md 的核心示例(React / CSF 3 / TS):
// Replace your-framework with the framework you are using, e.g., react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } from '@storybook/your-framework';
// 👇 Automocked module resolves to '../lib/__mocks__/db'
import db from '../lib/db';
import { Page } from './Page';
const meta = { component: Page } satisfies Meta<typeof Page>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
play: async ({ mount, args, userEvent }) => {
const note = await db.note.create({
data: { title: 'Mount inside of play' },
});
const canvas = await mount(
// 👇 把 play 函数内创建的数据传给组件
// 例如一个刚刚生成的 UUID
<Page {...args} params={{ id: String(note.id) }} />,
);
await userEvent.click(await canvas.findByRole('menuitem', { name: /login to add/i }));
},
argTypes: {
// 👇 该参数的值总会在 play 中被覆盖,因此无需在面板中手动控制
params: { control: { disable: true } },
},
};
这段代码完成了一整条链路,值得逐段拆解:
db来自自动 Mock 模块:注释标明该模块解析到../lib/__mocks__/db,即组件依赖的数据库访问层被 Mock 文件替换,因此db.note.create(...)不会真的写库,而是返回一条测试数据;- 先造数据、后挂载:
note创建完成后,才调用await mount(...);note.id是运行时才知道的值; - 把
args展开进组件:<Page {...args} params={{ id: String(note.id) }} />保证既有 Controls 面板调整过的参数生效,同时用动态数据覆盖params; - 返回的
canvas仍可用于查询与交互:mount返回挂载后的可查询元素,可以继续findByRole等待元素出现,再配合userEvent模拟点击; - 用
argTypes禁用控件:因为params.id永远被 play 内部数据覆盖,手工修改没有意义,所以通过params: { control: { disable: true } }在面板中隐藏该控件,避免误导。
前端框架的区别:直接传元素 vs 传组件 + props
由于各渲染器挂载 API 形态不同,写法略有差异:
- React / Vue / Svelte:直接以元素形式传入,例如 React 的
<Page {...args} params={{...}} />; - Angular:以“组件 + props 对象”形式传入:
mount(Page, { props: { ...args, params: { id: String(note.id) } } })。
Angular / CSF 3 / TS 的完整版本:
import type { Meta, StoryObj } from '@storybook/angular';
// 👇 Automocked module resolves to '../lib/__mocks__/db'
import db from '../lib/db';
import { Page } from './page.component';
const meta = { component: Page } satisfies Meta<typeof Page>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
play: async ({ mount, args, userEvent }) => {
const note = await db.note.create({
data: { title: 'Mount inside of play' },
});
const canvas = await mount(
Page,
// 👇 把 play 函数内创建的数据传给组件
{ props: { ...args, params: { id: String(note.id) } } },
);
await userEvent.click(await canvas.findByRole('menuitem', { name: /login to add/i }));
},
argTypes: {
// 👇 Make the params prop un-controllable, as the value is always overridden in the play function.
params: { control: { disable: true } },
},
};
CSF Next(🧪 实验性)写法
Storybook 还提供了实验性的 CSF Next 语法:通过 preview.meta({...}) 与 meta.story({...}) 组织文件,mount 的用法完全一致。React 版本:
import preview from '../.storybook/preview';
// 👇 Automocked module resolves to '../lib/__mocks__/db'
import db from '../lib/db';
import { Page } from './Page';
const meta = preview.meta({ component: Page });
export const Basic = meta.story({
play: async ({ mount, args, userEvent }) => {
const note = await db.note.create({
data: { title: 'Mount inside of play' },
});
const canvas = await mount(
// 👇 Pass data that is created inside of the play function to the component
<Page {...args} params={{ id: String(note.id) }} />,
);
await userEvent.click(await canvas.findByRole('menuitem', { name: /login to add/i }));
},
argTypes: {
params: { control: { disable: true } },
},
});
Angular 的 CSF Next 版本将 mount 的两个参数形式保留,Vue 版本同样适用(Vue 的 CSF Next 写法见原片段 mount-advanced.md,它使用 mount(Page, { props: { ...args, params: { id: String(note.id) } } }) 将动态数据传给 Vue 组件)。Svelte 的 CSF 3 使用 mount(Page, { props: { ...args, params: { id: String(note.id) } } }) 传参;上述各渲染器的 JS 变体与 TS 变体的差异仅在于 import 来源、去掉类型标注以及 meta/StoryObj 的定义方式,play 与 argTypes 的结构完全一致。
为什么必须手动转发 args:mount(组件) 会绕过 story 的 render
官方文档在 interaction-testing.mdx 的 Callout 中明确了两条行为规则,这也是理解本例的关键:
- 无参调用
mount()时,组件会按照 story 自身的 render 函数渲染——无论是 隐式默认渲染 还是 显式自定义渲染; - 像本例这样显式传入组件调用
mount时,story 的 render 函数会被忽略,取而代之的是你传入的这份元素/组件。因此必须自己把args展开/透传给组件,否则 Controls 面板中的参数将全部丢失。
这一行为可以从源码层面得到印证:
- 在 prepareStory.ts 中,默认的
defaultMount只是简单地await context.renderToCanvas()并返回context.canvas(对应规则 1); - 而各渲染器的
mount实现会改写渲染入口。以 React 渲染器为例,code/renderers/react/src/mount.ts 的实现是:
export const mount: BaseAnnotations<ReactRenderer>['mount'] =
(context: StoryContext) => async (ui) => {
if (ui != null) {
// 有传入元素时,用「返回该元素」的函数替换 story 原本的渲染函数
context.originalStoryFn = () => ui;
}
await context.renderToCanvas();
return context.canvas;
};
ui != null 即代表“显式传入了待挂载元素”——此时 originalStoryFn 被替换为返回该元素的新函数,story 自定义的 render 自然被绕过;随后调用 renderToCanvas 完成渲染并返回可查询的 canvas。这正是“必须转发 args”的底层原因。
前置要求:为什么必须从 context 解构 mount
在 play 函数中使用 mount 有两个硬性前置条件(官方文档原话):
- 必须从
context(play 的参数对象)中解构出mount。只有检测到解构语句,Storybook 才会推迟自动渲染,把渲染控制权交给 play 函数; - 所使用的 Storybook 框架或 builder 必须配置为转译到 ES2017 或更高版本。因为解构语句与
async/await若被过度转译会丢失,Storybook 将无法识别你对mount的使用。
条件 1 的原因可以直接在源码中看到。检测逻辑位于 mount-utils.ts,它通过 mountDestructured(playFunction) 解析 play 函数的源码字符串来推断是否解构使用了 mount:
export function mountDestructured(playFunction?: (...args: any[]) => any): boolean {
return playFunction != null && getUsedProps(playFunction).includes('mount');
}
getUsedProps 用正则提取函数参数与函数体,判断解构列表(含重命名、默认值,并剔除注释)中是否存在 mount。由于是文本级解析,因此若把 async/await、解构语法被 Babel 之类的工具“摊平”或改写(这正是需要 ES2017+ 的原因),检测就会失效,Storybook 也无法正确地推迟渲染、控制挂载时机。
检测结果随后在 prepareStory.ts 中被使用:当 usesMount 为真时,Storybook 便知道应由 play 函数内部决定何时调用 mount;若既没有 render 函数又不使用 mount,会抛出 NoRenderFunctionError。最终 play 函数实际使用的 mount 按“story 级 → 组件级 → 项目级 → 默认实现”的优先级解析:
const mount =
storyAnnotations.mount ??
componentAnnotations.mount ??
projectAnnotations.mount ??
defaultMount;
在运行时(StoryRender.ts)还存在 MountMustBeDestructuredError、NoStoryMountedError 等错误类型,用于在用户未解构 mount 或尚未挂载组件就尝试交互时给出明确提示。
配合模块 Mock:../lib/__mocks__/db 从哪来
代码第一行的注释 Automocked module resolves to '../lib/__mocks__/db' 属于 Storybook 的**模块自动 Mock(Automocking)**体系。其完整配置方法见 Mocking modules 指南,其要点与本例直接相关:
- 用
sb.mock在项目级.storybook/preview.*中注册需要 Mock 的模块(本地文件或node_modules包均可,本地路径需相对.storybook/preview.*并带上扩展名); - 其中一种方式是提供 Mock 文件:把与被 Mock 模块同名的文件放在其旁边的
__mocks__目录下,并导出与原模块一致的具名导出。例如 Mocklib/db,就创建lib/__mocks__/db.ts——这正是Page.stories.tsx顶部注释所指的解析目标; - Mock 文件拥有 Vitest Mock 函数的全部能力(
mockReturnValue、mockImplementation以及各类断言),因此db.note.create(...)在 play 中可以即调即返回可控数据。
建议的落地路径是:
- 在
.storybook/preview.*中用sb.mock(import('../lib/db.ts'))(或对应的 mock 文件方式)注册db模块; - 在本例的 story 中
import db from '../lib/db',让组件与 story 共享同一份 Mock 实例; - 在
play内创建数据并mount,得到稳定的初始 UI 后再做交互与断言。
使用注意与最佳实践
params这类“运行期覆盖”的参数,配合argTypes: { params: { control: { disable: true } } }从 Controls 面板中禁用,能避免开发者误改一个注定会被 play 覆盖的值;若该 prop 非必填、仅在 play 场景下使用,也可以考虑在组件的 propTypes/类型中标记可选。await一个都不能少:db.note.create、mount、findByRole、userEvent.click全部要await,这样 Interaction 面板才能正确记录步骤、便于在失败时回溯。- 等待异步元素:数据是异步创建再渲染的,DOM 元素可能不会立即出现,因此用
await canvas.findByRole(...)而不是getByRole(...),前者会轮询等待元素出现。 - 与组件级/项目级生命周期配合:如果需要让某类 mock 数据对所有 story 生效,官方更推荐在 preview 文件的
beforeAll/beforeEach中统一初始化,而不是每个 story 都重复这套流程(详见 interaction-testing.mdx);而 Storybook 会在每次渲染 story 前自动恢复fn()mock,无需手动清理。 - 仅限 React / Vue / Svelte(含 CSF Next):本进阶示例被官方文档限制在这三种渲染器中(见 interaction-testing.mdx);Angular 场景中“渲染前执行代码”的等价做法是 story 级异步
beforeEach函数,而非在play中带参调用mount(本示例中 Angular 版代码用于演示同样框架下的 API 形态差异,落地时请对照你所用框架版本的官方建议)。
小结
mount 的进阶形态解决了交互测试中最常见的一类“数据时序”问题:先构造运行时数据,再决定组件的初始渲染内容。它把 db 模块 Mock、play 函数、args 转发、控件禁用与异步查询串联成一个完整、可复现的测试叙事。理解其背后的三条机制——文本级 mount 解构检测、mount(ui) 替换 originalStoryFn 从而绕过 story render、以及 canvas 作为查询句柄继续驱动交互——将帮助你在需要动态初始状态的复杂 Story 中写出既真实又稳定的交互测试。
相关文件导航:
- 本篇主体片段:docs/_snippets/mount-advanced.md
- 基础版
mount()(渲染前设置 MockDate):docs/_snippets/mount-basic.md - 官方交互测试文档(上下文 Callout 与运行方式):docs/writing-tests/interaction-testing.mdx
- 模块 Mock 完整指南:docs/writing-stories/mocking-data-and-modules/mocking-modules.mdx
- 核心实现:mount 解析与默认实现 prepareStory.ts、文本级检测 mount-utils.ts、React 渲染器实现 code/renderers/react/src/mount.ts
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 StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00