首页
/ 使用 Playwright Gallery 模式进行 Vue 组件测试:Stories、插件与类型化 mount 完全指南

使用 Playwright Gallery 模式进行 Vue 组件测试:Stories、插件与类型化 mount 完全指南

2026-09-06 18:15:29作者:戚魁泉Nursing

在 Playwright 1.63 移除 @playwright/experimental-ct-vue 之后,组件测试改为通过内置 mount fixture 驱动一个"故事画廊(story gallery)"页面来完成:先用 Vue 3 编写 story 包装组件、在应用自身开发服务器上托管画廊,再编写普通 e2e 测试对组件进行隔离验证。本文以官方组件测试指南中的 Vue 专项文档 为主体,结合其总览 SKILL.md、画廊契约 gallery-spec.md、类型系统文档 typing.md 与仓库中的真实示例与源码实现,完整讲解 Vue 组件测试的两类 story 写法、全局插件注入、类型化 props、CSS 引入以及对应的底层原理,让你无需任何额外测试运行器或打包器集成即可落地一套 Vue 组件测试方案。

一、从总流程到 Vue:Gallery 模式的前提

在深入 Vue 细节前,先厘清整个模式的上下文。官方指南把组件测试拆成三个部分(详见 SKILL.md):

  • story(故事):紧邻被测组件的小型包装组件,把组件放进某个特定场景——写死 props、mock 数据、Provider、记录回调。故事写在 *.story.ts / *.story.js / *.story.vue 文件中,一个命名导出就是一个故事。
  • gallery(画廊):一个由你实现的单页(契约见 gallery-spec.md),向 window 暴露 window.mount(params)window.unmount(),负责把"从 story 文件中解析出来的组件"渲染进 #root
  • 测试:普通 Playwright 测试。内置的 mount(storyId, props?) fixture(来自 @playwright/test)驱动画廊的 window.mount,返回指向 #rootLocator

Vue 文档开篇就交代了它的核心任务:按 SKILL.md 完成设置,再按 gallery-spec.md 实现画廊。而在 Vue 中,挂载的基本动作是:

const app = createApp(h(story, props));
app.mount('#root');

卸载则调用 app.unmount()。这篇 Vue 专项文档覆盖的就是 Vue 特有的细节:两种 story 语法、插件注入、类型化 props、CSS 与状态保持。

挂载 fixture 的底层行为

从仓库源码可以确认内置 mount fixture 的真实调用链。在 packages/playwright/src/index.ts 中,fixture 接收 { page, baseURL }

  • 要求配置了 baseURL,否则抛出 mount() requires 'baseURL' to point at the component gallery
  • 通过 page.goto(baseURL) 导航到画廊页面;
  • page.evaluate 调用 window.mount,若页面未定义该方法则抛错 The gallery page does not define window.mount().
  • 返回 page.locator('#root') 并混入两个增强方法:update(newProps) 会以相同 story id 再次调用 window.mount(不导航),unmount() 则调用 window.unmount?.()

源码注释特别强调两点:update() 能保持组件状态的前提是画廊复用同一个根节点/实例,由框架自行 reconcile;以及 mount 返回值就是 #root 定位器,因此测试里必须从它向下查询——component.getByRole('button').click(),而不是 component.click()

Vue 画廊中的状态保持:响应式 host

React 画廊可以复用 createRootupdate() 触发 reconcile(见 gallery-spec.md 的 React 示例),而 Vue 不同:createApp(...).mount() 每次调用都会构建全新实例,直接重挂会丢失状态。因此画廊契约给出专门的 Vue 变体(state-preserving)——一次性挂载一个小的响应式 host 组件,之后通过更新 ref 触发原位重渲染:

// playwright/gallery/main.ts
import { createApp, h, shallowRef, type App, type Component } from 'vue';

// resolve() 与 import.meta.glob 与 React 示例相同
const story = shallowRef<Component | null>(null);
const props = shallowRef<Record<string, any>>({});
const host = { render: () => (story.value ? h(story.value, props.value) : null) };
let app: App | undefined;

(window as any).mount = async ({ story: id, props: next }: { story: string, props?: Record<string, any> }) => {
  const resolved = await resolve(id);
  if (!resolved)
    throw new Error(`Unknown story: ${id}`);
  story.value = resolved;
  props.value = next ?? {};
  if (!app) {                    // 只挂载一次;上面的 ref 更新会在原位重渲染
    app = createApp(host);
    app.mount('#root');
  }
};

(window as any).unmount = async () => {
  app?.unmount();
  app = undefined;
};

