首页
/ Storybook 快照测试实践:用 Portable Stories 在 Jest 与 Vitest 中复用 Button 等组件 Story

Storybook 快照测试实践:用 Portable Stories 在 Jest 与 Vitest 中复用 Button 等组件 Story

2026-09-07 12:11:56作者:龚格成

本指南围绕 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 用法分别记录于:

整体流程非常直观,共三步:

  1. composeStoriesButton.stories 的全部导出合成为可渲染组件;
  2. await Primary.run() 完成挂载并执行 story 生命周期(含 play function);
  3. 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,因此测试回调声明为 asyncawait
  • 首次运行测试时会生成并写入快照文件;再次运行时若 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 入口,便于按框架选用导入路径:

快照不一致时会发生什么

当快照比对失败时,终端输出大致如下(来自 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 中给出简化流程:

Story pipeline 流程图:先 set project annotations;再 compose story 生成可渲染元素;最后 run,挂载组件并执行全部生命周期钩子与 play function。

在 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 渲染器内置的默认注解(reactProjectAnnotationsreactArgTypesAnnotations 的组合),再调用 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」一节。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