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 API(composeStories/composeStory),而不是已经弃用、不再维护的 Storyshots。Portable Stories 会把某个.stories.*文件里的所有故事连同其 args、decorators、parameters、loaders 与 play function 一起“组合”成可渲染对象。
如果整份测试文件统一调用expect(...).toMatchSnapshot()(对应官方基线片段 snapshot-tests-portable-stories.md),Jest 会把同一份测试文件里的全部快照写进一个共享快照文件(例如__snapshots__/storybook.test.js.snap)。随着组件数量增多,这个文件会不断膨胀:任何组件的一像素改动都会造成一大片 diff,多人并行开发时容易产生合并冲突,故障定位也不直观。
本关联文档所展示的正是与之相对的另一种组织方式——组件级独立快照(individual snapshots):在每条用例里显式指定快照输出路径,让每个组件名对应一个专属快照文件。这样改动影响面被限制在单个文件内,diff 更小、合并冲突概率更低、CI 失败信息更易定位。
运行前提与依赖
- Storybook 版本:Portable Stories API 自 Storybook
8.2.7起提供(其前身 API 使用.play()方法,其余一致),详见 docs/api/portable-stories/portable-stories-jest.mdx。 - 导入来源:
composeStories从你实际使用的 Storybook 框架包导出(代码中的@storybook/your-framework是占位符),React/Vue3 生态通常是@storybook/react、@storybook/vue3;Next.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.js:
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'; 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),让composeStories<StoryFile>获得完整泛型推断:
// 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 ): ReturnType<typeof composeStories<StoryFile>> => { 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 关键点逐段拆解
- 扩展 expect:
import '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}多种扩展。对每个文件剥离出storyFile(require结果)、所在目录与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): ReturnType<typeof composeStories<StoryFile>> => { 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<StoryFile>('./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注入) | toMatchFileSnapshot(Vitest 内置) |
| 用例内部差异 | 同步断言expect(...).toMatchSpecificSnapshot(...) | 需await expect(...).toMatchFileSnapshot(...) |
| Story 文件发现 | glob.sync+require | import.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()再延迟 1ms?run会触发挂载以及 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.md(Jest 版)与 portable-stories-vitest-multi-snapshot-test.md(Vitest 版)。其中用
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.sync+require,Vitest 用import.meta.glob(eager)扫描全部 story 文件; - 组合:
composeStories把每个文件的 stories 与其注解合成为可执行对象,run()完成挂载与 play 生命周期; - 落盘:
toMatchSpecificSnapshot/toMatchFileSnapshot按“每组件一份”策略把 DOM 快照写入__snapshots__/<组件名>.snap,与默认的全量共享快照文件解耦。
独立快照让回归影响面收敛到单个组件文件、让 diff 和合并冲突最小化,同时保留了 DOM 快照“捕捉非视觉变化”的全部价值。若你的目标只是让“快照内容正确”,可先从本文骨架起步;一旦要追求外观级保障,则进一步参考仓库内 docs/writing-tests/snapshot-testing.mdx 中关于视觉测试与交互测试的边界建议,为不同断言诉求选择最合适的工具。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考