这段代码中 shallowRef 保存当前 story 组件与 props,hostrender() 读取它们;首次调用 window.mount 时创建 app 并挂载到 #root,此后 story 或 props 变化只触发 ref 更新,Vue 在原实例上 re-render,从而让 component.update({ ... }) 在"不重挂、保留组件内部状态"的前提下完成(契约在 gallery-spec.md 有完整说明)。story 解析的 import.meta.glob 与框架挂载逻辑就留在这个文件里,其余一切放到 stories 和测试中——这是整个 gallery 中唯一的框架相关胶水。

二、文件布局:Gallery 与 Stories 放哪里

Vue 专项文档给出两类需要落地的文件:

  • playwright/gallery/ ——按 gallery-spec.md 实现的画廊,由一个 index.html 加一个 main.ts 模块组成,需要 Vue 3(createApp 是 Vue 3 的 API,Vue 2 不适用)。
  • Stories:位于 src/**/*.story.{ts,js,vue},仓库模板示例是 templates/vue/Button.story.ts

配套的配置方式来自 SKILL.md:应用若跑在 Vite 上(存在 vite.config.*),画廊由现有 dev server 直接服务(Vite 会服务项目根下任意 .html,且应用插件/别名/CSS 自动生效,vite build 会忽略它);否则需要一个独立的 Vite dev server 提供画廊页,并把 baseURL 指向它。随后在 playwright.config.ts 中增加 components project 与 webServer

projects: [
  {
    name: 'components',
    testDir: './tests/components',
    use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5173/playwright/gallery/index.html', serviceWorkers: 'block', reuseContext: true },
  },
],
webServer: {
  command: 'npm run dev',                                       // 或: npx vite --config playwright/vite.config.ts
  url: 'http://localhost:5173/playwright/gallery/index.html',   // 独立服务器: http://localhost:3100/playwright/gallery/index.html
  reuseExistingServer: !process.env.CI,
},

要点:端口必须与 dev server 匹配;mount 每次会导航到 baseURL(源码在 packages/playwright/src/index.tspage.goto(baseURL) 落实),所以 baseURL 必须指向画廊 URL;serviceWorkers: 'block' 防止应用自带 Service Worker 用缓存响应盖掉 page.route() 的 mock;reuseContext: true 让同一个 worker 内的测试复用浏览器上下文(如同旧 CT 运行时),对组件测试套件是很大的提速。若配置中已有 projects/webServer,应合并而非替换。最后运行 npx playwright test --project=components,或在浏览器中直接打开画廊 URL 目视检查全部 story。

三、两种写 Story 的方式

Vue 专项文档强调 Vue 与其他框架不同的一点:存在两套并行的 story 写法,各自适用不同场景。

方式一:渲染函数 story(Render-function stories)

文件命名如 Button.story.ts,一个文件内可以有多个场景,每个命名导出一个 story。它使用 defineComponent 配合 h(),完全不涉及 SFC 编译。仓库模板 templates/vue/Button.story.ts 演示了三个典型故事:

import { defineComponent, h, ref } from 'vue';
import Button from './Button.vue';

export const Primary = defineComponent(() => () => h(Button, { title: 'Submit' }));

export const Disabled = defineComponent(() => () => h(Button, { title: 'Submit', disabled: true }));

// story 拥有状态并提供回调,把状态记录进隐藏表单供测试断言
export const CountsClicks = defineComponent(() => {
  const clicks = ref(0);
  return () => h('div', [
    h(Button, { title: 'Submit', onClick: () => clicks.value++ }),
    h('form', { hidden: true }, [
      h('input', { 'data-testid': 'click-count', readonly: true, value: String(clicks.value) }),
    ]),
  ]);
});

对应测试 templates/vue/button.spec.ts 演示了核心测试模式——"story 拥有状态、测试只做操作并断言记录值":

import { test, expect } from '@playwright/test';

test('button click fires callback', async ({ mount }) => {
  const component = await mount('components/Button/CountsClicks');
  await component.getByRole('button').click();
  await expect(component.getByTestId('click-count')).toHaveValue('1');
});

值得注意的细节:

  • story id 的规则(见 gallery-spec.md):src/ 下的路径去掉 .story.* 扩展名再拼接导出名,例如 src/components/Button.story.tsPrimary 导出 → components/Button/Primary;任意唯一后缀也能解析,如 Button/Primary
  • 记录断言值用隐藏表单里的 data-testid 输入框(String(...),负载用 JSON.stringify(...)),再以 web-first 的 toHaveValue() 断言——该断言自带重试,直到状态落定才通过(机制见 SKILL.md)。反向场景同理:执行操作后断言值"未"改变。

方式二:单文件组件 story(SFC stories)

文件命名如 Button.primary.story.vue,一个文件即一个 story(使用默认导出),可用完整模板语法。官方给出的示例:

<script setup lang="ts">
import { ref } from 'vue';
import Button from './Button.vue';
const clicks = ref(0);
</script>

