首页
/ Storybook 交互测试进阶:用 play 函数中的 `mount` 在渲染前注入 Mock 数据与组件参数

Storybook 交互测试进阶:用 play 函数中的 `mount` 在渲染前注入 Mock 数据与组件参数

2026-09-07 12:53:01作者:范垣楠Rhoda

本文基于 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 } },
  },
};

这段代码完成了一整条链路,值得逐段拆解:

  1. db 来自自动 Mock 模块:注释标明该模块解析到 ../lib/__mocks__/db,即组件依赖的数据库访问层被 Mock 文件替换,因此 db.note.create(...) 不会真的写库,而是返回一条测试数据;
  2. 先造数据、后挂载note 创建完成后,才调用 await mount(...)note.id 是运行时才知道的值;
  3. args 展开进组件<Page {...args} params={{ id: String(note.id) }} /> 保证既有 Controls 面板调整过的参数生效,同时用动态数据覆盖 params
  4. 返回的 canvas 仍可用于查询与交互mount 返回挂载后的可查询元素,可以继续 findByRole 等待元素出现,再配合 userEvent 模拟点击;
  5. 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 的定义方式,playargTypes 的结构完全一致。

为什么必须手动转发 argsmount(组件) 会绕过 story 的 render

官方文档在 interaction-testing.mdx 的 Callout 中明确了两条行为规则,这也是理解本例的关键:

  1. 无参调用 mount() 时,组件会按照 story 自身的 render 函数渲染——无论是 隐式默认渲染 还是 显式自定义渲染
  2. 像本例这样显式传入组件调用 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 有两个硬性前置条件(官方文档原话):

  1. 必须从 context(play 的参数对象)中解构出 mount。只有检测到解构语句,Storybook 才会推迟自动渲染,把渲染控制权交给 play 函数;
  2. 所使用的 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)还存在 MountMustBeDestructuredErrorNoStoryMountedError 等错误类型,用于在用户未解构 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__ 目录下,并导出与原模块一致的具名导出。例如 Mock lib/db,就创建 lib/__mocks__/db.ts——这正是 Page.stories.tsx 顶部注释所指的解析目标;
  • Mock 文件拥有 Vitest Mock 函数的全部能力(mockReturnValuemockImplementation 以及各类断言),因此 db.note.create(...) 在 play 中可以即调即返回可控数据。

建议的落地路径是:

  1. .storybook/preview.* 中用 sb.mock(import('../lib/db.ts'))(或对应的 mock 文件方式)注册 db 模块;
  2. 在本例的 story 中 import db from '../lib/db',让组件与 story 共享同一份 Mock 实例;
  3. play 内创建数据并 mount,得到稳定的初始 UI 后再做交互与断言。

使用注意与最佳实践

  • params 这类“运行期覆盖”的参数,配合 argTypes: { params: { control: { disable: true } } } 从 Controls 面板中禁用,能避免开发者误改一个注定会被 play 覆盖的值;若该 prop 非必填、仅在 play 场景下使用,也可以考虑在组件的 propTypes/类型中标记可选。
  • await 一个都不能少db.note.createmountfindByRoleuserEvent.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 中写出既真实又稳定的交互测试。

相关文件导航:

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