用 Cypress 复用 Storybook Story:基于 iframe.html 的组件端到端测试实战指南
本篇技术指南聚焦 Storybook 官方测试体系中的「Stories in end-to-end tests」主题,以仓库内 component-cypress-test.md 代码片段为骨架,讲解如何把 CSF 中定义的 Story 直接复用到 Cypress 测试中,对运行在 Storybook 隔离 iframe 里的组件做真实 DOM 断言。读完本文,你将理解 Storybook iframe.html 预览页的加载机制、story id(如 components-login-form--example)的生成规则,并掌握一套可直接落地到登录表单等场景的 Cypress + Storybook 端到端测试写法。
背景:CSF 让 Story 天然成为 E2E 测试素材
端到端测试(E2E)要求在完整运行环境中模拟真实用户行为。Cypress 是典型的端到端测试框架,但它的用例对象可以是整站,也可以是「跑在隔离环境里的单个组件」——这正是 Storybook 的用武之地。
在 Storybook 中,一个 CSF 文件的每个命名导出(named export)即一个 Story。官方文档 stories-in-end-to-end-tests.mdx 明确指出:借助 Component Story Format(CSF),开发者可以编写模拟用户交互、验证单个组件行为的测试用例,从而在 Storybook 环境中对组件的功能、响应式与视觉表现进行跨场景验证。也就是说,同一个 LoginForm.stories.ts 里写好的 Story,既能用于 Storybook 浏览,也能被 Cypress 直接渲染并断言,实现「一次编写、多处复用」。
本文关联文档的出处
本文讲解的核心代码片段 component-cypress-test.md 是一个被文档引擎动态嵌入的 snippet 文件,它的宿主页面是官方 E2E 测试指南中的 With Cypress 小节,相关配套片段还包括:
- 前置的 Story 定义示例:login-form-with-play-function.md
- 同主题的 Playwright 对照示例:component-playwright-test.md
Cypress 测试用例逐行剖析
下面是从原文档继承的完整 Cypress 测试文件(文件落在 /cypress/integration/Login.spec.js,对应经典 Cypress 布局):
/// <reference types="cypress" />
describe('Login Form', () => {
it('Should contain valid login information', () => {
cy.visit('/iframe.html?id=components-login-form--example');
cy.get('#login-form').within(() => {
cy.log('**enter the email**');
cy.get('#email').should('have.value', 'email@provider.com');
cy.log('**enter password**');
cy.get('#password').should('have.value', 'a-random-password');
});
});
});
逐部分拆解如下。
1. 类型提示声明
/// <reference types="cypress" />
/// 三斜线指令将 Cypress 的类型声明引入该 JS 文件,让 cy 全局对象获得完整的 IntelliSense 与类型检查支持。当你的 Cypress 工程使用较新版本并默认采用 cypress/e2e 目录时,只要把该 spec 放入与 cypress 配置一致的位置并保持同样的类型声明即可。
2. 用例骨架
describe('Login Form', () => {
it('Should contain valid login information', () => {
...
});
});
外层 describe 按「登录表单」归组,it 描述断言意图是「表单应包含有效的登录信息」。测试对象不是页面而是组件,这正是 Storybook 与 Cypress 结合的典型形态。
3. 访问 Storybook 的隔离 iframe
cy.visit('/iframe.html?id=components-login-form--example');
这是整段测试的关键一行:Cypress 直接访问 Storybook 预览应用提供的 iframe.html,并通过 id 查询参数定位到某个具体的 Story。
iframe.html是 Storybook 渲染 Story 的隔离 iframe 页面(preview iframe),它不包含 Manager 侧的工具栏/面板,只承载组件本身,因此非常适合被外部测试框架直接驱动。这一机制在源码中有大量佐证,例如 Iframe.stories.tsx 中就以src="/iframe.html?id=components-loader--infinite-state"作为 iframe 入口 URL,FramesRenderer.stories.tsx 也展示了iframe.html?id=${id}的拼接形式。- URL 使用相对路径
/iframe.html,意味着 Cypress 的baseUrl应指向运行中的 Storybook 服务(开发模式默认 6006 端口,Playwright 对照片段 component-playwright-test.md 中显式使用了http://localhost:6006/iframe.html?id=...,可互相印证)。
4. Story id 是怎么来的
components-login-form--example 不是随机字符串,而是由 CSF 的 标题(title)+ Story 名称 计算得到的稳定标识。查看 Storybook 构建 story index 的核心实现 StoryIndexGenerator.ts 可以看到:
const name = input.name ?? storyNameFromExport(input.exportName);
const title = input.title ?? defaultMakeTitle();
const id = input.__id ?? toId(input.metaId ?? title, storyNameFromExport(input.exportName));
即 Story 的最终 id 由 toId(title, storyName) 生成,其中 toId 来自 storybook/internal/csf/csf-utils(同一文件在 StoryIndexGenerator.test.ts 中被大量单测覆盖)。它的规则可概括为:把 title 与 Story 名转成小写、按连字符(kebab-case)规范化后用 -- 分隔:
title: 'components/LoginForm'→ 前缀段components-login-form- 名为
Example(或导出名Example)的 Story → 后缀段example - 拼接结果 →
components-login-form--example
需要特别提醒:官方示例的 CSF 文件 login-form-with-play-function.md 中的 FilledForm 对应 story id 中的 filled-form 段。因此实测时,iframe.html?id= 后面的 id 必须与你仓库中真实 Story 的 meta 标题和导出名一致,否则 Cypress 将访问到不存在的 Story。你可以直接在浏览器打开 Storybook 对应 Story,从地址栏复制准确 id。
5. 作用域收窄与语义化日志
cy.get('#login-form').within(() => { ... });
.within() 把后续查询的作用域收窄到 #login-form 容器内部,避免与页面其他元素撞名;块内的 cy.log('**enter the email**') 会在 Cypress 命令日志里渲染成易于阅读的步骤注释,是官方文档在长流程用例中常用的可读性技巧。
6. 值断言
cy.get('#email').should('have.value', 'email@provider.com');
cy.get('#password').should('have.value', 'a-random-password');
should('have.value', ...) 断言输入框的当前值。这一步能够通过,前提是 Story 在渲染后已经带上了这些初始值——这正是下面要说的 play function 场景。
前置条件:让 Story 自带可断言的初始状态
官方文档强调:Cypress 测试跑起来时,「加载 Storybook 的隔离 iframe 并检查输入框是否与测试值匹配」。为了满足这种匹配,被访问的 Story 必须在渲染完成后进入「已填好值」的状态。
实现该状态的标准方式是在 Story 中书写 play function。参考配套片段 login-form-with-play-function.md 中的 CSF 3 写法(以 React 为例):
import { expect } from 'storybook/test';
import { LoginForm } from './LoginForm';
export default { component: LoginForm };
export const FilledForm = {
play: async ({ canvas, userEvent }) => {
// 👇 模拟与组件的交互
await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
await userEvent.click(canvas.getByRole('button'));
// 👇 断言 DOM 结构
await expect(
canvas.getByText('Everything is perfect. Your account is ready and we should probably get you started!'),
).toBeInTheDocument();
},
};
play function 是理解两种测试协同的关键概念:它是一段在 Story 渲染完成后自动执行的小代码,允许你编排组件内的交互序列(键入、点击等)。当 Cypress 通过 iframe.html?id=... 加载该 Story 时,play function 已经先一步把邮箱与密码填好,因此 Cypress 侧只需做纯读取式断言即可验证最终 DOM 状态,无需重复模拟整段交互。仓库为 React、Angular、Vue 3、Svelte、Web Components 等渲染器均提供了相同语义的多框架示例,同一 Story 定义同时服务于 Storybook 交互测试与外部 E2E 框架。
与 Playwright 版本对照
同一主题的 Playwright 示例 component-playwright-test.md 呈现了「相同思路、不同 API」的实现,适合对比阅读:
import { test, expect } from '@playwright/test';
test('Login Form inputs', async ({ page }) => {
await page.goto('http://localhost:6006/iframe.html?id=components-login-form--example');
const email = await page.inputValue('#email');
const password = await page.inputValue('#password');
await expect(email).toBe('email@provider.com');
await expect(password).toBe('a-random-password');
});
两者的共同点是:都直接访问同一份 iframe.html?id=components-login-form--example。差异体现在 API 风格上:
| 维度 | Cypress | Playwright |
|---|---|---|
| URL 访问 | cy.visit('/iframe.html?id=...')(相对路径,走 baseUrl) |
page.goto('http://localhost:6006/iframe.html?id=...') |
| 取值 | cy.get('#email').should('have.value', ...) 链式断言 |
await page.inputValue('#email') 后配合 expect 断言 |
| 作用域 | .within() 收窄选择范围 |
通过具体选择器直接定位 |
选择哪个取决于团队既有基建:Cypress 提供命令日志与时间旅行调试,Playwright 则强调跨浏览器与移动端模拟能力。无论选择哪一个,Story 定义与 iframe.html 加载模型都是完全复用的。
运行前提与适用边界
要让上述测试真正跑通,请先确认以下前提,它们都以本仓库文档内容与代码行为为准:
- Storybook 服务可用:先用
storybook dev(开发预览,默认端口 6006)启动项目;若已storybook build产出静态文件,也可用任意静态服务器托管 dist 目录,使/iframe.html可访问。 - Story id 真实存在:访问前先在浏览器打开目标 Story,确认地址栏 id(含
--分隔符)与测试中书写的一致。 - Story 状态可被观察:目标 Story 渲染后应携带确定性内容(如示例中的邮箱、密码值),通常通过 play function 或 args 提供;否则
have.value断言将无从比较。
同时要认清适用边界:这种模式适合对「运行在 Storybook 隔离环境中的组件/功能模块」做组件级 E2E 验证;若要验证跨页面路由、真实鉴权或后端联调的整体业务链路,仍需针对完整应用实例编写独立的端到端测试。它与其他测试形态是互补关系而非替代关系。
延伸阅读与配套测试体系
围绕「用 Story 驱动测试」,本仓库官方文档还提供了一组相互衔接的指南,可按需深入:
- Interaction testing(交互测试):用 play function 在浏览器中模拟用户行为;
- Accessibility testing(无障碍测试):自动化检查可访问性;
- Visual testing(视觉测试):基于像素对比检查外观;
- Snapshot testing(快照测试):捕获渲染错误与警告;
- Test coverage(覆盖率):度量代码覆盖率;
- CI(持续集成):在 CI/CD 流水线中运行测试;
- Test runner:自动化执行 Story 级测试并生成报告;
- Unit testing(单元测试):把 CSF 引入 Vitest/Jest 做单测。
小结
本文以官方 E2E 指南中的 Cypress snippet 为线索,梳理出一条完整链路:CSF 命名的 Story → Storybook 为每个 Story 生成稳定 id → 外部测试框架通过 iframe.html?id=<title>--<story> 加载隔离预览 → 依据 play function 已就绪的 DOM 状态做 Cypress 断言。掌握 story id 的生成规则(StoryIndexGenerator.ts)与 iframe.html 的加载语义(FramesRenderer.stories.tsx、Iframe.stories.tsx),你就能把 Storybook 从「组件浏览工具」无缝扩展为「组件级端到端测试基础设施」,让组件开发与验证在同一条流水线上高效闭环。
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 StartedRust0627
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