<template>
  <Button title="Submit" @click="clicks++" />
  <form hidden><input data-testid="click-count" readonly :value="String(clicks)" /></form>
</template>

SFC story 用不带扩展名的路径寻址:mount('components/Button.primary')(id 语法见 gallery-spec.md——.story.vue 是单一 story,只用路径即可,走其 default 导出)。官方建议:当场景需要插槽(slots)或较复杂模板时,优先使用 SFC story——因为渲染函数里写插槽既繁琐又难读,而 SFC 模板可以自然表达默认插槽、具名插槽与作用域插槽。

四、全局插件与 Provider:decorator 帮手 + 应用级 use()

Vue 应用经常依赖全局插件:Pinia、vue-router、vue-i18n 等。专项文档给出的策略是:为依赖插件的组件准备一个 decorator story 帮手,每个 story 创建一份全新实例,避免测试间共享状态:

// src/stories/decorators.ts
import { defineComponent, h, type Component } from 'vue';
import { createPinia } from 'pinia';

export function withStore(story: Component) {
  return defineComponent(() => {
    const pinia = createPinia();
    return () => h(story, { pinia });
  });
}

用法是先在某个 story 文件里导出经 decorator 包装的故事,再在测试中 mount 那个 story。这一模式与 React 侧的共享 decorator 思想一致(对比 react.md 中"不要把 decorator 内建进 gallery,把它留在 story 文件中以便可见并可 opt-out"的告诫),Vue 侧同样建议由 story 显式声明其依赖。

对于必须安装在 app 实例上的插件(走 app.use(...) 的,比如 createPinia() 后还需 app.use(pinia)router 等),则要在画廊中 createApp(...) 之后立刻补上——这正是应用自身 bootstrap 逻辑的等价物。回忆上一节 Vue 变体画廊的结构,由于 app 只创建一次(if (!app) 分支),插件安装点也应放在这附近;如果某些插件依赖特定 story 的参数,可以让 window.mount 依据传入的 story/props 分支处理——画廊契约把 window.mount 定义为"你的 setup/teardown 钩子"(对应旧 CT 的 beforeMount/afterMount),所有前置安装与后置工作都集中在这个函数里完成,见 gallery-spec.md 的契约说明。

值得强调的是"每个 story 一份新实例"的价值:mount 每次调用都会导航新页面(源码 page.goto(baseURL) 保证每次挂载都是干净页面),因此每个测试天然隔离;decorator 里新建 Pinia store 则进一步保证同一页面内多次 update() 也不会串状态。

五、类型化 props:运行时声明 + 泛型 mount 双管齐下

Vue 与 React 在类型化 props 上有一个关键差异:Vue 需要 props 的运行时声明。专项文档明确指出:接收 per-test props 的 story 要声明两次——一次在 setup 签名中(为了类型),一次在 props 选项中(让 Vue 把数据作为 props 下发,而不是掉进 attrs):

// src/components/Button.story.ts
export const WithTitle = defineComponent(
  (props: { title?: string }) => () => h(Button, { title: props.title ?? 'Default' }),
  { props: ['title'] },
);

第一次声明出现在 setup 的参数类型里(上例 props: { title?: string }),给 TypeScript 提供静态类型信息;第二次是运行时 props: ['title'] 选项,Vue 据此把 title 识别为声明式 prop——若缺少后者,Vue 会把 title 当作普通 attrs 处理,行为与类型就会分叉。第二参数 props: ['title'] 也可以写成对象形式 { props: { title: String } } 并附带类型校验,但数组写法最简。Options API 风格(defineComponent({ props: { ... } }))推断 props 的方式相同。

mount 对 story 是泛型的:把 story 类型作为模板参数传入,即可对 props(以及 update() 的参数)做类型检查:

// src/components/button.spec.ts
import type { WithTitle } from './Button.story';

const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });

注意必须用 import type,这样 story(连带其 Vue/CSS 导入)永远不会被加载进 Node 测试进程(原因见 typing.md)。

可选进阶:生成 gallery 类型

如果连 id 本身都想获得类型与自动补全,可以生成 gallery 类型(详见 typing.md),那样无需类型导入即可直接写:

const component = await mount('acme-ui/components/Button/WithTitle', { title: 'Hello' });
//                            ^ 已注册 id 有自动补全    ^ 对照 WithTitle 的 props 检查

机制是:@playwright/test 导出一个空的 interface Stories {},当 story id 是 Stories 的键时,mount 会从对应条目取出 props 类型;其余任意字符串保持 props 无类型,因此可以渐进式采用。画廊旁放一个小的 Vite 插件,在 dev server 启动/build 以及 story 文件增删时自动生成 stories.d.ts,通过模块增强填充 Stories。文档提示两个实施细节:monorepo 多包共享 tsconfig 时务必用 <package>/<path>/<Export> 前缀 id(否则 Stories 合并会报 TS2320,且自动补全互相污染);生成文件内容确定,应当提交进版本库,并显式列入项目 tsconfig"files" 中,让 tsc 和编辑器不依赖运行中的 dev server 也能加载它。

