首页
/ Storybook Test Runner 视口同步实战:用 preVisit 钩子与 getStoryContext 让每个 Story 按自己的 Viewport 参数渲染

Storybook Test Runner 视口同步实战:用 preVisit 钩子与 getStoryContext 让每个 Story 按自己的 Viewport 参数渲染

2026-09-09 09:07:30作者:霍妲思

导读

在 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 内部信息(如 argsparameters),getStoryContext 是其中之一。它接收 Playwright 的 page 对象和当前 story 对象,返回该 story 的完整上下文对象,其中包含 parameters

这一步是必需的:preVisit 钩子收到的第二个参数只包含 story 的 idtitlename,并不携带参数数据,必须通过 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_VIEWPORTSstorybook/viewport 导出的内置视口集合,结构与 Viewport 插件中自定义视口完全一致——每个条目是一个 { name, styles } 对象,styles 内含 widthheight

// MINIMAL_VIEWPORTS 的结构示例(与自定义视口同构)
{
  mobile1: {
    name: 'Small mobile',
    styles: { width: '320px', height: '568px' },
  },
  // ...
}

这一点可以从官方文档中自定义视口的写法得到印证(docs/_snippets/addon-viewport-add-viewport-in-preview.md):自定义视口同样由 namestyles: { 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_VIEWPORTSstyles 的值是带单位的字符串(例如 '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 为测试准备浏览器(接收 pagebrowserContexttestRunnerConfig
setup 在所有测试运行前执行一次
preVisit 在 story 被首次访问并渲染之前执行
postVisit 在 story 被访问并完全渲染之后执行

测试执行时遵循以下顺序:

  1. setup 函数先于所有测试执行;
  2. 生成包含所需信息的 context 对象;
  3. Playwright 导航到 story 页面;
  4. preVisit 函数执行(本方案的视口调整发生在此刻);
  5. story 渲染,若有 play function 则执行;
  6. 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 能保证视口设置完成后再进入渲染阶段,行为更确定。

完整接入步骤

  1. 安装 Test Runner:在项目根目录执行安装命令,并在 package.json 中添加脚本:
{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}
  1. 启动 Storybook:在终端运行开发命令启动本地 Storybook(Test Runner 需要一个本地运行或已发布的 Storybook 实例)。

  2. 创建配置文件:在 .storybook/ 目录下新建 test-runner.js(或 test-runner.ts),粘贴上文代码。

  3. 运行测试:另开一个终端窗口执行:

yarn test-storybook

如果项目使用 Vite 驱动的 Storybook 框架,官方文档建议优先考虑 Vitest addon 来获得更现代的测试体验(它同样提供完整的组件测试能力);本方案针对使用 Webpack 版 Test Runner 的既有项目依然完全适用。

常见问题与注意事项

  • 视口只影响渲染,不影响响应式断言逻辑setViewportSize 改变的是页面视口,媒体查询会随之重新求值;但截图类快照测试如果依赖 deviceScaleFactor 等参数,仍需要额外的 prepare 钩子配置。
  • MINIMAL_VIEWPORTS 中未命中的 viewport 名:当 story 声明了 defaultViewport 但名称不在查找表中时,会走回退分支使用 1280×720。若发现测试视口不符合预期,优先检查 preview.js 中自定义视口的键名与 story 中的 defaultViewport 是否一致。
  • 钩子为实验性 APIpreVisit 等钩子可能随版本调整,升级 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 中"每个组件都在正确布局下预览"的开发体验,无缝延伸到了自动化测试环节,让响应式组件在测试中也能在正确的断点下被验证。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
858
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
923
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.83 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
524
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
393