news 2026/9/8 19:59:03

Storybook Portable Stories:在单元测试中覆盖 Story 的 decorators、globalTypes 与 parameters

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Storybook Portable Stories:在单元测试中覆盖 Story 的 decorators、globalTypes 与 parameters

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 单独应用特定的decoratorsparameters,而不影响其他测试。

此时就可以在组合函数上“追加覆盖配置”,这正是本文的主题。

场景一:用 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声明并固定全局变量(如localetheme)的可选值与默认值
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 的字段合并规则,可以得到每个字段的精确覆盖行为:

  • 对象型字段globalTypesargsargTypesinitialGlobals):通过getObjectFieldObject.assign({}, ...)合并,后出现的 key 覆盖先出现的。也就是说你在第三个参数里写的globalTypes.locale覆盖preview 中同名 key 的定义,而未提及的 key 保持原样;
  • 数组型字段decoratorsloadersbeforeEachafterEachtags等):通过getArrayField直接拼接[...prev, ...normalized]),你的 decorator 会追加在preview 的 decorators 之后执行,而不是替换它们;
  • parameters:走combineParameters做深度合并,同名 key 以覆盖侧为准;
  • 单例型字段rendermountrenderToCanvas等):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同时暴露argsparametersargTypesidstoryNametagsplayrun等属性(组合结果赋值处),因此断言时可以直接引用 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(); });

两个要点:

  1. 覆盖发生在组合时而非运行时,所以每个 test 内各自composeStory一次、得到独立的 composed story,互不串扰;
  2. 断言写在 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.tsxentry-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)的导出结构与此一致,因此本文的覆盖写法在这些框架中通用。

小结与注意事项

  1. 覆盖范围分层:想影响整批 story 用composeStories(stories, overrides);只想影响单条用composeStory(story, meta, overrides)。两者共享同一套composeConfigs合并语义;
  2. 数组是追加、对象是覆盖decorators等数组字段会与 preview 中的项拼接执行,而不是替换;globalTypesparameters等对象字段按 key 覆盖;
  3. globals 优先级:story 级globals> 项目级initialGlobals>globalTypes默认值,理解这一层展开顺序有助于排查“为什么我覆盖的 global 没生效”;
  4. 前提条件:必须在测试 setup 中配置好 portable stories 环境(setProjectAnnotations+beforeAll(annotations.beforeAll)),否则组合出的 story 不会携带.storybook/preview.*的 decorators,官方文档对此有明确的 warning 提示(见 stories-in-unit-tests.mdx);
  5. 相关文档:完整 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 19:57:57

FPGA基带与中频处理实战:从DDC到同步环路的工程实现

1. 基带与中频处理&#xff1a;为什么FPGA是绕不开的一块硬骨头做通信、雷达、电子对抗这一类系统&#xff0c;如果你绕开基带和中频去谈FPGA&#xff0c;那基本就等于绕开了FPGA最核心的价值。我接触FPGA开发十年出头&#xff0c;从最开始的SPI控制器、接口逻辑&#xff0c;一…

作者头像 李华
网站建设 2026/9/8 19:54:11

降AI率工具哪个好用?亲测2026年8款免费降AI率工具

自己辛苦码字大半个月完成的稿件&#xff0c;被系统判定含有机器生成痕迹&#xff0c;这种感觉真的很无奈。为了找到靠谱的免费降ai率工具&#xff0c;我把市面上的主流产品逐一测试了一遍。 这段时间我经历过字数大幅增加的情况&#xff0c;也遇到了排版完全错乱的麻烦。今天就…

作者头像 李华
网站建设 2026/9/8 19:52:40

深度学习恶意软件检测实战:基于CNN的源码与数据集解析

简介&#xff1a;面向深度学习与信息安全交叉领域学习者&#xff0c;这份恶意软件检测项目提供完整源码与配套数据&#xff0c;可满足课程设计、毕业设计或入门基准项目的需要。压缩包内含163个文件&#xff0c;共34.66MB&#xff0c;其中18个Python脚本负责模型训练、预测与评…

作者头像 李华
网站建设 2026/9/8 19:51:32

红包抽奖小程序开发实战:源码+后台+支付对接全解析

简介&#xff1a;资源为红包抽奖微信小程序完整源码&#xff0c;含前台小程序端与后台管理部分&#xff0c;面向微信小程序开发者、前端学习者和产品运营人员&#xff0c;适用于节日红包营销、粉丝互动抽奖、商家促活等真实场景。资源包共收录23个文件&#xff0c;主要由js、wx…

作者头像 李华