使用 Playwright Gallery 模式进行 Vue 组件测试:Stories、插件与类型化 mount 完全指南
在 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,返回指向#root的Locator。
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 画廊可以复用 createRoot 让 update() 触发 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,host 的 render() 读取它们;首次调用 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.ts 以 page.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.ts的Primary导出 →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.vueSFC 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.vue 的 default 导出映射为裸路径(K extends 'default' ? 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 组件测试工程包含:
playwright/gallery/index.html:<div id="root"></div>+ 加载./main.ts的模块脚本;playwright/gallery/main.ts:import.meta.glob发现 story + 响应式 host +window.mount/window.unmount(Vue 变体见上文,React 完整版示例见 gallery-spec.md);playwright.config.ts:componentsproject +webServer(第一节所示配置);src/components/Button.story.ts(或Button.xxx.story.vue):声明各场景 story;src/components/button.spec.ts:用内置mountfixture 写普通测试。
实践中有几条被源码与文档双重印证的铁律:
- 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 组件测试体验。
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