ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Storybook 独立快照测试实战:用 Portable Stories 为每个组件生成单独的 Jest / Vitest 快照文件

Storybook 独立快照测试实战:用 Portable Stories 为每个组件生成单独的 Jest / Vitest 快照文件 Storybook 独立快照测试实战用 Portable Stories 为每个组件生成单独的 Jest / Vitest 快照文件本篇技术指南聚焦于 Storybook 官方文档片段 individual-snapshot-tests-portable-stories.md 所演示的**“组件级独立快照测试”实现**。文中完整继承并逐行讲解这段在 Jest 与 Vitest 中批量复用 Storybook Stories 的测试代码说明如何用jest-specific-snapshot的toMatchSpecificSnapshot与 Vitest 的toMatchFileSnapshot让每个组件各自拥有独立命名的快照文件而非全部堆积在单一快照文件中。读完你即可把这套可直接运行的测试骨架接入自己的组件库实现“一键遍历所有 Stories、逐个组件生成独立 DOM 快照”的回归防线。快照测试为什么需要“独立快照文件”快照测试的思路是以某种状态渲染组件 → 抓取渲染后的 DOM 或 HTML → 与上一次保存的快照比对出现差异即测试失败。Storybook 官方把这种手段定位为**“验证非视觉输出、防止 DOM 意外变化”**的有效补充真正检验外观建议使用视觉测试详见 docs/writing-tests/snapshot-testing.mdx。要在 Jest/Vitest 等测试环境里复用 Stories官方推荐的是Portable Stories APIcomposeStories/composeStory而不是已经弃用、不再维护的 Storyshots。Portable Stories 会把某个.stories.*文件里的所有故事连同其 args、decorators、parameters、loaders 与 play function 一起“组合”成可渲染对象。如果整份测试文件统一调用expect(...).toMatchSnapshot()对应官方基线片段 snapshot-tests-portable-stories.mdJest 会把同一份测试文件里的全部快照写进一个共享快照文件例如__snapshots__/storybook.test.js.snap。随着组件数量增多这个文件会不断膨胀任何组件的一像素改动都会造成一大片 diff多人并行开发时容易产生合并冲突故障定位也不直观。本关联文档所展示的正是与之相对的另一种组织方式——组件级独立快照individual snapshots在每条用例里显式指定快照输出路径让每个组件名对应一个专属快照文件。这样改动影响面被限制在单个文件内diff 更小、合并冲突概率更低、CI 失败信息更易定位。运行前提与依赖Storybook 版本Portable Stories API 自 Storybook8.2.7起提供其前身 API 使用.play()方法其余一致详见 docs/api/portable-stories/portable-stories-jest.mdx。导入来源composeStories从你实际使用的 Storybook 框架包导出代码中的storybook/your-framework是占位符React/Vue3 生态通常是storybook/react、storybook/vue3Next.js 集成框架则是storybook/nextjs参考 docs/writing-tests/snapshot-testing.mdx 中的import { composeStories } from storybook/react。Jest 方案依赖jest、jest/globals、glob以及用于扩展expect的jest-specific-snapshot项目需以 jsdom 作为测试环境因为断言目标是document.body.firstChild。Vitest 方案依赖vitest自带 DOM 外的快照 API测试文件首行// vitest-environment jsdom声明 jsdom 运行环境文件收集改用 Vite 的import.meta.glob无需glob包。目录假设Stories 位于stories/**形如*.stories.js|jsx|mjs|ts|tsx或*.story.*快照按代码约定输出到./__snapshots__/目录。Jest 实现借助 jest-specific-snapshot 自定义快照路径先看 JavaScript 版本测试文件命名为storybook.test.jsimport path from path; import * as glob from glob; // Augment expect with jest-specific-snapshot import jest-specific-snapshot; import { describe, test, expect } from jest/globals; // 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 stories files const storyFiles glob.sync( path.join(process.cwd(), stories/**/*.{stories,story}.{js,jsx,mjs,ts,tsx}), ); return storyFiles.map((filePath) { const storyFile require(filePath); const storyDir path.dirname(filePath); const componentName path.basename(filePath).replace(/\.(stories|story)\.[^/.]$/, ); return { filePath, storyFile, storyDir, componentName }; }); } describe(Stories Snapshots, () { getAllStoryFiles().forEach(({ storyFile, componentName }) { const meta storyFile.default; const title meta.title || componentName; describe(title, () { const stories Object.entries(compose(storyFile)).map(([name, story]) ({ name, story })); 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., ); } stories.forEach(({ name, story }) { test(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)); // Defines the custom snapshot path location and file name const customSnapshotPath ./__snapshots__/${componentName}.test.js.snap; expect(document.body.firstChild).toMatchSpecificSnapshot(customSnapshotPath); }); }); }); }); });TypeScript 版本唯一的差异是把componentName的命名.test.ts.snap与模块结构补上类型注解StoryFile类型default 导出为Meta其余具名导出为StoryFn | Meta让composeStoriesStoryFile获得完整泛型推断// Replace your-framework with one of the supported Storybook frameworks (react, vue3) import type { Meta, StoryFn } from storybook/your-framework; import path from path; import * as glob from glob; // Augment expect with jest-specific-snapshot import jest-specific-snapshot; import { describe, test, expect } from jest/globals; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import { composeStories } from storybook/your-framework; 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} ); } }; function getAllStoryFiles() { // Place the glob you want to match your stories files const storyFiles glob.sync( path.join(process.cwd(), stories/**/*.{stories,story}.{js,jsx,mjs,ts,tsx}), ); return storyFiles.map((filePath) { const storyFile require(filePath); const storyDir path.dirname(filePath); const componentName path .basename(filePath) .replace(/\.(stories|story)\.[^/.]$/, ); return { filePath, storyFile, storyDir, componentName }; }); } describe(Stories Snapshots, () { getAllStoryFiles().forEach(({ storyFile, componentName }) { const meta storyFile.default; const title meta.title || componentName; describe(title, () { const stories Object.entries(compose(storyFile)).map( ([name, story]) ({ name, story }) ); 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. ); } stories.forEach(({ name, story }) { test(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)); // Defines the custom snapshot path location and file name const customSnapshotPath ./__snapshots__/${componentName}.test.ts.snap; expect(document.body.firstChild).toMatchSpecificSnapshot(customSnapshotPath); }); }); }); });Jest 关键点逐段拆解扩展 expectimport jest-specific-snapshot为 Jest 的expect注入toMatchSpecificSnapshot(snapshotPath)。与内置toMatchSnapshot把快照写入与测试文件同名的单一.snap文件不同它允许每条用例自行指定快照写入位置。这正是本方案的基石。统一入口compose对每个 story 模块调用composeStories并用 try/catch 包装——组合失败时抛出携带模块内容JSON.stringify(entry)的明确错误便于在大批量遍历中快速定位坏掉的 story 文件。文件发现getAllStoryFiles()glob.sync以process.cwd()为基准递归匹配stories目录文件名支持.{stories,story}双词形与.{js,jsx,mjs,ts,tsx}多种扩展。对每个文件剥离出storyFilerequire结果、所在目录与componentName把Button.stories.tsx这类名字还原为Button。测试组织外层describe(Stories Snapshots)统一归属内层用meta.title || componentName作为组件维度标题。若一个模块组合后故事数为 0直接抛错防止“悄悄漏测”的假绿。渲染与等待await story.run()是 Portable Stories 组合故事的核心入口——它会挂载组件并依次执行故事生命周期钩子与 play function详见 API 文档中对run的定义与 docs/writing-tests/snapshot-testing.mdx 中的用法。随后setTimeout(1)是为了保证拿到的是渲染稳定后的 DOM快照内容前后一致。独立快照写入const customSnapshotPath \./snapshots/${componentName}.test.js.snap;把快照定位到snapshots/组件名.test.js.snap断言目标为document.body.firstChild组件渲染挂载到的 DOM 根节点。快照文件的命名规律是“每组件一份”因此Button的全部故事无论多少条都沉淀在同一份Button 专属快照文件里与其它组件完全隔离。Vitest 实现用 toMatchFileSnapshot 定点落盘Vitest 生态无需额外依赖jest-specific-snapshot它自带toMatchFileSnapshot(filePath)可把快照写到指定路径。文件收集也换成 Vite 的import.meta.glob(..., { eager: true })天然适配 ESM 与 Vite 项目。JavaScript 版本storybook.test.js// vitest-environment jsdom import path from path; 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 (error) { throw new Error( There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${error}, ); } }; 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 }; }); } describe(Stories Snapshots, () { getAllStoryFiles().forEach(({ storyFile, componentName }) { const meta storyFile.default; const title meta.title || componentName; describe(title, () { const stories Object.entries(compose(storyFile)).map(([name, story]) ({ name, story })); 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., ); } stories.forEach(({ name, story }) { test(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)); // Defines the custom snapshot path location and file name const customSnapshotPath ./__snapshots__/${componentName}.spec.js.snap; await expect(document.body.firstChild).toMatchFileSnapshot(customSnapshotPath); }); }); }); }); });TypeScript 版本为import.meta.glob补上StoryFile泛型并把快照扩展名换成.spec.ts.snap// vitest-environment jsdom // Replace your-framework with one of the supported Storybook frameworks (react, vue3) import type { Meta, StoryFn } from storybook/your-framework; import path from path; 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; 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}, ); } }; function getAllStoryFiles() { // Place the glob you want to match your story files const storyFiles Object.entries( import.meta.globStoryFile(./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 }; }); } describe(Stories Snapshots, () { getAllStoryFiles().forEach(({ storyFile, componentName }) { const meta storyFile.default; const title meta.title || componentName; describe(title, () { const stories Object.entries(compose(storyFile)).map(([name, story]) ({ name, story })); 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., ); } stories.forEach(({ name, story }) { test(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)); // Defines the custom snapshot path location and file name const customSnapshotPath ./__snapshots__/${componentName}.spec.ts.snap; await expect(document.body.firstChild).toMatchFileSnapshot(customSnapshotPath); }); }); }); }); });Vitest 与 Jest 方案的差异对照环节Jest 方案Vitest 方案运行时环境需在 Jest 配置中开启 jsdom文件首行// vitest-environment jsdom快照匹配器toMatchSpecificSnapshot由jest-specific-snapshot注入toMatchFileSnapshotVitest 内置用例内部差异同步断言expect(...).toMatchSpecificSnapshot(...)需await expect(...).toMatchFileSnapshot(...)Story 文件发现glob.syncrequireimport.meta.glob(..., { eager: true })Vite 静态收集天然 ESM快照文件命名__snapshots__/组件.test.js/.test.ts.snap__snapshots__/组件.spec.js/.spec.ts.snap除上述差异外两者的compose包装、空故事抛错保护、describe(title)组织、story.run()渲染、1ms 稳定性等待以及document.body.firstChild快照目标都完全一致可视为同一套“遍历脚本”在两个测试运行器上的等价移植。行为细节与进阶推导为什么先run()再延迟 1msrun会触发挂载以及 play function / loaders 等异步钩子见 docs/api/portable-stories/portable-stories-vitest.mdx 对故事管线的说明。代码注释明确写道1ms 延迟是为了等待组件渲染完成保证每次快照内容一致。若你的组件在挂载后还有更明显的异步副作用请求、动画、状态更新可在run()后自行增加更充分的等待或使用稳定的 mock 数据源。断言目标是根节点还是整体document.body.firstChild只针对组件被挂载后的第一个 DOM 子节点做快照避免把测试框架或 Storybook 运行时注入的额外 DOM 元素计入基线从而把快照噪音降到最低。快照文件与基线管理首次运行会在__snapshots__下生成快照文件之后每次运行都会与之比对。视觉上的px-4→px-3这类样式改动会导致快照 mismatch——这正是官方在 docs/writing-tests/snapshot-testing.mdx 中提醒的场景纯外观断言更适合交给视觉测试快照测试应聚焦 DOM 结构与非视觉输出如“错误是否按预期抛出”。进阶把路径抽成选项官方还提供了一份把“组件目录 快照目录 扩展名”做成配置参数的多快照变体片段——portable-stories-jest-multi-snapshot-test.mdJest 版与 portable-stories-vitest-multi-snapshot-test.mdVitest 版。其中用path.join(storyDir, options.snapshotsDirName, \${componentName}${options.snapshotExtension})动态拼装路径与本篇的硬编码./snapshots/${componentName}...snap 一脉相承。对照阅读即可明白把快照目录紧挨着每个 story 文件存放能让“快照与源码同目录、按组件就近管理”。测试环境补全提醒实际项目中 Portable Stories 还建议通过setProjectAnnotations一次性应用 preview 里的全局 decorators/parameters见 docs/api/portable-stories/portable-stories-jest.mdx。本篇骨架聚焦“批量收集 逐组件独立快照”本身若你的全局注解影响渲染需按各自运行器文档在 setup 文件中先行配置。为什么推荐直接复用 Stories 而非另写渲染代码每个 story 的 args/decorators/play function 已被composeStories完整组合并注入到run()中测试覆盖的“状态”与 Storybook 侧看到的状态天然一致不存在手写测试与组件实现脱节的问题。一旦某个 story 被改坏测试会给出“渲染结果与快照不符”的精确反馈。小结把 individual-snapshot-tests-portable-stories.md 中这四份代码接入项目即可得到一个可持续运转的 UI 回归骨架收集Jest 用glob.syncrequireVitest 用import.meta.glob(eager)扫描全部 story 文件组合composeStories把每个文件的 stories 与其注解合成为可执行对象run()完成挂载与 play 生命周期落盘toMatchSpecificSnapshot/toMatchFileSnapshot按“每组件一份”策略把 DOM 快照写入__snapshots__/组件名.snap与默认的全量共享快照文件解耦。独立快照让回归影响面收敛到单个组件文件、让 diff 和合并冲突最小化同时保留了 DOM 快照“捕捉非视觉变化”的全部价值。若你的目标只是让“快照内容正确”可先从本文骨架起步一旦要追求外观级保障则进一步参考仓库内 docs/writing-tests/snapshot-testing.mdx 中关于视觉测试与交互测试的边界建议为不同断言诉求选择最合适的工具。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表