ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

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/storybookStorybook 的 portable stories API 允许把*.stories.ts中的 story 直接带入 Jest、Vitest、Playwright 等外部测试环境复用。默认情况下setProjectAnnotations会把.storybook/preview.*中定义的全部全局配置注入测试但当某个用例需要只改自己这一份配置时例如强制使用特定 locale、给某条 story 挂上专属 decorator就需要在调用composeStories/composeStory时传入第三个参数来覆盖全局配置。本文以仓库中 官方示例片段 为核心完整讲解这两种覆盖写法并结合 Storybook 核心源码 portable-stories.ts 说明覆盖语义在底层是如何生效的。背景全局配置为什么会“渗入”测试在 Storybook 内部一条 story 的成型要经过三步流水线应用项目级 annotations → 组合composestory → 运行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 youre using; otherwise remove this import * as addonAnnotations from my-addon/preview; import * as previewAnnotations from ./.storybook/preview; const annotations setProjectAnnotations([previewAnnotations, addonAnnotations]); // Run Storybooks 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 normalizeProjectAnnotationsTRenderer( 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 项目级initialGlobalsglobalTypes默认值。这解释了为何“覆盖 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.mdimport { 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 annotationsentry-preview.tsx与entry-preview-argtypes.ts作为默认层并入 core 的 composeProjectAnnotationsWithCore避免 core 注解被重复应用composeStory在调用 core 实现时把globalThis.globalProjectAnnotations ?? INTERNAL_DEFAULT_PROJECT_ANNOTATIONS作为defaultConfig传入——这正是上一节“第一项配置”的实体composeStories以框架版composeStory作为composeStoryFn透传给 core 的composeStories保证整批 story 都走同一套合并逻辑。Vuerenderers/vue3、Svelterenderers/svelte以及 Next.jsframeworks/nextjs、frameworks/nextjs-vite的导出结构与此一致因此本文的覆盖写法在这些框架中通用。小结与注意事项覆盖范围分层想影响整批 story 用composeStories(stories, overrides)只想影响单条用composeStory(story, meta, overrides)。两者共享同一套composeConfigs合并语义数组是追加、对象是覆盖decorators等数组字段会与 preview 中的项拼接执行而不是替换globalTypes、parameters等对象字段按 key 覆盖globals 优先级story 级globals 项目级initialGlobalsglobalTypes默认值理解这一层展开顺序有助于排查“为什么我覆盖的 global 没生效”前提条件必须在测试 setup 中配置好 portable stories 环境setProjectAnnotationsbeforeAll(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),仅供参考
返回列表