
Storybook 单元测试实战使用 composeStories 在单个测试中组合复用多个 Stories【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook本指南聚焦 Storybook 官方文档中的multiple-stories-test.md代码示例讲解如何在 Jest/Vitest 等测试框架中通过composeStories一次性组合某个组件故事文件CSF里的多个命名导出的 Story并在单个测试文件中分别校验表单无效态与表单有效态等不同组件状态。读完本文你将掌握 portable stories可移植故事在 React、Vue 项目中的标准测试写法理解composeStories与composeStory的差异、run与play的执行语义以及如何让单元测试与 Storybook 中的 stories 始终保持同步。为什么要把多个 Stories 放进同一个测试在 Stories in unit tests 一节中Storybook 官方文档描述了这样一个普遍痛点团队会用不同工具测试 UI 的不同特性交互、可访问性、视觉、快照等而每种工具都要求你反复复刻同一个组件状态维护成本极高。Storybook 的核心解法是用*.stories.js|tsCSFComponent Story Format把组件隔离出来并把它的各种使用场景固化为命名导出的 Story。Story 本身就是标准 JavaScript 模块天然可以被 Jest、Vitest、Testing Library、Playwright 等整个 JS 生态复用。于是测试不再需要另起炉灶重写组件状态而是直接以 stories 为起点。当一个组件有多个 Story例如InvalidForm与ValidForm时最直接的做法是对每个 Story 分别composeStory写一条独立用例但当多个 Story 共享同一套查询与断言逻辑时更高效的做法是使用composeStories一次性组合全部 Story再在同一个测试文件中分别驱动它们运行单条 Story用composeStory参考 single-story-test.md多条 Story 组合进同一测试用composeStories它会处理你指定的每一个 Story包括你在 Story/Meta/preview 中定义的args与decorators。先决条件启用 portable stories 项目级注解composeStories属于 Storybook 的 portable stories 机制。官方文档明确警告在把 stories 复用到测试环境前你必须先配置测试环境使用 portable stories也就是通过setProjectAnnotations在测试 setup 文件中注册项目级注解。这是因为 Storybook 正常渲染 story 时会自动完成套用项目注解 → 组装 story → 渲染并执行 play的完整 story pipeline而在外部测试环境中该 pipeline 不会自动发生必须由你显式触发。// 项目级注解示例.storybook/preview.* 中的 decorators/parameters // 以及 addon 导出的注解都要通过 setProjectAnnotations 注入到测试环境从源码看portable-stories.ts 中setProjectAnnotations会把传入的注解模块composeProjectAnnotationsWithCore后存入globalThis.globalProjectAnnotations后续composeStories/composeStory在组合时都会把这份全局注解纳入 pipeline保证测试环境渲染出的组件 Storybook 里渲染出的组件。核心示例React 项目中用 composeStories 测试多个表单状态文档 multiple-stories-test.md 给出的 React 示例基于一个登录表单组件LoginForm的两个 StoryInvalidForm无效表单与ValidForm有效表单。测试逻辑是加载 Story 渲染组件 → 模拟用户点击 Submit → 断言invalid-form标记是否出现在文档中。JavaScriptJSX版本import { screen } from testing-library/react; import userEvent from testing-library/user-event; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import { composeStories } from storybook/your-framework; import * as FormStories from ./LoginForm.stories; const { InvalidForm, ValidForm } composeStories(FormStories); test(Tests invalid form state, async () { const user userEvent.setup(); await InvalidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).toBeInTheDocument(); }); test(Tests filled form, async () { const user userEvent.setup(); await ValidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).not.toBeInTheDocument(); });TypeScriptTSX版本与 JSX 版本在逻辑上完全一致仅多了类型标注与import的 TS 语义import { screen } from testing-library/react; import userEvent from testing-library/user-event; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import { composeStories } from storybook/your-framework; import * as FormStories from ./LoginForm.stories; const { InvalidForm, ValidForm } composeStories(FormStories); test(Tests invalid form state, async () { const user userEvent.setup(); await InvalidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).toBeInTheDocument(); }); test(Tests filled form, async () { const user userEvent.setup(); await ValidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).not.toBeInTheDocument(); });关键点拆解逐行拆解这段测试可以提炼出四个可复用要点整文件导入而非默认导出import * as FormStories from ./LoginForm.stories。composeStories要求传入 CSF 文件的全部导出而非仅 default export因为只有这样它才能知道要组合哪些 Story解构出被组合的 Storyconst { InvalidForm, ValidForm } composeStories(FormStories)。返回值是一个以 Story 名为键的对象值是对应的已组合 story 函数用run()而非render()驱动await InvalidForm.run()。run负责挂载组件并执行该 story 的全部生命周期loaders、beforeEach、play 函数等userEvent.setup()返回的user对象随后用于模拟点击在测试中用 Testing Library 的screen查询因为组合出的 story 就运行在单元测试渲染器中DOM 直接暴露给测试进程。这也解释了为什么用例里使用screen.getByRole(button, ...)、screen.getByLabelText(invalid-form)。两条用例的一正一反断言注意两条用例的断言方向是互补的InvalidForm.run()之后点击 Submitinvalid-form标签应当存在toBeInTheDocument()ValidForm.run()之后点击 Submitinvalid-form标签不应存在not.toBeInTheDocument()。这正体现了组合测试的价值同一个组件、同一套查询与交互代码通过切换不同 Story 的 args 即可覆盖一组对偶的状态路径无需为每种状态重写渲染与点击逻辑。toBeInTheDocument等断言匹配器来自testing-library/jest-dom需要先行安装并扩展。Vue 项目中的对应写法同样的组合策略也适用于 Vue。差异仅在于两点composeStories从storybook/vue3-vite导入Testing Library 的查询 API 换成了testing-library/vue。下面给出文档中的 JavaScript 与 TypeScript 两种完整版本。JavaScript 版本import { screen } from testing-library/vue; import userEvent from testing-library/user-event; import { composeStories } from storybook/vue3-vite; import * as FormStories from ./LoginForm.stories; const { InvalidForm, ValidForm } composeStories(FormStories); test(Tests invalid form state, async () { const user userEvent.setup(); await InvalidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).toBeInTheDocument(); }); test(Tests filled form, async () { const user userEvent.setup(); await ValidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).not.toBeInTheDocument(); });TypeScript 版本import { screen } from testing-library/vue; import userEvent from testing-library/user-event; import { composeStories } from storybook/vue3-vite; import * as FormStories from ./LoginForm.stories; const { InvalidForm, ValidForm } composeStories(FormStories); test(Tests invalid form state, async () { const user userEvent.setup(); await InvalidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).toBeInTheDocument(); }); test(Tests filled form, async () { const user userEvent.setup(); await ValidForm.run(); const buttonElement screen.getByRole(button, { name: Submit, }); await user.click(buttonElement); const isFormValid screen.getByLabelText(invalid-form); expect(isFormValid).not.toBeInTheDocument(); });注意Svelte 项目虽然同样支持 portable stories处于 Experimental 状态但官方要求使用标准 CSF 而非 Svelte CSF而上述示例注释中的your-framework需要替换为你实际使用的框架包React 生态常见值如react-vite、nextjs、nextjs-viteVue 生态则直接使用storybook/vue3-vite。composeStories 的底层语义与源码佐证它返回什么composeStories的类型签名在 portable-stories-vitest.mdx 中定义如下( csfExports: CSF file exports, projectAnnotations?: ProjectAnnotations ) Recordstring, ComposedStoryFn返回值Recordstring, ComposedStoryFn的键为 Story 名值为组合后的 Story 函数。每个组合出的 Story 额外携带以下属性属性类型含义argsRecordstring, anyStory 的 argsargTypesArgTypeStory 的 argTypesidstringStory 的 idparametersRecordstring, anyStory 的 parametersplay(context) Promisevoid \| undefined执行给定 Story 的 play 函数run(context) Promisevoid \| undefined挂载并执行给定 Story 的 play 函数storyNamestringStory 的名称tagsstring[]Story 的 tags示例中正是通过解构出的InvalidForm.run()/ValidForm.run()来挂载并运行每个 Story 的。源码视角组合 pipeline在 portable-stories.ts 的composeStory实现中可以看到组合一个 Story 要经历归一化组件注解normalizeComponentAnnotations→ 归一化 StorynormalizeStory→ 合并全局项目注解composeConfigsnormalizeProjectAnnotations→prepareStory准备可执行上下文的完整链路。composeStories则相当于对这个流程做批量封装遍历 CSF 导出并逐个执行上述组合最终返回{ [storyName]: composedStory }的映射。示例中的InvalidForm与ValidForm之所以能开箱即用正是因为这段源码把 Story 级、Meta 级与项目级三层注解合并到了一起——你在 stories 里写的 args、在 preview 里写的 decorators都会被自动带入测试渲染。该实现的行为在 portable-stories.test.ts 中有系统的单元测试覆盖涉及组合结果、注解合并、run/play调用等多个层面可作为深入理解该 API 的补充阅读材料。关于screen与canvas的使用边界stories-in-unit-tests.mdx 特别强调了一个取舍在测试用例中使用 Testing Library 的screen查询是合适的因为组合出的 story 运行在你的单元测试渲染器里但在 story 自身的 play 函数内部应当优先使用 context 提供的canvas查询让交互始终限定在被渲染的那个 story范围内避免跨 Story 污染。组合场景的最佳实践与常见坑使用 Jest 或 Vitest示例中的test()由你使用的测试运行器提供React/Vue 两个版本与 Jest、Vitest 均兼容。选择时注意若用 Vitest官方 portable-stories-vitest.mdx 提供完整 API 说明且推荐优先使用基于该 API 构建的 Vitest addon 以获得更自动化的体验若用 Jest可参考 portable-stories-jest.mdx两种环境都必须在 setup 阶段调用setProjectAnnotations通常放在测试框架的 setupFiles 中否则组合出的 story 会缺失 preview 中定义的全局 decorators 等配置。args 不会传进测试组合出的组件不仅可被渲染还带有来自 story、meta 与全局配置合并后的属性。若在测试中想读取或覆写 args/parameters可以直接访问组合结果的这些属性。在 Vue 中给组件传入的 props 会覆盖 story args 中定义的值Svelte 除外Svelte 需要通过composeStory单独覆写。Next.js Vite 用户如果你使用 Next.js 并遇到Cannot find module sb-original/image-context之类的错误请确保在 Vite 配置中启用了storybookNextJsPluginstorybook/nextjs-vite会重新导出该插件。play 函数内含断言当 Story 的 play 函数中包含断言如expect调用时这些断言会在run()执行过程中生效一旦失败测试即失败——这可以看作把交互测试内嵌进了单元测试但也意味着你要确保被组合的 Story 的 play 逻辑在测试环境JSDOM下可以顺利跑通。保持单一事实来源这一模式最大的工程收益是同步性组件状态的变化只需在LoginForm.stories中调整所有复用这些 Story 的单元测试会自动跟随更新不再需要story 一套、测试另一套的双份维护。将多个相关 Story如一组对偶的表单状态组合进同一个测试文件、共享同一套查询断言骨架是把可维护性落到实处的推荐做法。小结围绕 multiple-stories-test.md 这段官方示例本文覆盖了它的完整代码React JS/TS 与 Vue JS/TS 四个版本、运行前置条件、逐段语义、composeStories的返回结构与源码 pipeline以及 screen/canvas、args 覆写、Next.js Vite 等边界情形。把它与 Stories in unit tests、Portable storiesVitest 以及源码 portable-stories.ts 配合阅读即可在你的项目中稳定落地一个 CSF 文件、多份组件状态、被单元测试批量复用的测试策略。【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考