Storybook Test Runner 视口同步实战:用 preVisit 钩子与 getStoryContext 让每个 Story 按自己的 Viewport 参数渲染
导读
在 Storybook Test Runner 的默认行为中,所有 story 都运行在 Playwright 的同一个固定视口尺寸下。如果某个 story 在 viewport 参数中声明了 defaultViewport: 'mobile1',测试时页面却仍以桌面尺寸渲染,那么依赖响应式布局的断言就会失真。本文基于 Storybook 官方文档中的配置片段(docs/_snippets/test-runner-custom-page-viewport.md),完整讲解如何通过 Test Runner 的 preVisit 生命周期钩子与 getStoryContext 辅助函数,读取每个 story 的 viewport 参数并将其转换为 Playwright 的页面视口尺寸,让组件测试真正做到"所见即所测"。
为什么需要为测试同步视口
Storybook Test Runner(docs/writing-tests/integrations/test-runner.mdx)会把你的每一个 story 变成可执行的测试:没有 play function 的 story 验证其能否无错渲染,带 play function 的 story 还会校验 play function 中的断言。它由 Jest 与 Playwright 驱动,运行在真实浏览器中。
与此同时,Storybook 的 Viewport 插件允许通过参数为每个 story 指定默认视口:
// .storybook/preview.js
export default {
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
};
问题在于:Viewport 插件控制的是 Storybook 预览 iframe 的渲染尺寸,而 Test Runner 打开页面时使用的是 Playwright 浏览器上下文的默认视口。二者互不相通,导致同一组 story 在 Storybook UI 中是移动端布局,在测试中却是桌面端布局。对于媒体查询驱动的响应式组件,play function 里针对移动端元素的交互断言就会失败。
解决思路很直接:在 story 渲染之前(preVisit 钩子),读取该 story 的 viewport 参数,找到对应的视口定义,调用 Playwright 的 page.setViewportSize 把页面调整到正确尺寸。
核心代码:JS 与 TS 两个版本
官方文档提供的配置片段(docs/_snippets/test-runner-custom-page-viewport.md)放在 Storybook 配置目录下的 .storybook/test-runner.js 中:
const { getStoryContext } = require('@storybook/test-runner');
const { MINIMAL_VIEWPORTS } = require('storybook/viewport');
const DEFAULT_VIEWPORT_SIZE = { width: 1280, height: 720 };
module.exports = {
async preVisit(page, story) {
// Accesses the story's parameters and retrieves the viewport used to render it
const context = await getStoryContext(page, story);
const viewportName = context.parameters?.viewport?.defaultViewport;
const viewportParameter = MINIMAL_VIEWPORTS[viewportName];
if (viewportParameter) {
const viewportSize = Object.entries(viewportParameter.styles).reduce(
(acc, [screen, size]) => ({
...acc,
// Converts the viewport size from percentages to numbers
[screen]: parseInt(size),
}),
{},
);
// Configures the Playwright page to use the viewport size
page.setViewportSize(viewportSize);
} else {
page.setViewportSize(DEFAULT_VIEWPORT_SIZE);
}
},
};
TypeScript 版本(.storybook/test-runner.ts)在类型上更严谨,引入了 TestRunnerConfig 类型:
import type { TestRunnerConfig } from '@storybook/test-runner';
import { getStoryContext } from '@storybook/test-runner';
import { MINIMAL_VIEWPORTS } from 'storybook/viewport';
const DEFAULT_VIEWPORT_SIZE = { width: 1280, height: 720 };
const config: TestRunnerConfig = {
async preVisit(page, story) {
// Accesses the story's parameters and retrieves the viewport used to render it
const context = await getStoryContext(page, story);
const viewportName = context.parameters?.viewport?.defaultViewport;
const viewportParameter = MINIMAL_VIEWPORTS[viewportName];
if (viewportParameter) {
const viewportSize = Object.entries(viewportParameter.styles).reduce(
(acc, [screen, size]) => ({
...acc,
// Converts the viewport size from percentages to numbers
[screen]: parseInt(size),
}),
{},
);
// Configures the Playwright page to use the viewport size
page.setViewportSize(viewportSize);
} else {
page.setViewportSize(DEFAULT_VIEWPORT_SIZE);
}
},
};
export default config;
工作原理逐行拆解
1. getStoryContext(page, story):从浏览器页面取回 story 上下文
Test Runner 导出了一些辅助函数(Helpers),用于访问 Storybook 内部信息(如 args、parameters),getStoryContext 是其中之一。它接收 Playwright 的 page 对象和当前 story 对象,返回该 story 的完整上下文对象,其中包含 parameters。
这一步是必需的:preVisit 钩子收到的第二个参数只包含 story 的 id、title、name,并不携带参数数据,必须通过 getStoryContext 从页面中取回完整的 story 上下文。
2. 读取 defaultViewport 参数
const viewportName = context.parameters?.viewport?.defaultViewport;
可选链(?.)保证了即使 story 没有配置 viewport 参数,viewportName 也只会是 undefined 而不会抛错。当 story 声明了 parameters.viewport.defaultViewport: 'mobile1' 时,这里就取到字符串 'mobile1'。
3. 从 MINIMAL_VIEWPORTS 中查找视口定义
const viewportParameter = MINIMAL_VIEWPORTS[viewportName];
MINIMAL_VIEWPORTS 是 storybook/viewport 导出的内置视口集合,结构与 Viewport 插件中自定义视口完全一致——每个条目是一个 { name, styles } 对象,styles 内含 width 与 height:
// MINIMAL_VIEWPORTS 的结构示例(与自定义视口同构)
{
mobile1: {
name: 'Small mobile',
styles: { width: '320px', height: '568px' },
},
// ...
}
这一点可以从官方文档中自定义视口的写法得到印证(docs/_snippets/addon-viewport-add-viewport-in-preview.md):自定义视口同样由 name 与 styles: { width, height } 组成,并且可以通过展开 ...MINIMAL_VIEWPORTS 与内置视口合并使用。这也意味着如果你在 .storybook/preview.js 中扩展了 viewport.options,本方案的可复用性会更高——story 中声明的 defaultViewport 越多样,测试覆盖的布局形态就越全面。
4. 将样式字符串转换为数字尺寸
const viewportSize = Object.entries(viewportParameter.styles).reduce(
(acc, [screen, size]) => ({
...acc,
[screen]: parseInt(size),
}),
{},
);
MINIMAL_VIEWPORTS 中 styles 的值是带单位的字符串(例如 '320px',也可能出现百分比形式),而 Playwright 的 page.setViewportSize 要求纯数字。Object.entries 取出 [['width', '320px'], ['height', '568px']],parseInt 把字符串转为数字(parseInt('320px') 得到 320),再通过 reduce 重新组装成 { width: 320, height: 568 }。代码注释中"将百分比转换为数字"正是指这一过程。
5. 应用视口或回退到默认值
if (viewportParameter) {
page.setViewportSize(viewportSize);
} else {
page.setViewportSize(DEFAULT_VIEWPORT_SIZE);
}
如果 story 声明了 defaultViewport 且能在 MINIMAL_VIEWPORTS 中命中,就用对应尺寸;否则回退到兜底的 DEFAULT_VIEWPORT_SIZE(1280×720,常见的桌面基准分辨率)。这个回退分支非常关键——它保证了没有配置 viewport 参数的 story 依然有一个确定、一致的视口,避免测试依赖 Playwright 的默认值(默认 1280×720,与这里的回退值一致)。
在 Story 中声明默认视口
配置好 test-runner.js 之后,story 侧无需任何改动,只要像平时使用 Viewport 插件一样声明参数即可:
// MyComponent.stories.js
export default {
title: 'Example/MyComponent',
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
};
Test Runner 遍历到该 story 时,preVisit 钩子会在页面导航后、story 渲染前执行(完整生命周期见下文),此时视口已被调整为 320×568,play function 中基于移动端布局的断言(例如可见性、位置、元素尺寸)便能准确执行。
钩子执行时机:preVisit 在生命周期中的位置
根据 docs/writing-tests/integrations/test-runner.mdx 中"Test hook API"一节的说明,Test Runner 导出四个可全局覆写的测试钩子:
| 钩子 | 说明 |
|---|---|
prepare |
为测试准备浏览器(接收 page、browserContext、testRunnerConfig) |
setup |
在所有测试运行前执行一次 |
preVisit |
在 story 被首次访问并渲染之前执行 |
postVisit |
在 story 被访问并完全渲染之后执行 |
测试执行时遵循以下顺序:
setup函数先于所有测试执行;- 生成包含所需信息的 context 对象;
- Playwright 导航到 story 页面;
preVisit函数执行(本方案的视口调整发生在此刻);- story 渲染,若有 play function 则执行;
postVisit函数执行。
值得注意的是,官方文档同时提醒:这些钩子属于实验性 API,可能发生破坏性变更,官方鼓励尽可能把断言逻辑放在 story 的 play function 内。因此视口同步这类"无法通过 play function 实现"的浏览器环境配置,正是钩子 API 的典型适用场景——因为 play function 运行在浏览器内,而 page.setViewportSize 是 Node 侧的 Playwright 操作,只能在钩子中完成。
扩展到自定义视口集合
上述方案仅查询 MINIMAL_VIEWPORTS。如果项目在 .storybook/preview.js 中通过 viewport.options 扩展了大量自定义视口(参考 docs/_snippets/addon-viewport-add-viewport-in-preview.md 中 Kindle Fire 系列的写法),可以做一个更通用的版本——把自定义视口并入查找表:
const { getStoryContext } = require('@storybook/test-runner');
const { MINIMAL_VIEWPORTS } = require('storybook/viewport');
// 与 preview.js 中定义的视口保持一致
const customViewports = {
kindleFire2: {
name: 'Kindle Fire 2',
styles: { width: '600px', height: '963px' },
},
};
const ALL_VIEWPORTS = { ...MINIMAL_VIEWPORTS, ...customViewports };
const DEFAULT_VIEWPORT_SIZE = { width: 1280, height: 720 };
const toNumericSize = (styles) =>
Object.entries(styles).reduce(
(acc, [screen, size]) => ({ ...acc, [screen]: parseInt(size) }),
{},
);
module.exports = {
async preVisit(page, story) {
const context = await getStoryContext(page, story);
const viewportName = context.parameters?.viewport?.defaultViewport;
const viewportParameter = ALL_VIEWPORTS[viewportName];
await page.setViewportSize(
viewportParameter ? toNumericSize(viewportParameter.styles) : DEFAULT_VIEWPORT_SIZE,
);
},
};
这里做了三处增强:一是合并自定义视口,二是抽出 toNumericSize 纯函数便于复用与单测,三是补上 await——page.setViewportSize 返回 Promise,官方示例虽未显式 await,但作为 preVisit 的异步操作,显式 await 能保证视口设置完成后再进入渲染阶段,行为更确定。
完整接入步骤
- 安装 Test Runner:在项目根目录执行安装命令,并在
package.json中添加脚本:
{
"scripts": {
"test-storybook": "test-storybook"
}
}
-
启动 Storybook:在终端运行开发命令启动本地 Storybook(Test Runner 需要一个本地运行或已发布的 Storybook 实例)。
-
创建配置文件:在
.storybook/目录下新建test-runner.js(或test-runner.ts),粘贴上文代码。 -
运行测试:另开一个终端窗口执行:
yarn test-storybook
如果项目使用 Vite 驱动的 Storybook 框架,官方文档建议优先考虑 Vitest addon 来获得更现代的测试体验(它同样提供完整的组件测试能力);本方案针对使用 Webpack 版 Test Runner 的既有项目依然完全适用。
常见问题与注意事项
- 视口只影响渲染,不影响响应式断言逻辑:
setViewportSize改变的是页面视口,媒体查询会随之重新求值;但截图类快照测试如果依赖deviceScaleFactor等参数,仍需要额外的prepare钩子配置。 MINIMAL_VIEWPORTS中未命中的 viewport 名:当 story 声明了defaultViewport但名称不在查找表中时,会走回退分支使用 1280×720。若发现测试视口不符合预期,优先检查preview.js中自定义视口的键名与 story 中的defaultViewport是否一致。- 钩子为实验性 API:
preVisit等钩子可能随版本调整,升级 Test Runner 后建议回归验证视口同步逻辑。 - 远程 Storybook 场景:若使用
--url指向部署后的 Storybook,getStoryContext依然可用(它读取的是页面内 story 的上下文数据),本方案对远程实例同样有效。
结语
本文从官方配置片段(docs/_snippets/test-runner-custom-page-viewport.md)出发,完整还原了"让 Test Runner 按 story 的 viewport 参数自动调整 Playwright 视口"这一实战方案:getStoryContext 负责取回参数,MINIMAL_VIEWPORTS 负责视口定义查找,preVisit 钩子负责在渲染前应用尺寸。它把 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 StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00