
在 Vitest 可移植故事测试中覆盖 GlobalsStorybook 多语言/主题隔离测试实战导读Storybook 的可移植故事Portable Stories允许把*.stories组件故事直接导入 Vitest复用装饰器、参数与 play 函数等完整故事管线进行单元测试。但当同一个组件需要针对不同 locale、主题或全局配置分别断言时直接在preview.*中注入的全局配置会成为障碍。本文讲解如何在 Vitest 测试中通过composeStory/composeStories的第三个参数覆盖 project annotations 里的globals实现同一故事的英文/西班牙语等多场景隔离测试并深入到storybook仓库底层源码剖析 globals 的合并优先级。为什么可移植故事需要手动管理全局配置在 Storybook 内部故事会自动走一遍完整的 story pipeline应用 project-level annotationspreview.*文件与 addon 导出的装饰器、参数、组合composeCSF 导出、挂载并执行play函数。当把故事搬到 Vitest 外部环境中时这一切不会自动发生需要借助 portable stories API 手动复刻管线应用项目级注解——通过setProjectAnnotations在测试 setup 文件中把.storybook/preview.*中的装饰器、全局参数、loader 应用到所有故事组合故事——通过composeStories/composeStory把 CSF 文件转成可渲染/可运行的对象运行——调用组合后故事上的run()方法触发 loader、beforeEach与play函数。默认情况下setProjectAnnotations会把你在 Storybook 实例中定义的全局配置如preview.*中的 parameters、decorators注入测试。这在为多语言组件写测试时会产生副作用你希望同一个Primary故事分别在locale: en与locale: es下渲染并断言但全局配置却把 locale 锁死成单一值。这正是Override story properties一节描述的问题——解决办法不是放弃全局注解而是在调用composeStory/composeStories时为单个测试覆盖它们。核心姿势用 composeStory 第三个参数覆盖 globalscomposeStory的类型签名见 portable-stories API 文档为( story: Story export, // 具名导出的单个故事必填 componentAnnotations: Meta, // 同文件默认导出 meta必填 projectAnnotations?: ProjectAnnotations, // 项目注解可覆盖 setProjectAnnotations 的注入 exportsName?: string ) ComposedStoryFn第三个参数projectAnnotations官方定位为便利参数日常建议优先用setProjectAnnotations做全局注入但它专门用于覆盖setProjectAnnotations已应用的注解。若故事行为随 globals如英文/西班牙语文案、明暗主题变化就可在组合单个故事时传{ globals: { locale: en } }如下方 snippet 所示。React / react-vite关联片段代码 提供的 React 版本基于storybook/your-framework根据项目实际替换为react-vite、nextjs、nextjs-vite等通过 Testing Library 渲染、Vitest 断言import { test } from vitest; import { render } from testing-library/react; import { composeStory } from storybook/react-vite; import meta, { Primary as PrimaryStory } from ./Button.stories; test(renders in English, async () { const Primary composeStory( PrimaryStory, meta, { globals: { locale: en } }, // 项目注解覆盖 locale 全局值 ); await Primary.run(); }); test(renders in Spanish, async () { const Primary composeStory(PrimaryStory, meta, { globals: { locale: es } }); await Primary.run(); });两个用例共享同一个Primary故事唯一的差异是组合时传入的 globals。await Primary.run()会完整执行故事生命周期处理 loader、挂载组件、执行play函数中的交互与断言。Svelte / svelte-viteimport { test } from vitest; import { render } from testing-library/svelte; import { composeStory } from storybook/svelte-vite; // 或 sveltekit import meta, { Primary as PrimaryStory } from ./Button.stories; test(renders in English, async () { const Primary composeStory( PrimaryStory, meta, { globals: { locale: en } }, ); await Primary.run(); }); test(renders in Spanish, async () { const Primary composeStory(PrimaryStory, meta, { globals: { locale: es } }); await Primary.run(); });Vue / vue3-viteimport { test } from vitest; import { render } from testing-library/vue; import { composeStory } from storybook/vue3-vite; import meta, { Primary as PrimaryStory } from ./Button.stories; test(renders in English, async () { const Primary composeStory( PrimaryStory, meta, { globals: { locale: en } }, ); await Primary.run(); }); test(renders in Spanish, async () { const Primary composeStory(PrimaryStory, meta, { globals: { locale: es } }); await Primary.run(); });注意 Svelte 场景下 Vue 示例直接硬编码vue3-vite而 React/Svelte 示例中的your-framework需要替换不同渲染器的composeStory均由各自渲染器包从核心实现再导出如 react renderer 实现、vue3 实现、svelte 实现。globals 覆盖的底层实现合并优先级揭秘composeStory的核心实现在 code/core/src/preview-api/modules/store/csf/portable-stories.ts。组合单个故事时源码先把通过setProjectAnnotations注册到全局的注解与本次调用传入的 projectAnnotations合并const normalizedProjectAnnotations normalizeProjectAnnotations( composeConfigs([ defaultConfig ?? globalThis.globalProjectAnnotations ?? {}, projectAnnotations ?? {}, ]) );composeConfigs对注解做深度合并因此第三参数中globals: { locale: en }会按属性覆盖掉preview.*中定义的同名 global而保留其余部分。随后构建故事上下文时按如下顺序得出最终 globals源码 第 120-126 行const globalsFromGlobalTypes getValuesFromGlobalTypes(normalizedProjectAnnotations.globalTypes); const globals { ...globalsFromGlobalTypes, // ① 由 globalTypes 声明推导的默认值 ...normalizedProjectAnnotations.initialGlobals, // ② 项目注解里的 initialGlobals含本次覆盖 ...story.storyGlobals, // ③ 故事自身定义的 storyGlobals };从源码结构看可得出三条可验证结论globalTypes 的默认值最先铺底——Button.stories的 meta 中若通过globalTypes.locale声明了en之类的默认值会成为 globals 的基础组合时传入的{ globals }与preview.*的initialGlobals处于同一合并层级——即在setProjectAnnotations之后、故事自身 globals 之前生效。这正是该方案能隔离测试指定 locale而不污染其他用例的原因每个test()内单独composeStory各自的覆盖互不影响若某故事自身带storyGlobals其优先级仍高于测试覆盖——为单测想强制某种 locale 时应避免在故事里写死同名的 storyGlobals。因此这种覆盖机制不仅适用于 locale也适用于主题切换、RTL 方向、feature flag 等一切以globalTypes/globals表达的全局开关。从覆盖 globals 到覆盖 decorators / parameters同一个第三参数不仅能覆盖 globals还能覆盖任意 project annotations。官方在 Stories in unit tests → Override story properties 中给出了更完整的示例说明覆盖的真正动机你有时希望总是用某个 locale 测试、或给某个故事单独套上特定 decorator / parameter而不是让setProjectAnnotations注入的全局配置影响那些本不该使用它的测试。需要时可将override-compose-story-test.md片段 作为对照它会展示如何把 decorator、参数一并塞进 compose 调用。这正是composeStory单故事场景文档与composeStories批量场景共同支持的测试内联覆盖心智模型全局配置默认统一、局部按需覆盖。配套前提与使用限制为了让上面的测试真正可运行需要满足几个前提它们直接决定文章中的代码片段是否有效必须先在 setup 文件调用setProjectAnnotationsportable stories 不会自动应用项目级注解。需要在 Vitest 的setupFiles里配置.storybook/preview.*的导出必要时追加 addon 的 preview 导出参考 setProjectAnnotations 文档 与配套片段portable-stories-vitest-set-project-annotations.md。不这样做即使覆盖了 globalspreview 里的 decorator/loader 也不会生效。play 函数中的断言会直接决定测试成败run()会执行故事的全部生命周期钩子与 play 函数如果 play 里包含expect断言失败即测试失败。想在断言前检查渲染结果建议按文档指引优先使用 Testing Library 的screen查询组合故事运行在单测渲染器内。渲染器支持范围目前 portable stories in Vitest 仅官方支持 React、Vue 与 Svelte 项目其中 Svelte 被标记为实验性且不兼容 Svelte CSF必须使用标准 Component Story Format。官方新推荐路径对于在 Vitest 中测试故事Storybook 目前更推荐 Vitest addon——它在底层自动使用上述 portable stories API 把故事转换为真实 Vitest 测试同时无需手写 compose 管线。若团队已经拥抱 addon 自动化流程可将其视为演进方向本文的直接 API 方案对偏好显式控制测试内容的团队仍然可用。小结覆盖 globals 是 portable stories 在外置测试环境中一行隔离多场景的利器通过在composeStory(PrimaryStory, meta, { globals })第三参数传入测试专属的 project annotations即可在不触碰preview.*、不复制故事的前提下用同一故事跑出英文、西班牙语等多组隔离断言。结合 portable-stories 核心源码 可见其合并优先级为globalTypes 默认值 → initialGlobals/projectAnnotations 覆盖 → storyGlobals这正是理解何时生效、何时会被故事自身覆盖的关键。相同手法还可推广到 decorators、parameters 等任意 project annotations 的测试级定制让故事真正成为跨工具、跨场景复用的单一事实来源。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考