使用 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.ts(composeStory从第 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、React+Vite 用@storybook/react-vite、Vue3 用@storybook/vue3、Next.js 用@storybook/nextjs或@storybook/nextjs-vite等。
Portable Stories 在 Vitest 中目前官方支持的渲染器为 React、Vue、Svelte(Svelte 需使用标准 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): 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}`, ); } };其余逻辑(glob 扫描、过滤、快照断言)与 JS 版本逐行一致,只是把import.meta.glob泛型化为import.meta.glob<StoryFile>(...),让后续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是 Vite(Vitest 构建层)提供的批量模块导入能力。模式./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:path'(Vite 在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 || componentName:
if (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),仅供参考