Storybook 快照测试实践:用 Portable Stories 在 Jest 与 Vitest 中复用 Button 等组件 Story
本指南围绕 Storybook 官方文档 docs/writing-tests/snapshot-testing.mdx 及其核心代码片段 button-snapshot-test-portable-stories.md 展开,讲解如何通过 Storybook 的 Portable Stories API,在 Jest 或 Vitest 等外部测试环境中复用现有 story 完成 DOM 快照测试。读完本文,你将掌握 composeStories + run() 的最小快照测试写法、跨 React / Vue3 / Svelte 框架的配置差异,以及该 API 背后的 story 组合与渲染管线原理,并理解快照测试与视觉测试的边界。
快照测试的价值与适用边界
快照测试(Snapshot Testing)的核心思想是:渲染组件在某个状态下的输出,记录渲染出的 DOM / HTML 快照,再在后续运行中与历史快照比对。它创建成本低,能快速捕获 DOM 结构的意外变化。
不过官方文档明确指出其维护性短板:如果快照包含的信息过多,一旦 UI 发生合理变更,快照会变得「噪声很大」、难以 Review。因此对于 UI 组件:
- 外观类断言优先使用视觉测试思路(对渲染结果截图做基线比对);
- 功能类断言优先使用交互测试(Interaction Testing);
- 快照测试仍存在价值,尤其适合一些非视觉输出场景,例如确保组件按预期抛错。
用 Portable Stories 复用 story 做快照测试
快照测试的另一个关键前提是:不在 Storybook 内部,而是在 Jest / Vitest 等独立测试环境中,直接复用你为组件书写的 stories。Storybook 为此提供 Portable Stories API:它把 story 连同其 annotations(args、decorators、parameters 等)组装(compose)起来,产出一个可在测试中渲染的元素。
该 API 的 Vitest 与 Jest 用法分别记录于:
- docs/api/portable-stories/portable-stories-vitest.mdx
- docs/api/portable-stories/portable-stories-jest.mdx
整体流程非常直观,共三步:
- 用
composeStories把Button.stories的全部导出合成为可渲染组件; await Primary.run()完成挂载并执行 story 生命周期(含 play function);expect(document.body.firstChild).toMatchSnapshot()对渲染出的 DOM 做快照比对。
最小可运行示例:Jest + React
来自原文档的 React + Jest 版本(test/Button.test.js|ts):
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
import * as stories from '../stories/Button.stories';
const { Primary } = composeStories(stories);
test('Button snapshot', async () => {
await Primary.run();
expect(document.body.firstChild).toMatchSnapshot();
});
最小可运行示例:Vitest + React
Vitest 版(同一文档片段)几乎与 Jest 版一致,区别仅在于需要显式声明 jsdom 测试环境并导入 Vitest 的断言方法:
// @vitest-environment jsdom
import { expect, test } from 'vitest';
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import { composeStories } from '@storybook/your-framework';
import * as stories from '../stories/Button.stories';
const { Primary } = composeStories(stories);
test('Button snapshot', async () => {
await Primary.run();
expect(document.body.firstChild).toMatchSnapshot();
});
要点拆解:
@vitest-environment jsdom使测试运行在带 DOM 的 jsdom 环境,document.body才可用;运行 Vite 渲染器时若缺少该指令,run()无法把组件挂载到文档。import * as stories from '../stories/Button.stories'必须导入 CSF 文件的全部导出,而不是默认导出Meta,这样composeStories才能按 story 名批量合成。Primary.run()返回 Promise,因此测试回调声明为async并await。- 首次运行测试时会生成并写入快照文件;再次运行时若 DOM 与快照不一致,测试失败并输出 diff。
跨框架复用:Vue3 与 Svelte
Portable Stories 的 Vitest 支持同样适用于 Vue3 与 Svelte 项目,只是包入口与目录命名不同。原文档提供了以下两个完整片段。
Vue3 + Vitest
// @vitest-environment jsdom
import { expect, test } from 'vitest';
import { composeStories } from '@storybook/vue3-vite';
import * as stories from '../stories/Button.stories';
const { Primary } = composeStories(stories);
test('Button snapshot', async () => {
await Primary.run();
expect(document.body.firstChild).toMatchSnapshot();
});
Svelte + Vitest
// @vitest-environment jsdom
import { expect, test } from 'vitest';
// Replace your-framework with the framework you are using, e.g. sveltekit or svelte-vite
import { composeStories } from '@storybook/your-framework';
import * as stories from '../stories/Button.stories';
const { Primary } = composeStories(stories);
test('Button snapshot', async () => {
await Primary.run();
expect(document.body.firstChild).toMatchSnapshot();
});
从仓库源码看,三个渲染器各自实现了 Portable Stories 入口,便于按框架选用导入路径:
- React:code/renderers/react/src/portable-stories.tsx
- Vue3:code/renderers/vue3/src/portable-stories.ts
- Svelte:code/renderers/svelte/src/portable-stories.ts
快照不一致时会发生什么
当快照比对失败时,终端输出大致如下(来自 docs/writing-tests/snapshot-testing.mdx 的真实示例):
FAIL src/components/ui/Button.test.ts > Button snapshot
Error: Snapshot `Button snapshot 1` mismatched
- Expected
+ Received
<div>
<button
- class="inline-flex items-center justify-center gap-2 ... bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3"
+ class="inline-flex items-center justify-center gap-2 ... bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 h-9 px-3 py-2 has-[>svg]:px-3"
data-slot="button"
>
Button
</button>
</div>
注意上面的真实案例中,变更只是 Tailwind 类名里的一个间距从 px-4 变成 px-3 —— 在这段巨长的 class 串里肉眼几乎无法定位。这正是官方文档反复强调的观点:对于「组件外观」这类断言,视觉测试(截图基线比对)远比快照测试更合适,因为它不但变更一目了然,而且检验的是用户真正看到的渲染结果,而非 DOM 上的 CSS 类名字符串。
背后原理:story pipeline 与 composeStories
一个 story 是怎么「长成」的
在 Storybook 中预览 story 时,框架会执行一条完整的 story pipeline:应用项目级注解 → 加载数据 → 渲染 → 执行交互。官方在 portable-stories-vitest.mdx 中给出简化流程:
在 Storybook 内部这条管线是自动完成的;而一旦要把 story 拿到 Jest / Vitest 等外部环境使用,就必须自己还原这条管线。Portable Stories API 恰好提供了对应的三个环节:
| 阶段 | 你需要的 API | 作用 |
|---|---|---|
| 1. 应用项目级注解 | setProjectAnnotations |
应用 .storybook/preview.* 与 addon 导出的注解 |
| 2. Compose(组装) | composeStories / composeStory |
生成携带全部注解的可渲染组件 |
| 3. Run(运行) | 合成结果的 .run() |
挂载组件并执行 loaders、beforeEach、play function 等 |
从源码看 composeStories 的实现
React 渲染器的实现位于 code/renderers/react/src/portable-stories.tsx,其中 composeStories 接收 CSF 文件全部导出与可选的 projectAnnotations,通过 storybook/preview-api 中导入的 originalComposeStories(csfExports, projectAnnotations, composeStory) 完成批量合成:
- 合成结果是一个对象:key 为 story 名(如
Primary),value 为合成后的 story。 - 每个合成后的 story 还附带丰富元数据,官方文档给出如下属性表:
| 属性 | 类型 | 说明 |
|---|---|---|
args |
Record<string, any> |
story 的 args |
argTypes |
ArgType |
story 的 argTypes |
id |
string |
story 的 id |
parameters |
Record<string, any> |
story 的 parameters |
play |
(context) => Promise<void> | undefined |
执行给定 story 的 play function |
run |
(context) => Promise<void> | undefined |
挂载组件并执行给定 story 的 play function |
storyName |
string |
story 名称 |
tags |
string[] |
story 的 tags |
快照测试片段中调用的 await Primary.run() 正是上表中的 run:它把组件挂载到测试环境的 DOM(jsdom 的 document.body),再执行该 story 在 loaders / beforeEach / play function 中准备的逻辑。这也是示例紧接着用 document.body.firstChild 取渲染结果做快照的原因——run() 完成挂载后,首个 DOM 子节点就是组件根节点。
仓库内部的测试充分印证了这一行为,例如 code/renderers/react/src/test/portable-stories.test.tsx 中大量 await SomeStory.run() 后断言渲染结果的用例,以及 expect(ThrowsError.run()).rejects.toThrowError(...) 这类错误路径断言。
单独合成单个 story:composeStory
如果只需要某一条 story,可以用 composeStory(story, componentAnnotations, projectAnnotations?, exportsName?),其中:
story(必填):要合成的 story 导出;componentAnnotations(必填):该 story 所在文件的默认导出(Meta);exportsName:通常不需要;因为composeStory只接收单个 story,无法像composeStories那样从文件导出名拿到 story 名,当你必须保证测试中 story 名唯一且无法使用composeStories时,可在此传入 story 的导出名。
别忘了先应用项目级注解
使用 Portable Stories 在 Vitest/Jest 中跑测试,默认并不会自动应用你在 .storybook/preview.* 中定义的项目级注解(decorators、globals 等)。这些注解来自三个层面:story 自身、story 所在组件、以及整个项目(.storybook/preview.* 与 addon 导出的注解)。项目级注解需要你在 setup 文件里通过 setProjectAnnotations 手动应用,并且只应调用一次。
Vitest 的 setup 文件示例(来自 portable-stories-vitest-set-project-annotations.md):
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 you're using; otherwise remove this
import * as addonAnnotations from 'my-addon/preview';
import * as previewAnnotations from './.storybook/preview';
const annotations = setProjectAnnotations([previewAnnotations, addonAnnotations]);
// Run Storybook's beforeAll hook
beforeAll(annotations.beforeAll);
注意 beforeAll(annotations.beforeAll):它会执行 Storybook 的 beforeAll 钩子(例如模拟 Date、初始化全局 mock)。如果某个 story 依赖 addon 的 decorator / loader 才能正确渲染(例如由 decorator 包上路由上下文),则必须把该 addon 的 preview 导出一并传入,见示例中的 addonAnnotations。
React 侧的实现同样印证这一点:在 portable-stories.tsx 中,setProjectAnnotations 先通过 setDefaultProjectAnnotations(INTERNAL_DEFAULT_PROJECT_ANNOTATIONS) 注入 React 渲染器内置的默认注解(reactProjectAnnotations 与 reactArgTypesAnnotations 的组合),再调用 preview-api 的原始实现把用户项目注解设为全局,这样后续所有 composeStories / composeStory 都会自动纳入这些注解。
进阶实战:用快照 / 断言验证组件按预期抛错
快照测试最有代表性的落地场景之一,是验证组件在非法输入下正确抛出异常。官方文档用三个小步骤演示了完整链路(详见 docs/writing-tests/snapshot-testing.mdx):
第 1 步:一个收到某 prop 就会抛错的 Button:
function Button(props) {
if (props.doNotUseThisItWillThrowAnError) {
throw new Error('I tried to tell you...');
}
return <button {...props} />;
}
第 2 步:通过 story 的 args 传入该 prop,并用 tags 排除该 story——'!dev' 让它在 Storybook 侧边栏中不展示,'!test' 让它不被 Storybook Test 作为普通 story 测试:
export const ThrowError = {
tags: ['!dev', '!test'],
args: {
doNotUseThisItWillThrowAnError: true,
},
};
第 3 步:在测试文件中断言 run() 被拒绝并携带指定错误信息:
// @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...');
});
这同样体现了 run() 的一个关键行为:如果 story 的 play function 中带有断言(如 expect),这些断言失败时测试本身也会失败(官方在 portable-stories-vitest.mdx 中以 Callout 明确提示)。仓库内部测试中可找到同类范式,例如 portable-stories.test.tsx 中的 expect(ThrowsError.run()).rejects.toThrowError('Error in render')。
FAQ:快照测试与视觉测试到底怎么选
官方文档给出的权威区分是:
- 视觉测试:对 story 渲染结果截图并与截图基线比对,最擅长验证「外观」,Review 直观;
- 快照测试:对DOM 或 HTML 做快照并与文本基线比对,适合验证非视觉输出、防止 DOM 结构意外漂移。
如果你的 Storybook 使用了 Vitest addon(内部底层正是 Portable Stories),项目会自动把 story 转成 Vitest 测试;对旧版 Storyshots 用户,官方明确表示 Storyshots 已废弃且不再维护,推荐迁移到 Portable Stories API。无法使用 Portable Stories 的项目,则可借助 test-runner 的快照能力,具体配置见 docs/writing-tests/snapshot-testing.mdx 中「Snapshot testing with the test-runner」一节。
延伸阅读
- 单条 story 合成快照与批量多快照示例:portable-stories-vitest-compose-story.md、portable-stories-vitest-snapshot-test.md、portable-stories-vitest-multi-snapshot-test.md(Jest 对应版本见
docs/_snippets/portable-stories-jest-*系列) - 官方 API 文档:docs/api/portable-stories/portable-stories-vitest.mdx、docs/api/portable-stories/portable-stories-jest.mdx
- 渲染器实现源码:code/renderers/react/src/portable-stories.tsx、code/renderers/vue3/src/portable-stories.ts、code/renderers/svelte/src/portable-stories.ts
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0625
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
