用 CSF Next 工厂函数故事的.run()在 Vitest 中复用 Storybook 组件测试
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
本篇技术指南聚焦 Storybook 仓库中 docs/_snippets/portable-stories-csf-factory-run.md 所演示的实践:当项目采用新一代CSF Next(CSF 工厂函数)语法编写 story 后,如何在 Vitest + Testing Library 测试文件中直接调用故事对象上的.run(),一次性完成"挂载组件 + 执行 Storybook 全生命周期钩子"的渲染与断言。读完本文,你将理解.run()与.Component两种复用方式的取舍、composed/input属性与参数覆盖规则,以及 CSF Next 下 portable stories 测试脚本与旧版composeStories流程的差异。
1. 先看清这段片段所在的位置:CSF Next 的 portable stories 测试
portable-stories-csf-factory-run.md本质是 docs/api/csf/csf-next.mdx 文档中"在测试文件里复用 story"环节使用的真实代码片段。在该文档(第 5 步)中,官方给出了新老写法的对比:
import { test, expect } from 'vitest'; import { screen } from '@testing-library/react'; - import { composeStories } from '@storybook/your-framework'; // Import all stories from the stories file import * as stories from './Button.stories'; + const { Primary } = stories; - const { Primary } = composeStories(stories); test('renders primary button with default args', async () => { // The run function will mount the component and run all of Storybook's lifecycle hooks await Primary.run(); const buttonElement = screen.getByText('Text coming from args in stories file!'); expect(buttonElement).not.toBeNull(); });这意味着在CSF Next 下,story 模块导出本身就是"开箱即用"的可组合对象,不再需要composeStories二次包装。这是理解后面所有.run()细节的前提。
使用前需要明确三个适用范围限制:
- CSF Next 目前是preview(预览)特性,API 在未来版本可能变化;
- 仅官方支持 React、Vue、Angular 与 Web Components 四个渲染器项目(见 docs/api/csf/csf-next.mdx);
- CSF Next 采用工厂函数链
defineMain → definePreview → preview.meta → meta.story,每一环都带类型推导,story 的类型(含 args)自动从组件与 meta 推断,不再需要手动给 meta/story 标注 props 类型。
2. 核心示例:在 Vitest 测试中调用.run()渲染并断言
portable-stories-csf-factory-run.md提供的完整可运行示例(React + Vitest + Testing Library)如下:
import { test, expect } from 'vitest'; import { screen } from '@testing-library/react'; // Import all stories from the stories file import * as stories from './Button.stories'; const { Primary, Secondary } = stories; test('renders primary button with default args', async () => { // The run function will mount the component and run all of Storybook's lifecycle hooks await Primary.run(); const buttonElement = screen.getByText('Text coming from args in stories file!'); expect(buttonElement).not.toBeNull(); }); test('renders primary button with overridden props', async () => { // You can override props by passing them in the context argument of the run function await Primary.run({ args: { ...Primary.composed.args, children: 'Hello world' } }); const buttonElement = screen.getByText(/Hello world/i); expect(buttonElement).not.toBeNull(); });拆解这段代码,它覆盖了三个核心点:
- 直接解构故事:
const { Primary, Secondary } = stories把 stories 文件中的具名 story 导出当作已组合好的测试单元;Secondary虽未使用,但揭示整个模块中所有 story 都具备同样能力。 - 默认参数渲染:
await Primary.run()等价于让 Storybook 用该 story 的默认 args 完整渲染一次组件,随后用screen.getByText(...)在真实 DOM 中做断言。 - 覆盖参数渲染:
await Primary.run({ args: { ...Primary.composed.args, children: 'Hello world' } })通过 run 函数的 context 参数覆盖 args。
为什么覆盖 args 时要先展开Primary.composed.args
在 code/core/src/preview-api/modules/store/csf/portable-stories.ts 中,run的实现如下:
const run = (extraContext?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => { const context = initializeContext(); Object.assign(context, extraContext); return runStory(story, context); };extraContext会通过Object.assign整体覆盖到 story context 上:如果你只写{ args: { children: 'Hello world' } },那么 context.args 会被这个仅含children的对象整体替换,story 在 meta / story 里定义的其余默认 args 都会丢失。因此示例特意用{ ...Primary.composed.args, children: 'Hello world' }先展开已合并的默认 args、再覆盖单个字段——这是一个保证"在默认参数基础上做局部覆盖"的关键细节。
3..run()底层做了什么:从类型定义到渲染管线
.run()并非测试库提供的能力,而是composed story 对象接口的一部分。仓库类型文件 code/core/src/types/modules/composedStory.ts 定义了ComposedStoryFn:
export type ComposedStoryFn< TRenderer extends Renderer = Renderer, TArgs = Args, > = PartialArgsStoryFn<TRenderer, TArgs> & { args: TArgs; id: StoryId; play?: (context?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => Promise<void>; run: (context?: Partial<StoryContext<TRenderer, Partial<TArgs>>>) => Promise<void>; load: () => Promise<void>; storyName: string; parameters: Parameters; argTypes: StrictArgTypes<TArgs>; reporting: ReporterAPI; tags: Tag[]; globals: Globals; };可见 story 对象身上同时挂载了run、play、load、args、parameters、globals、reporting等完整测试面。其中run的职责注释与实现对应关系为:
run:走完整渲染管线(runStory),挂载组件并执行 Storybook 的加载、渲染、play、清理等完整生命周期(portable-stories.ts中与片段注释一致);play:仅执行故事中定义的 play 函数体,不负责完整挂载流程;load:仅加载 loaders 产出的异步数据(若上下文尚未加载则先加载再执行)。
从执行流程看,run内部通过initializeContext()构造携带new HooksContext()的StoryContext,再把canvasElement缺省指向document.body,因此它天然与 Testing Library 的screen查询配合:run把组件渲染进真实 DOM,测试随后用screen.getByText/getByRole等断言交互与结构。
与.Component渲染方式的关系
同一个 story 对象还暴露.Component属性,便于你脱离.run()用自己的渲染方式(例如直接render(<Primary.Component />))——这正是同目录配套片段 portable-stories-csf-factory-render.md 展示的用法:
const { Primary, Secondary } = stories; test('renders primary button with default args', async () => { // Access the story's component via the .Component property render(<Primary.Component />); const buttonElement = screen.getByText('Text coming from args in stories file!'); expect(buttonElement).not.toBeNull(); }); test('renders primary button with overridden props', async () => { // You can override props by passing them directly to the story's component render(<Primary.Component>Hello world</Primary.Component>); const buttonElement = screen.getByText(/Hello world/i); expect(buttonElement).not.toBeNull(); });两者的取舍很清晰:
| 复用方式 | 触发的能力 | 适用场景 |
|---|---|---|
await Primary.run({ ... }) | 完整挂载 + Storybook 生命周期钩子(loaders、decorators、play、清理等) | 希望最大程度复刻 Storybook 内渲染行为、含 play 函数与装饰器的测试 |
render(<Primary.Component .../>) | 组件与默认 args 由 story 提供,但由你自己掌控渲染 | 想用 Testing Library 的render灵活组合,或对自定义渲染有强需求 |
官方建议:story 的args、parameters等属性统一通过.composed访问(见 docs/api/csf/csf-next.mdx),不要直接访问Story.args这类旧式字段(在 CSF Next 中已弃用)。
4.composed与input:合并值 vs 原始输入
为什么片段中要写Primary.composed.args而不是Primary.args?因为 CSF Next 为"合并语义"引入了专门命名:
composed:由story、component meta、preview(项目级)三层配置合并后的结果,是渲染与测试真正使用的最终值。属性名正是取自"由各层 compose 而来"的含义(参见 docs/api/csf/csf-next.mdx)。input:你在 story / meta 定义里直接写入的原始输入,未经合并。
这一设计的证据也体现在源码侧:工具函数getCsfFactoryAnnotations(code/core/src/preview-api/modules/store/csf/csf-factory-utils.ts)在把工厂 story 转回普通注解时会分别取:
return isStory(story) ? { story: story.input, meta: story.meta.input, preview: story.meta.preview.composed, } : { story, meta: isMeta(meta) ? meta.input : meta, preview: projectAnnotations };即:story/meta 保留原始输入,而preview(项目级)注解直接取composed合并结果。这正好解释了为什么 CSF Next 下即使不用setProjectAnnotations,story 仍"随身携带"项目级配置——story 对象通过story.meta.preview.composed把 preview 层装饰器、参数与钩子绑定在自己身上。
这也意味着.run()在测试里可以开箱即用地享受到.storybook/preview中的全局装饰器与参数(例如主题装饰器设置data-theme),而不必在测试侧再手动应用一遍。
5. 配套的 Vitest setup 文件该怎样写
要让.run()在 Vitest 中完整可用,一般还需要在 setup 文件中启动 Storybook 全局的beforeAll/afterEach生命周期。CSF Next 下的推荐写法与旧版存在明显差异(见 docs/api/csf/csf-next.mdx):
import { beforeAll } from 'vitest'; // 👇 No longer necessary - // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import { setProjectAnnotations } from '@storybook/your-framework'; - import * as addonAnnotations from 'my-addon/preview'; + import preview from './.storybook/preview'; - import * as previewAnnotations from './.storybook/preview'; // No longer necessary - const annotations = setProjectAnnotations([previewAnnotations, addonAnnotations]); // Run Storybook's beforeAll hook + beforeAll(preview.composed.beforeAll); - beforeAll(annotations.beforeAll);两个要点值得注意:
- addons 声明位置前移:在 CSF Next 中,addon 通过
definePreview({ addons: [addonA11y()] })声明(见 docs/api/csf/csf-next.mdx),其注解随preview.composed一起可用,因此 setup 里不再需要单独import * as addonAnnotations。安装 addon 时官方也建议直接使用npx storybook add <addon-name>或运行storybook dev让配置自动更新。 - 新旧混合需双 setup 文件:官方明确提示——只有全部故事都采用 CSF Next 时才适用上述简化;若测试里同时混用 CSF 1/2/3 与 CSF Next,则必须维护两套独立的 setup 文件(
portable-stories.ts的setProjectAnnotations仍会被旧格式组合路径使用)。
如果无法使用 Storybook Test(vitest addon)而要在普通测试文件中复用 story,CSF Next 的这个"免composeStories"特性把整条链路从"组合注解 → 渲染"进一步收敛成了"直接run"。这套完整方法论的更多背景可继续阅读:
- Portable stories 单元测试整体说明:docs/writing-tests/integrations/stories-in-unit-tests.mdx
- CSF Next 完整 API 与迁移指南:docs/api/csf/csf-next.mdx
.Component渲染配套片段:docs/_snippets/portable-stories-csf-factory-render.md- 组合逻辑与
run实现:code/core/src/preview-api/modules/store/csf/portable-stories.ts - composed story 类型接口:code/core/src/types/modules/composedStory.ts
6. 常见注意点小结
- 务必
await:.run()返回 Promise,内部执行含异步生命周期(loaders、play、清理),不 await 会出现断言先于渲染完成导致的偶发失败。 - args 覆盖要带展开:
run({ args })的 args 是整包替换语义,局部覆盖请始终{ ...Story.composed.args, ...你想要的字段 }。 - 读默认值看
composed:只有Story.composed.args/Story.composed.parameters才是合并 story + meta + preview 三层后的最终渲染值。 - 预览特性,注意升级:CSF Next 与
.run()目前在仓库文档中标记为 preview,API 仍可能演进;升级请参考 docs/api/csf/csf-next.mdx 中的迁移章节。 - 渲染器限制:
.run()目前仅在 React、Vue、Angular、Web Components 渲染器下获得官方支持与文档背书。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考