
使用 Vitest 与 Storybook Portable Stories 复刻 Storyshots 式组件快照测试【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook导读本文介绍如何在 Vitest 环境中借助 Storybook 的 Portable Stories API核心是composeStories把既有 Storybook 故事直接搬进快照测试通过story.run()将故事渲染进 JSDOM再对document.body.firstChild取 DOM 快照并与基线比对从而在完全兼容 Storyshots 过滤/禁用/跳过语义的前提下实现批量组件快照回归。读完本文你将掌握一份开箱即用的storybook.test.js/ts测试模板并能理解其内部的故事管线、CSF 参数过滤机制与快照误报的取舍。背景快照测试的价值与适用边界快照测试Snapshot testing的思想是把组件在某个给定状态下渲染出来对渲染结果DOM 或 HTML 字符串拍摄快照之后每次运行都与上一次的快照进行比对。它的优点在于创建成本极低——只要有渲染结果就能生成基线缺点也很明显快照中若包含过多信息维护起来会非常嘈杂。因此在 Storybook 官方指南 snapshot-testing.mdx 中明确建议对于 UI 的外观变化优先考虑视觉测试更容易人工评审对于功能行为优先考虑交互测试。但快照测试仍有两类刚需场景验证非视觉输出例如错误是否按预期被抛出、DOM 结构是否被意外改动在无法接入视觉测试基建的纯 Node 环境下做低成本回归。本文讨论的模板正是围绕以 Storybook 故事为数据源、在 Vitest 中批量生成 DOM 快照这一目标展开。与已废弃 Storyshots 的关系Storyshots 是 Storybook 历史上用于快照测试的旧方案现已被官方标注为deprecated 且不再维护见 snapshot-testing.mdx 的 Callout。官方推荐迁移到 Portable Stories API。本文给出的模板正是这种迁移的典型形态它保留了 Storyshots 的配置语义suite、正则过滤、storyshots.disable/skip参数但底层改用官方维护的 Portable Stories 管线让老用户几乎无痛切换。前置Portable Stories 与 Vitest 环境准备Portable Stories 的定义是可以被带到外部测试环境如 Vitest、Jest中直接复用的 Storybook 故事。在 Storybook 内部故事会经过一条故事管线story pipeline——收集项目级注解来自.storybook/preview.*与各 addon 的 decorator/loader、按 CSF 规则组装注解args、decorators、parameters 等、渲染组件、执行 play function——之后才呈现在界面上。当你在 Vitest 里复用时这条管线必须由你自己重建这正是composeStories/composeStory所提供的机制。详细 API 说明见 portable-stories-vitest.mdx核心实现位于 portable-stories.tscomposeStory从第 76 行起composeStories从第 275 行起。在动手前需要满足三点安装 Vitest并保证项目的测试可解析到jsdom环境快照模板首行// vitest-environment jsdom即声明该文件运行于 jsdom因为story.run()会把组件挂载进document.body。配置项目级注解若你的组件渲染依赖.storybook/preview.*中的 decorator例如主题 Provider、Router 包裹或 addon 注解需要先在 Vitest setup 文件里调用setProjectAnnotations确保composeStories组合时把它们一并带上参考 portable-stories-vitest-set-project-annotations.md。将框架包替换为实际值下文代码中所有storybook/your-framework都要替换为你项目对应的包名例如 React 用storybook/react、ReactVite 用storybook/react-vite、Vue3 用storybook/vue3、Next.js 用storybook/nextjs或storybook/nextjs-vite等。Portable Stories 在 Vitest 中目前官方支持的渲染器为 React、Vue、SvelteSvelte 需使用标准 CSF 而非 Svelte CSF其他渲染器请以 portable-stories-vitest.mdx 的标注为准。核心模板用composeStories实现 Storyshots 式批量快照本模板的完整出处为 portable-stories-vitest-snapshot-test.md以下 JavaScript 版本即该模板的主体// vitest-environment jsdom import { describe, expect, test } from vitest; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import { composeStories } from storybook/your-framework; const compose (entry) { try { return composeStories(entry); } catch (e) { throw new Error( There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}, ); } }; function getAllStoryFiles() { // Place the glob you want to match your story files const storyFiles Object.entries( import.meta.glob(./stories/**/*.(stories|story).(js|jsx|mjs|ts|tsx), { eager: true, }), ); return storyFiles.map(([filePath, storyFile]) { const storyDir path.dirname(filePath); const componentName path.basename(filePath).replace(/\.(stories|story)\.[^/.]$/, ); return { filePath, storyFile, componentName, storyDir }; }); } // Recreate similar options to Storyshots. Place your configuration below const options { suite: Storybook Tests, storyKindRegex: /^.*?DontTest$/, storyNameRegex: /UNSET/, snapshotsDirName: __snapshots__, snapshotExtension: .storyshot, }; describe(options.suite, () { getAllStoryFiles().forEach(({ storyFile, componentName, storyDir }) { const meta storyFile.default; const title meta.title || componentName; if (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) { // Skip component tests if they are disabled return; } describe(title, () { const stories Object.entries(compose(storyFile)) .map(([name, story]) ({ name, story })) .filter(({ name, story }) { // Implements a filtering mechanism to avoid running stories that are disabled via parameters or that match a specific regex mirroring the default behavior of Storyshots. return !options.storyNameRegex?.test(name) !story.parameters.storyshots?.disable; }); if (stories.length 0) { throw new Error( No stories found for this module: ${title}. Make sure there is at least one valid story for this module, without a disable parameter, or add parameters.storyshots.disable in the default export of this file., ); } stories.forEach(({ name, story }) { // Instead of not running the test, you can create logic to skip it, flagging it accordingly in the test results. const testFn story.parameters.storyshots?.skip ? test.skip : test; testFn(name, async () { await story.run(); // Ensures a consistent snapshot by waiting for the component to render by adding a delay of 1 ms before taking the snapshot. await new Promise((resolve) setTimeout(resolve, 1)); expect(document.body.firstChild).toMatchSnapshot(); }); }); }); }); });原文档同时提供了完全等价、附带StoryFile类型约束的 TypeScript 版本portable-stories-vitest-snapshot-test.md 后半部分。其类型核心如下用Meta与StoryFn声明 CSF 文件的形状{ default: Meta; [name: string]: StoryFn | Meta }并据此约束composeStories的入参与返回值type StoryFile { default: Meta; [name: string]: StoryFn | Meta; }; const compose (entry: StoryFile): ReturnTypetypeof composeStoriesStoryFile { try { return composeStories(entry); } catch (e) { throw new Error( There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}, ); } };其余逻辑glob 扫描、过滤、快照断言与 JS 版本逐行一致只是把import.meta.glob泛型化为import.meta.globStoryFile(...)让后续storyFile.default、story.parameters的访问获得完整类型推导。建议 TypeScript 项目直接采用 TS 版本。下面拆解这个模板的五个关键环节。1. 兜底 compose把失败变成可读错误const compose (entry) { try { return composeStories(entry); } catch (e) { throw new Error( There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}, ); } };composeStories的作用是把 CSF 文件导出的全部故事逐一与注解组合产出可渲染组件返回的每个 composed story 都携带args、argTypes、id、parameters、play、run、storyName、tags等属性详见 portable-stories-vitest.mdx。组合过程可能因为故事文件导出的对象不合法例如缺少 default export、包含了不可组合的导出而抛错。这里用 try/catch 包裹并附上模块名 原始错误让批量扫描时某个文件出问题能立刻定位到具体文件而不是抛出一串晦涩的堆栈。需要特别强调的是composeStories接收的是CSF 文件的全量导出对象即import * as stories from ./Button.stories那种形态而不是默认导出default本模板通过Object.entries(compose(storyFile))把组合结果还原成{ name, story }列表正是为了同时拿到故事名与 composed story 对象。2. 批量发现故事文件eager glob 扫描const storyFiles Object.entries( import.meta.glob(./stories/**/*.(stories|story).(js|jsx|mjs|ts|tsx), { eager: true, }), );import.meta.glob是 ViteVitest 构建层提供的批量模块导入能力。模式./stories/**/*.(stories|story).(js|jsx|mjs|ts|tsx)会匹配stories目录下所有以.stories或.story结尾、扩展名为 js/jsx/mjs/ts/tsx 的文件——请把它替换成你自己项目中故事文件实际所在的目录。eager: true表示在模块加载阶段就同步执行所有匹配模块快照测试需要一次性拿到全部故事定义。返回的Object.entries中 key 是相对文件路径value 是模块导出的命名空间对象。随后用path.dirname(filePath)取故事所在目录、用path.basename(...).replace(/\.(stories|story)\.[^/.]$/, )从文件名中剥离.stories.tsx这类后缀得到组件名为每个文件组装{ filePath, storyFile, componentName, storyDir }。补充说明模板中直接使用了path.dirname/path.basename。如果你的测试文件运行在标准 Node 环境需要自行import path from node:pathVite 在ssr/Node 环境下通常也能解析node:前缀若你的项目配置把node:path做了 alias 或已在全局注入则无需额外导入。请以你本地实际能否解析为准做最小改动。3. options对齐 Storyshots 的配置面const options { suite: Storybook Tests, // 顶层 describe 名称可在报告中快速识别 storyKindRegex: /^.*?DontTest$/, // 匹配组件标题kind的过滤正则 storyNameRegex: /UNSET/, // 匹配单个故事名的过滤正则 snapshotsDirName: __snapshots__, // 与 Storyshots 一致的快照目录名Vitest 默认即 __snapshots__ snapshotExtension: .storyshot, // 旧 Storyshots 使用的快照扩展名 };这段注释写得很直白Recreate similar options to Storyshots——这些字段并非被 Vitest 魔法识别而是模板为你预留的Storyshots 兼容配置面便于你把历史 Storyshots 配置逐项搬过来。其中suite、storyKindRegex、storyNameRegex会真正参与后续的 describe 命名与过滤逻辑snapshotsDirName、snapshotExtension是叙事性占位Vitest 默认会把快照写入测试文件旁的__snapshots__目录若你要精确控制快照文件的落盘目录与扩展名应在 Vitest 配置的resolveSnapshotPath钩子中实现。4. 双层过滤组件级 故事级模板用两个describe层级组织测试并在每一层都实现过滤完整复刻 Storyshots 的默认行为组件kind级——读取 CSF 的默认导出storyFile.default标题取meta.title || componentNameif (options.storyKindRegex.test(title) || meta.parameters?.storyshots?.disable) { // Skip component tests if they are disabled return; }即标题命中storyKindRegex或组件级parameters.storyshots.disable为真时整个组件直接跳过。故事story级——对组合结果按name过滤.filter(({ name, story }) { return !options.storyNameRegex?.test(name) !story.parameters.storyshots?.disable; });即故事名命中storyNameRegex默认是几乎匹配不到任何合法故事名的/UNSET/用于精确放行或故事级parameters.storyshots.disable为真时被剔除。空模块保护若某组件过滤后剩余故事数为 0直接抛出可操作错误提示该模块下没有任何可测故事请添加有效故事、去掉 disable 参数或在文件默认导出上加parameters.storyshots.disable。这能避免团队误删故事后测试在静默中失去覆盖。5. skip 语义与快照断言const testFn story.parameters.storyshots?.skip ? test.skip : test; testFn(name, async () { await story.run(); await new Promise((resolve) setTimeout(resolve, 1)); expect(document.body.firstChild).toMatchSnapshot(); });story.parameters.storyshots?.skip为真时改用test.skip让该故事在测试报告中明确显示为跳过而不是被过滤到仿佛不存在——模板注释也提示你可以根据团队约定改造成其他标记方式。await story.run()是真正的重头戏run是 composed story 提供的方法语义是挂载组件并执行该故事的 play function对应 portable-stories-vitest.mdx 中故事管线的第 3 步 Run。Storybook 的 loader、beforeEach与 play 阶段都会在这一次调用中被执行若 play function 内含断言失败会直接传导为测试失败。执行后组件会被渲染进 jsdom 的document.body。setTimeout 1ms是为了让异步渲染稳定落定后再取快照保证快照内容一致、不出现竞态抖动模板注释明确说明了这一意图。expect(document.body.firstChild).toMatchSnapshot()对组件挂载产生的根 DOM 节点拍摄快照。每次运行后 Vitest 会在__snapshots__目录生成/更新基线此后只要渲染结构发生变化测试即失败并输出 diff。通过 CSF 参数精确控制哪些测试得益于模板对meta.parameters.storyshots与story.parameters.storyshots的双层检查你可以在不改测试文件的前提下直接在 CSF 中声明测试行为组件级写在 CSF 的 default export 中parameters: { storyshots: { disable: true } }—— 整个组件跳过快照测试故事级写在某个 story export 上parameters: { storyshots: { disable: true } }—— 仅该故事不测parameters: { storyshots: { skip: true } }—— 仅该故事标记为 skip。再结合meta.title与故事导出名的正则你几乎可以表达 Storyshots 时代的一切排除规则例如排除掉 Sidebar 中展示用的文档型组件、或含有不稳定随机内容的特殊故事。一个特殊用例验证错误被正确抛出document.body.firstChild快照针对的是正常渲染结果。但对期望抛错的组件快照显然不适用——这时应当直接断言run()被 reject。在 snapshot-testing.mdx 中给出了官方示例一个Button组件收到doNotUseThisItWillThrowAnError为真时会主动抛错对应故事通过args传入该 prop并用tags: [!dev, !test]让故事既不进 Storybook 侧边栏、也不被 Storybook Test 当作独立测试执行function Button(props) { if (props.doNotUseThisItWillThrowAnError) { throw new Error(I tried to tell you...); } return button {...props} /; }export const ThrowError { tags: [!dev, !test], args: { doNotUseThisItWillThrowAnError: true, }, };// vitest-environment jsdom import { expect, test } from vitest; import { composeStories } from storybook/react; import * as stories from ./Button.stories; const { ThrowError } composeStories(stories); test(Button throws error, async () { await expect(ThrowError.run()).rejects.toThrowError(I tried to tell you...); });这正是快照测试验证非视觉输出能力的典型延伸ThrowError不需要任何快照因为其唯一价值就是稳定地抛出一个可断言的消息。同理可扩展到表单非法输入触发校验错误网络请求失败路径等复杂场景。快照失配输出长什么样运行测试后若组件结构发生变化Vitest 会输出Snapshot X mismatched并给出- Expected/ Received的 DOM diff。snapshot-testing.mdx 记录了一个真实案例——某次改动仅把按钮的 padding 类从px-4改为px-3就足以让整段 class 字符串 diff 报红FAIL src/components/ui/Button.test.ts Button snapshot Error: Snapshot Button snapshot 1 mismatched - Expected Received div button - class... bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 h-9 px-4 py-2 ... class... bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 h-9 px-3 py-2 ... contenteditable="false">【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考