SFC story 与 Options API 的类型边界

专项文档补足了 .story.vue 场景的类型约束:

  • Options-API 的 story(defineComponent({ props: { ... } }))按同样方式推断 props。
  • .story.vue SFC story,只有当 setup 能生成 SFC 类型(Volar/vue-tsc)时 props 类型才可推断;否则直接把 props 类型传给 mount
const component = await mount<{ title?: string }>('components/Button.primary', { title: 'Hello' });

typing.md 补充了 gallery 类型下的 SFC 处理:把 .story.vuedefault 导出映射为裸路径(K extends 'default' ? Prefix : Prefix/{Prefix}/{K}``),类型推断依赖 vue-tsc;若 SFC 无法推断出 props,则 story 以 unknown props 呈现(接受任意值)。

六、CSS:在画廊入口镜像应用的全局样式

Vue 应用的全局 CSS 必须在画廊入口导入,镜像应用自身入口的做法。以官方建议的路径为例,在 gallery-spec.md 推荐的 playwright/gallery/main.ts 中:

import '../../src/assets/main.css';   // 与应用入口对全局样式的引入保持一致

这保证了组件测试中的渲染视觉与真实应用一致(字体、颜色变量、reset 等全局影响),是截图类断言(如 toHaveScreenshot())可靠的前提。Vue SFC 自带的 <style> 在组件加载时天然生效,此处只需处理全局样式表;如果应用使用按路径扫描的 Tailwind 等工具,需确保 *.story.tsx(Vue 对应 *.story.vue / *.story.ts)文件被纳入扫描范围(该注意点见 react.md 的 Tailwind 提示)。

七、综合示例与最佳实践回顾

把整条链路串起来,一个最小 Vue 组件测试工程包含:

  1. playwright/gallery/index.html<div id="root"></div> + 加载 ./main.ts 的模块脚本;
  2. playwright/gallery/main.tsimport.meta.glob 发现 story + 响应式 host + window.mount / window.unmount(Vue 变体见上文,React 完整版示例见 gallery-spec.md);
  3. playwright.config.tscomponents project + webServer(第一节所示配置);
  4. src/components/Button.story.ts(或 Button.xxx.story.vue):声明各场景 story;
  5. src/components/button.spec.ts:用内置 mount fixture 写普通测试。

实践中有几条被源码与文档双重印证的铁律:

  • story 拥有状态并注入回调:一切组件需要的都在 story 内完成(它在浏览器中运行);测试断言的一切必须通过页面可观测(DOM、URL、网络)。回调事件把状态写进隐藏表单,测试用 toHaveValue() 做 web-first 断言——这避免了任何回调序列化/编组,story 同时充当了组件状态的活文档(见 SKILL.md)。
  • per-test props 只放可序列化数据:回调永远属于 story;需要批量扫参(如边界值)时把 props 作为 mount 第二参数传入。
  • 一个场景一个 story 导出:优先新增 story 而不是给现有 story 加参数——stories 是可 grep、可 review 的组件状态文档。
  • 网络 mock 用 page.route():在 mount() 之前注册(mount 会导航);serviceWorkers: 'block' 防止应用 SW 缓存响应遮蔽 mock(SKILL.md 网络 mock 一节);有 MSW 团队也可在 story 或 decorator 内启动 setupWorker
  • 截图断言拍根 Locator 而非整页await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png'),避免把浏览器 chrome 断言进去;同页连续 mount 多个 story 由于每次导航全新隔离,代价很低。

如果你正从 @playwright/experimental-ct-vue 迁移,官方迁移指南 migration.md 给出了完整映射:mount(<Button … onClick={spy}/>) → stateful story + 隐藏表单记录 + toHaveValue();JSX 结构 → 独立 story 导出(Vue 插槽密集场景用 .story.vue);update(<X a={2}/>)update({ a: 2 })(状态保持依赖画廊复用根节点);beforeMount → 画廊 window.mount 函数体或 per-story decorator。CT 包已于 Playwright 1.63 移除,因此建议锁定 1.62 完成迁移后再升级。

最终,这套方案的精髓在于"画廊是框架相关的唯一胶水、归你所有"。Vue 侧只需把响应式 host + createApp(h(story, props)) 这一小段写对,其余——story 解析、测试编写、类型化、插件装饰——都能在清晰的契约之上自由展开,得到与旧 CT 运行时完全等价(且更快、更贴近真实运行环境)的 Vue 组件测试体验。

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