Playwright 组件测试实战指南:用内置 mount 夹具驱动 Story Gallery
本篇指南基于 Playwright 官方文档 Component testing,系统讲解 Playwright Test 的组件测试(Component Testing)方法论:通过 @playwright/test 内置的 mount 夹具,将组件挂载到你自己的 dev server 提供的 story gallery 页面上进行隔离测试。读完本文,你将掌握 story/gallery 模型的全部约定、playwright.config.ts 的完整配置方式、状态录制与 update() 等测试模式,以及从已停更的 @playwright/experimental-ct-* 实验包的完整迁移路径,并能直接在自己的 React 或 Vue 项目中落地组件级测试。
核心模型:story、gallery 与 mount 夹具
Playwright Test 可以隔离地测试 Web 应用的组件。一个组件测试本质上就是一条普通的 Playwright 端到端测试,运行在你自己的 dev server 提供的一个小型 story gallery 页面上。没有专门的组件测试运行时、没有打包器集成、没有额外的 npm 包——一切都由 @playwright/test 内置的 mount 夹具驱动:
import { test, expect } from '@playwright/test';
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
});
测试运行在 Node.js 中,而组件运行在真实浏览器里:触发的是真实点击、执行的是真实布局、可以做视觉回归测试。同时测试获得 Playwright Test 的全部能力:并行、参数化、重试与事后追踪(post-mortem tracing)。
注意:实验性包
@playwright/experimental-ct-react、-ct-react17和-ct-vue已被移除且不再发布。如果仍在使用它们,请停留在 Playwright 1.62,并参照本文迁移章节完成迁移。
为什么是框架无关的设计
实验包允许测试内联书写 JSX——mount(<Button onClick={spy} />)。为此 Playwright 必须接管整条管线:扫描测试中的组件、用自带的 Vite 副本和自己的配置编译 bundle、从自己的 server 提供服务,并在 Node.js/浏览器边界上传递 props 与回调。这个设计让实验包永远停留在实验状态:
- 只有你的构建配置恰好与它一致时才可用。 路径别名、插件、CSS 处理都必须手动镜像进
ctViteConfig。使用 webpack、Next.js 或自定义管线的项目根本无法用自己的构建。每个框架都需要自己的包和自己的运行时胶水代码,每新增一个框架就多一个包。 - Node.js/浏览器边界会泄漏。 测试里写的 JSX 在 Node.js 中编译后又在浏览器中重组,活对象无法跨越边界,回调只能通过 marshalling 半工作,模块 mock 会静默失效。
新的方案反转了控制方向:
- 管线归你所有。 组件由你自己的 dev server 构建和服务,带着你的插件、别名和 CSS。Playwright 不编译也不服务任何东西——它只是导航到一个页面,和其他任何测试一样。
- 框架无关。 唯一的框架相关部分是 gallery 页面——一个由你拥有的一小段模块。React、Vue、Svelte、Solid 或任何其他框架:只要你的 dev server 能渲染它,Playwright 就能测试它。
- 稳定。 测试从普通的
@playwright/test导入test和expect,mount是文档化的内置夹具。没有实验包可依赖,也没有第二套配置方言。
三个概念构成了整个模型:
- story 是一个小型包装组件,把被测组件嵌入某个特定场景中:硬编码 props、mock 数据、providers、记录型回调。story 与组件同目录存放,文件名为
*.story.tsx(或.ts/.jsx/.js/.vue);每个具名导出就是一个 story。 - gallery 是一个单页面,由你的 dev server 服务,暴露
window.mount(params)和window.unmount()两个函数,把按 story id 解析出来的 story 渲染到#root元素中。它框架相关,由你实现和维护。 mount夹具导航到 gallery(即baseURL),用 story id 和 props 调用window.mount(),并返回指向 gallery 根的 Locator。从它出发限定查询范围:component.getByRole('button').click()。
组件所需的一切都在 story 内部(运行在浏览器里)完成初始化;测试断言的一切都必须能透过页面观察到:DOM、URL、网络。
快速上手(五步)
第 1 步:让编码代理读取 skill 完成搭建
gallery 属于应用代码——它归你所有,不属于 Playwright。最快的拥有方式是不自己写:Playwright 把这套方法论完整打包为一个 agent skill(源码见 SKILL.md)。安装 skill 并让你的编码代理(Claude Code、GitHub Copilot 或类似工具)完成搭建:
npx playwright init-skills
Set up component testing using the playwright-component-testing skill.
代理会检测你的框架和打包器、为你的技术栈实现 gallery、在配置中添加一个 Playwright project,并写出第一个 story 和 spec。该 CLI 命令在 program.ts 中注册,还支持 --loop <loop> 选项(可选 claude 或 agents,默认 claude)来选择 agentic loop 提供方。
即使你永远不打开 gallery 文件,也值得知道它必须履行的契约:
- 它是
playwright/gallery/下的一个单页面,由你自己的 dev server 服务——Vite 应用直接用已有 dev server 服务(Vite 会服务项目根下任意.html文件,vite build会忽略它);其他环境则运行一个独立的小型 Vite server。 - 它发现你的
*.story.*文件并暴露两个函数:window.mount({ story, props })把给定 id 的 story 渲染进#root,window.unmount()将其卸载。未知 story 或渲染错误会使 Promise reject,表现为测试中mount()调用抛出异常。 - 它在多次调用间复用渲染根,因此
component.update(props)是协调(reconcile)而非重新挂载,组件内部状态得以保留。 - 它以与应用入口相同的方式导入全局 CSS;
window.mount函数体就是应用级全局设置的天然位置——相当于旧beforeMount/afterMount钩子。
如果偏好手写,已安装的 skill 中 references/gallery-spec.md 包含完整规范与可运行的 React、Vue 示例——整个页面不过几十行。
第 2 步:配置 Playwright
在 playwright.config.ts 中添加一个 project,并把 baseURL 指向 gallery:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
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',
url: 'http://localhost:5173/playwright/gallery/index.html',
reuseExistingServer: !process.env.CI,
},
});
mount 会导航到 baseURL,所以它必须指向 gallery。serviceWorkers: 'block' 防止应用自己的 service worker 提供缓存响应、从而遮蔽你的 page.route() mock。reuseContext: true 让同一 worker 内的多个测试复用浏览器上下文——对组件测试套件是显著提速,也是实验包曾经隐式应用的同一优化。如果配置中已有 projects/webServer,合并而不是替换。
第 3 步:编写 story
story 与它驱动的组件同目录存放,每个具名导出是一个场景:
import { Button } from './Button';
export const Primary = () => <Button title='Submit' />;
export const Disabled = () => <Button title='Submit' disabled />;
第 4 步:编写测试
import { test, expect } from '@playwright/test';
test('renders primary button', async ({ mount }) => {
const component = await mount('components/Button/Primary');
await expect(component.getByRole('button')).toHaveText('Submit');
});
test('disabled button is disabled', async ({ mount }) => {
const component = await mount('components/Button/Disabled');
await expect(component.getByRole('button')).toBeDisabled();
});
第 5 步:运行
npx playwright test --project=components
mount 夹具的实现细节(源码级)
从 packages/playwright/src/index.ts 可以看到 mount 夹具的完整运行时实现,它只有三件核心的事:
mount: async ({ page, baseURL }, use) => {
// exposeFunctions turns any callbacks in props into real, browser-callable
// functions that dispatch back to the test.
const callMount = (params: { story: string, props?: Record<string, any> }) =>
page.evaluate(async p => {
const w = window as any;
if (typeof w.mount !== 'function')
throw new Error('The gallery page does not define window.mount().');
await w.mount(p);
}, params, { exposeFunctions: true });
await use(async (storyId: string, props?: any) => {
if (!baseURL)
throw new Error('mount() requires `baseURL` to point at the component gallery. Set it in your Playwright config.');
// The gallery is a single page (served at baseURL) that exposes window.mount()/window.unmount().
await page.goto(baseURL);
await callMount({ story: storyId, props });
// Points at the gallery root, scope the queries: component.getByRole(...).
return Object.assign(page.locator('#root'), {
// update() re-renders the same story with new props without navigating; ...
update: (newProps?: any) => callMount({ story: storyId, props: newProps }),
unmount: () => page.evaluate(async () => {
await (window as any).unmount?.();
}),
});
});
},
要点印证了文档的每一条承诺:
- 未设置
baseURL会直接抛错(mount() requires baseURL to point at the component gallery),因此第 2 步的配置不是可选的。 - 每次
mount()都执行page.goto(baseURL)——这就是"每次 mount 全新导航、测试完全隔离"的实现来源。 window.mount通过page.evaluate调用,并传入{ story, props }参数;gallery 页若未定义window.mount()会得到明确错误信息。- 返回值是
#root的 Locator,并用Object.assign附加了update(props)(不导航、用新 props 再次调用window.mount)与unmount()(调用window.unmount())两个方法。update()能否保留状态取决于 gallery 是否复用渲染根——这正是 gallery 契约的要求。 - 值得注意的是
callMount使用了exposeFunctions: true,从源码注释看,这会把 props 中的回调转换为可回调到测试的浏览器函数——但文档仍强烈建议 props 保持为纯可序列化数据,回调归 story 所有。
类型定义在 packages/playwright/types/test.d.ts 中,mount 是泛型的:mount<Story = never, Id extends StoryId>(storyId: Id, props?: MountProps<Story, Id>),配合类型工具 StoryProps(从函数组件签名或 Vue 的 $props 中推断 props 类型)实现 props 的编译期校验。它承诺返回 Promise<Locator & { update(props?): Promise<void>, unmount(): Promise<void> }>。
Gallery 契约要点
references/gallery-spec.md 给出了契约的精确定义,值得逐条对照:
window.mount(params)接收{ story, props },把解析后的组件渲染进#root;返回的 Promise 在挂载完成时 resolve、失败时 reject(未知 story、渲染抛错)。reject 直接表现为测试中await mount(...)抛出带真实堆栈的异常——没有 HTTP 状态码或 DOM 属性信号。- 跨调用复用根:
update()会带着相同 story 和新 props 再次调用window.mount(不导航)。只有渲染进同一个 root/实例,框架才会协调、保留组件内部状态;每次调用重建根(或导航)都会重置状态。框架自行协调,仅在 story(组件类型)变化时重新挂载。 window.mount就是你的 setup/teardown 钩子:它是浏览器侧的beforeMount/afterMount等价物——安装 providers、seed store、启动 in-browser mock server 放在渲染前,渲染后工作放在渲染后,全部在这个函数里按story/props分支。- story id 语法(推荐):
src/下的路径去掉.story.*扩展名 + 导出名,如src/components/Button.story.tsx的导出Primary→components/Button/Primary;任意唯一的尾部后缀也可解析(Button/Primary);单文件组件 story(Button.story.vue)是一个 story,仅用路径寻址(其 default 导出)。 - Vue 变体:
createApp(...).mount()每次调用都构造新实例,因此正确做法是挂载一个小型"响应式宿主"一次、之后只更新它的 refs——ref 更新会在原地重渲染,这正是跨update()保留状态的关键。
规范的 React + Vite 参考实现(仅供说明契约,并非让你照抄的文件):
// playwright/gallery/main.tsx
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
async function resolve(storyId: string) {
const sep = storyId.lastIndexOf('/');
const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
const mod = (file && await stories[file]()) as Record<string, any> | undefined;
return mod?.[name] ?? mod?.default;
}
const rootEl = document.getElementById('root')!;
let root: Root | undefined;
(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
const Story = await resolve(story);
if (!Story)
throw new Error(`Unknown story: ${story}`);
root ??= createRoot(rootEl); // 复用根,让 update() 协调并保留状态
// flushSync 保证渲染错误 reject promise 而不是被吞掉
flushSync(() => root!.render(<Story {...props} />));
};
(window as any).unmount = async () => {
root?.unmount();
root = undefined;
};
<!-- playwright/gallery/index.html -->
<!DOCTYPE html>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
Story 作为一种方法论
story 不只是测试技巧——它们是可 grep、可评审的组件状态文档,以下约定让它们保持如此:
- 一个导出对应一个场景。 优先新增 story 导出而非参数化已有 story。
Button.story.tsx导出Primary、Disabled、WithLongTitle读起来就像一份组件规格说明。 - story 与组件同目录。
src/components/Button.story.tsx文档化了src/components/Button.tsx。重命名和重构同时触及两者。 - story id 从文件路径派生:
src/下的路径去掉.story.*扩展名,加上导出名——components/Button/Primary。任意唯一后缀也可以:mount('Button/Primary')。 - story 拥有组件所需的一切:providers、mock 数据、状态、回调。测试拥有的只有交互和断言。
由于每个 story 都是一个具名、可寻址的页面状态,gallery 同时充当"活目录":在浏览器中打开 gallery URL 即可渲染任意 story 用肉眼检查。
测试模式
记录状态以供断言
组件接受回调,而测试想断言回调被触发。与其在 Node.js 和浏览器之间 marshalling 回调,不如让 story 拥有状态并提供回调——把可观察的结果记录到组件旁边的一个隐藏表单里:
React 版本([Button.story 同款写法]):
import { useState } from 'react';
import { Expandable } from './Expandable';
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title='Title'>Details</Expandable>
<form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>
</>;
};
Vue 版本(render 函数风格):
import { defineComponent, h, ref } from 'vue';
import Expandable from './Expandable.vue';
export const Stateful = defineComponent(() => {
const expanded = ref(false);
return () => h('div', [
h(Expandable, {
'expanded': expanded.value,
'onUpdate:expanded': (value: boolean) => expanded.value = value,
'title': 'Title',
}),
h('form', { hidden: true }, [
h('input', { 'data-testid': 'expanded', 'readonly': true, 'value': String(expanded.value) }),
]),
]);
});
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
});
这个模式是整套方法论的核心:
- 整个场景在浏览器内运行——没有回调 marshalling,没有 Node.js/浏览器边界可泄漏。
toHaveValue()是 web-first 断言:它会重试直到状态落地,无需手动 await 或轮询。- 每个观察值记录在自己的
data-testidinput 中——标量用String(...),payload 用JSON.stringify(...)。反方向同样适用:执行操作,然后断言值没有变化。 - 记录的状态在打开 gallery 查看 story 时可见:手动点击组件,观察值变化——story 同时就是自动化测试覆盖的同一场景的手动测试页。保持表单
hidden以获得干净的截图基线,或开发期间去掉hidden属性实时查看组件旁的状态。
每测试 props
当场景确实需要参数化时,把纯可序列化的 props 作为 mount 的第二个参数传入,gallery 会把它作为 props 交给 story:
import { Button } from './Button';
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
<Button title={title} />;
import type { WithTitle } from '../../src/components/Button.story';
const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });
mount 对 story 是泛型的:把 story 类型作为模板参数传入,props(以及 update())就会按 story 签名做类型检查(对应类型定义中的 MountProps<Story, Id> 工具类型,见 test.d.ts)。props 保持纯可序列化数据——回调归 story 所有。
用 update() 测试 prop 过渡
要测试组件对 prop 变化不重挂载(状态保留)的反应,调用 component.update(newProps)。它在既有根上用新 props 重渲染同一个 story:
const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2');
从 index.ts 的实现看,update 就是用相同 story 和新 props 再次调用 window.mount、不做任何导航;能否保留状态取决于 gallery 是否按契约复用了渲染根。
多状态与视觉对比
每次 mount() 都全新导航,因此测试完全隔离,一个测试中挂载多个 story 的代价很低:
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
对返回的根 locator 截图(而不是整页),避免把 gallery 里可能放的其他东西纳入断言。
处理网络请求
照常使用 page.route()——在 mount() 之前注册路由,因为挂载会触发导航:
test('renders the error state', async ({ page, mount }) => {
await page.route('**/api/items', route => route.fulfill({ status: 500 }));
const component = await mount('components/ItemList/Default');
await expect(component.getByRole('alert')).toContainText('Something went wrong');
});
配置中的 serviceWorkers: 'block' 选项防止应用自己的 service worker 提供遮蔽路由的缓存响应。拥有 MSW handler 库的团队可以在 story 或装饰器中启动 worker 替代这一方案。
调试 story
在浏览器中打开 gallery URL,从 DevTools 控制台调用 await window.mount({ story: 'components/Button/Primary' })——这正是 mount 夹具做的事(page.goto(baseURL) + window.mount(...))。未知 story 或渲染错误会 reject window.mount,表现为测试的 mount() 抛出带真实堆栈的异常。若想不用控制台浏览,可以给 gallery 加一个可选的索引页列出所有发现的 story。
迁移 from 实验性包
实验包在测试文件中编译 JSX 再把它 marshalling 进浏览器;gallery 模式把场景移入在浏览器中原生运行的 story 导出。概念映射如下(详见 skill 内的 migration.md):
@playwright/experimental-ct-* |
Story gallery |
|---|---|
mount(<Button onClick={spy} />) |
有状态 story:story 提供 onClick 并把效果记录到隐藏 input;测试用 toHaveValue() 断言 |
| 从测试传普通数据 props | 精神上不变:mount(id, props) |
| 从测试传 JSX children / slots | 每种组合一个 story 导出(Vue:slot 重的场景用 .story.vue 文件) |
component.update(<Button count={2} />) |
component.update({ count: 2 }) |
component.unmount() |
component.unmount() |
beforeMount / afterMount 钩子 |
gallery 的 window.mount 函数体(全局),或 story 装饰器(按 story) |
hooksConfig 按测试变体 |
props:mount('App/Routing', { route: '/dashboard' }),由 story 解释 |
router 夹具 / Node.js 中的 MSW handlers |
测试中的 page.route(),或 story 内的 MSW setupWorker |
playwright/index.html(样式、主题) |
gallery 的 index.html 与入口模块导入 |
ctViteConfig、ctPort、ctTemplateDir |
移除——gallery 经由你自己的 dev server 运行;端口在 webServer 与 baseURL 中 |
ct 包的 defineConfig |
@playwright/test 的普通 defineConfig |
一个典型 spec 的迁移对照:
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';
test('counts clicks', async ({ mount }) => {
let clicks = 0;
const component = await mount(<Button title='Submit' onClick={() => ++clicks} />);
await component.getByRole('button').click();
expect(clicks).toBe(1);
});
import { useState } from 'react';
import { Button } from './Button';
export const CountsClicks = () => {
const [clicks, setClicks] = useState(0);
return <>
<Button title='Submit' onClick={() => setClicks(count => count + 1)} />
<form hidden><input data-testid='click-count' readOnly value={String(clicks)} /></form>
</>;
};
import { test, expect } from '@playwright/test';
test('counts clicks', async ({ mount }) => {
const component = await mount('components/Button/CountsClicks');
await component.getByRole('button').click();
await expect(component.getByTestId('click-count')).toHaveValue('1');
});
增量迁移:在锁定 Playwright 1.62 期间,与旧的 CT project 并列搭建 gallery 和 components project,逐个 spec 迁移,然后连同 playwright/index.html、playwright/index.ts 和 playwright/.cache 一起移除 @playwright/experimental-ct-* 依赖并升级。
需要注意的事项:
- story id 是字符串。 重命名或移动 story 在运行时而非编译期破坏 spec。使用
mount<typeof Story>至少能在编译期把 props 绑定到 story。 - 每测试 JSX 消失了。 每个测试构造不同 JSX 树的测试,变为每种组合一个 story 导出——这正是设计意图:每个值得测试的组合都值得被命名和评审。
常见问题
如何访问组件的方法或其实例?
在测试代码中访问组件的内部方法或实例既不被推荐也不被支持。应聚焦于从用户视角观察和交互:点击它、查看页面、通过 story 把内部效果记录进 DOM。当测试避免实现细节时,变得更不脆弱、更有价值。如果从用户视角运行的测试失败了,通常意味着自动化测试发现了一个真实的 bug。
能保留打包器插件、别名和 CSS 配置吗?
能——这正是设计的核心。gallery 由你自己的 dev server 服务,所以你的应用能渲染什么,你的 story 就能渲染什么。没有需要同步的第二份打包器配置。
除 React 和 Vue 之外的框架怎么办?
让你的编码代理为你的框架实现 gallery 契约:把 story id 解析为组件、渲染进 #root、跨调用复用根使 update() 保留状态。mount 夹具不关心也不需要对端是哪个框架。
参考文件
- 官方文档:docs/src/test-components-js.md
mount夹具运行时实现:packages/playwright/src/index.ts#L502-L528mount类型定义与泛型 props 推导:packages/playwright/types/test.d.ts- Agent skill 总览:SKILL.md
- Gallery 契约规范(含 React/Vue 参考实现):gallery-spec.md
- React 走查(providers、StrictMode、CSS):react.md
- Vue 走查(
.story.ts/.story.vue、插件):vue.md - 从实验包迁移:migration.md
npx playwright init-skills命令注册:program.ts#L187-L198
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 StartedRust0629
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