Storybook Portable Stories:在单元测试中覆盖 Story 的 decorators、globalTypes 与 parameters
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
Storybook 的 portable stories API 允许把*.stories.ts中的 story 直接带入 Jest、Vitest、Playwright 等外部测试环境复用。默认情况下,setProjectAnnotations会把.storybook/preview.*中定义的全部全局配置注入测试;但当某个用例需要"只改自己这一份"配置时(例如强制使用特定 locale、给某条 story 挂上专属 decorator),就需要在调用composeStories/composeStory时传入第三个参数来覆盖全局配置。本文以仓库中 官方示例片段 为核心,完整讲解这两种覆盖写法,并结合 Storybook 核心源码 portable-stories.ts 说明覆盖语义在底层是如何生效的。
背景:全局配置为什么会“渗入”测试
在 Storybook 内部,一条 story 的成型要经过三步流水线:应用项目级 annotations → 组合(compose)story → 运行(mount + 生命周期钩子 + play function)。详见 Portable stories in Vitest 中的流程说明:
在外部测试环境中,这三步需要你手动完成,其中第一步是典型的初始化代码(来自 setProjectAnnotations 官方示例):
import { beforeAll } from 'vitest'; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import { setProjectAnnotations } from '@storybook/your-framework'; // 👇 Import the exported annotations, if any, from the addons you're using; otherwise remove this import * as addonAnnotations from 'my-addon/preview'; import * as previewAnnotations from './.storybook/preview'; const annotations = setProjectAnnotations([previewAnnotations, addonAnnotations]); // Run Storybook's beforeAll hook beforeAll(annotations.beforeAll);按 Stories in unit tests 文档的说明,setProjectAnnotations会把 Storybook 实例中已定义的全局配置(preview.*里的 parameters、decorators 等)注入到你现有的所有测试中。这通常是好事——测试与 stories 始终保持一致;但它也可能带来非预期副作用,例如:
- 你希望始终用某个 locale测试某条 story(通过
globalTypes控制); - 你希望某条 story 单独应用特定的
decorators或parameters,而不影响其他测试。
此时就可以在组合函数上“追加覆盖配置”,这正是本文的主题。
场景一:用 composeStories 覆盖整组 story 的全局配置
composeStories一次处理整个 stories 文件的全部导出,它的第二个参数接受一份ProjectAnnotations,作用于该函数组合出的所有 story。仓库官方示例(override-compose-story-test.md)如下:
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import { composeStories } from '@storybook/your-framework'; import * as stories from './LoginForm.stories'; const { ValidForm } = composeStories(stories, { decorators: [ // Decorators defined here will be added to all composed stories from this function ], globalTypes: { // Override globals for all composed stories from this function }, parameters: { // Override parameters for all composed stories from this function }, });三个可覆盖字段与它们在项目级 annotation 体系中的含义一致:
| 字段 | 覆盖范围 | 典型用途 |
|---|---|---|
decorators | 该次组合出的所有 story | 测试环境下挂 mock 路由、i18n Provider、主题 Provider 等包裹层 |
globalTypes | 该次组合出的所有 story | 声明并固定全局变量(如locale、theme)的可选值与默认值 |
parameters | 该次组合出的所有 story | 覆盖 a11y 规则、docs 展示等 addon 配置 |
注意import * as stories必须传入 CSF 文件的全部导出(而非仅默认导出),composeStories会自行取出 default export 作为 meta 并过滤掉非 story 导出——这一行为可以在源码 portable-stories.ts 中看到:它先解构出default: metaExport,再用isExportStory(exportsName, meta)逐个过滤,最后对每条 story 调用composeStoryFn(storyAnnotations, meta, globalConfig, exportsName),把第二参数透传给每一个单条 story 的组合过程。
场景二:用 composeStory 覆盖单条 story 的配置
如果只需针对某一条 story做差异化配置,使用composeStory并显式传入该 story 的 meta(默认导出)。这也是官方推荐的做法:传入 story metadata 可确保测试能准确拿到该 story 的元信息(参见 Stories in unit tests — Run tests on a single story)。官方示例同样来自 override-compose-story-test.md:
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import { composeStories } from '@storybook/your-framework'; import Meta, { ValidForm as ValidFormStory } from './LoginForm.stories'; const ValidForm = composeStory(ValidFormStory, Meta, { decorators: [ // Decorators defined here will be added to this story ], globalTypes: { // Override globals for this story }, parameters: { // Override parameters for this story }, });两个函数覆盖范围的差异可以概括为:composeStories的第二个参数影响“这一整批”,composeStory的第三个参数只影响“这一条”。两者内部都收敛到同一套合并逻辑,因此合并规则完全一致(见下一节)。
覆盖是如何生效的:源码级合并语义
上面示例的“覆盖”并非替换,而是按字段合并。核心证据在 composeStory 的实现:
const normalizedProjectAnnotations = normalizeProjectAnnotations<TRenderer>( composeConfigs([ defaultConfig ?? globalThis.globalProjectAnnotations ?? {}, projectAnnotations ?? {}, ]) );这里有一个关键顺序:composeConfigs接收一个数组,第一项是已注入的项目级全局配置(setProjectAnnotations写入的globalThis.globalProjectAnnotations,即 defaultConfig 的 fallback),第二项才是你在 compose 调用时传入的覆盖配置。后者排在数组末尾,因此按 composeConfigs 的字段合并规则,可以得到每个字段的精确覆盖行为:
- 对象型字段(
globalTypes、args、argTypes、initialGlobals):通过getObjectField→Object.assign({}, ...)合并,后出现的 key 覆盖先出现的。也就是说你在第三个参数里写的globalTypes.locale会覆盖preview 中同名 key 的定义,而未提及的 key 保持原样; - 数组型字段(
decorators、loaders、beforeEach、afterEach、tags等):通过getArrayField直接拼接([...prev, ...normalized]),你的 decorator 会追加在preview 的 decorators 之后执行,而不是替换它们; parameters:走combineParameters做深度合并,同名 key 以覆盖侧为准;- 单例型字段(
render、mount、renderToCanvas等):getSingletonField取数组中最后一个非空值,覆盖侧传入即可生效。
合并完成后,globals 的最终取值在 composeStory 内部按优先级拼装:
const globalsFromGlobalTypes = getValuesFromGlobalTypes(normalizedProjectAnnotations.globalTypes); const globals = { ...globalsFromGlobalTypes, // 1. globalTypes 声明的默认值 ...normalizedProjectAnnotations.initialGlobals, // 2. 项目级 initialGlobals ...story.storyGlobals, // 3. story 自身的 globals(最高优先级) };从源码结构看,优先级为:story 级globals> 项目级initialGlobals>globalTypes默认值。这解释了为何“覆盖 globalTypes”能改变测试中生效的全局值——它最终进入globalsFromGlobalTypes参与这层展开。
组合出的ComposedStoryFn同时暴露args、parameters、argTypes、id、storyName、tags、play、run等属性(组合结果赋值处),因此断言时可以直接引用 story 自身的值,无需在测试里重复字面量。例如 reuse-args-test.md 中的用法:
test('reuses args from composed story', () => { render(<Primary />); const buttonElement = screen.getByRole('button'); // Testing against values coming from the story itself! No need for duplication expect(buttonElement.textContent).toEqual(Primary.args.label); });覆盖 globals 的完整用例:locale 切换
文档给出的典型场景是国际化测试——同一条 story,用不同globals.locale各跑一遍(示例来自 portable-stories-vitest-override-globals.md):
import { test } from 'vitest'; import { render } from '@testing-library/react'; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import { composeStory } from '@storybook/your-framework'; import meta, { Primary as PrimaryStory } from './Button.stories'; test('renders in English', async () => { const Primary = composeStory( PrimaryStory, meta, { globals: { locale: 'en' } }, // 👈 Project annotations to override the locale ); await Primary.run(); }); test('renders in Spanish', async () => { const Primary = composeStory(PrimaryStory, meta, { globals: { locale: 'es' } }); await Primary.run(); });两个要点:
- 覆盖发生在组合时而非运行时,所以每个 test 内各自
composeStory一次、得到独立的 composed story,互不串扰; - 断言写在 play function 中时,
run()会执行 mount + play + 动画等待 +afterEach(见 runStory 的实现,测试环境下还会pauseAnimations()),因此await Primary.run()之后即可断言 DOM。
框架包的导出与调用链
示例中的@storybook/your-framework对应各 renderer 对 core 实现的薄封装。以 React 为例,renderers/react 的 portable-stories.tsx 做了三件事:
setProjectAnnotations额外调用setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS),把 React renderer 自身导出的 preview annotations(entry-preview.tsx与entry-preview-argtypes.ts)作为默认层并入 core 的 composeProjectAnnotationsWithCore,避免 core 注解被重复应用;composeStory在调用 core 实现时,把globalThis.globalProjectAnnotations ?? INTERNAL_DEFAULT_PROJECT_ANNOTATIONS作为defaultConfig传入——这正是上一节“第一项配置”的实体;composeStories以框架版composeStory作为composeStoryFn透传给 core 的composeStories,保证整批 story 都走同一套合并逻辑。
Vue(renderers/vue3)、Svelte(renderers/svelte)以及 Next.js(frameworks/nextjs、frameworks/nextjs-vite)的导出结构与此一致,因此本文的覆盖写法在这些框架中通用。
小结与注意事项
- 覆盖范围分层:想影响整批 story 用
composeStories(stories, overrides);只想影响单条用composeStory(story, meta, overrides)。两者共享同一套composeConfigs合并语义; - 数组是追加、对象是覆盖:
decorators等数组字段会与 preview 中的项拼接执行,而不是替换;globalTypes、parameters等对象字段按 key 覆盖; - globals 优先级:story 级
globals> 项目级initialGlobals>globalTypes默认值,理解这一层展开顺序有助于排查“为什么我覆盖的 global 没生效”; - 前提条件:必须在测试 setup 中配置好 portable stories 环境(
setProjectAnnotations+beforeAll(annotations.beforeAll)),否则组合出的 story 不会携带.storybook/preview.*的 decorators,官方文档对此有明确的 warning 提示(见 stories-in-unit-tests.mdx); - 相关文档:完整 API 参考见 Portable stories in Vitest(含
composeStories/composeStory/setProjectAnnotations的类型签名与返回属性表),单元/端到端复用 stories 的总入口见 Stories in unit tests。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考