在 Vitest 可移植故事测试中覆盖 Globals:Storybook 多语言/主题隔离测试实战
导读
Storybook 的可移植故事(Portable Stories)允许把*.stories组件故事直接导入 Vitest,复用装饰器、参数与 play 函数等完整故事管线进行单元测试。但当同一个组件需要针对不同 locale、主题或全局配置分别断言时,直接在preview.*中注入的全局配置会成为障碍。本文讲解如何在 Vitest 测试中通过composeStory/composeStories的第三个参数覆盖 project annotations 里的globals,实现同一故事的英文/西班牙语等多场景隔离测试,并深入到storybook仓库底层源码剖析 globals 的合并优先级。
为什么可移植故事需要手动管理"全局"配置
在 Storybook 内部,故事会自动走一遍完整的 story pipeline:应用 project-level annotations(preview.*文件与 addon 导出的装饰器、参数)、组合(compose)CSF 导出、挂载并执行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 第三个参数覆盖 globals
composeStory的类型签名(见 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-vite
import { 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-vite
import { 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 文件调用
setProjectAnnotations:portable stories 不会自动应用项目级注解。需要在 Vitest 的setupFiles里配置.storybook/preview.*的导出(必要时追加 addon 的 preview 导出),参考 setProjectAnnotations 文档 与配套片段portable-stories-vitest-set-project-annotations.md。不这样做,即使覆盖了 globals,preview 里的 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),仅供参考