Playwright 组件测试:在真实浏览器里挂载单个组件
要验证一个折叠面板点开之后状态对不对,最省事的办法当然是直接点它。但真到写测试的时候,你往往得先把整个应用启动起来、登录、导航到那个页面、把面板滚进视口,才轮到那一次点击。组件测试想解决的就是这段绕路:只把这一个组件放到浏览器里,点它,看它。
Playwright 现在给出的答案有点反直觉——它没有做一套组件测试的运行时。docs/src/test-components-js.md 开篇就写明:组件测试就是一个普通的 Playwright 端到端测试,只不过跑在一张由你自己的 dev server 提供的 story gallery 页面上;没有专门的组件测试运行时,没有打包器集成,也不需要额外的 npm 包,全靠 @playwright/test 内置的 mount fixture 驱动。
前置条件:先确认这几件事
第一,版本与包。 docs/src/test-api/class-fixtures.md 里 Fixtures.mount 标着 since: v1.62,返回类型是 Locator。也就是说这个 fixture 直接来自 @playwright/test,测试文件里 import { test, expect } from '@playwright/test' 就有,不用装别的东西。
第二,旧包的状态必须先看清楚。 文档里有一条 note 写得很硬:实验性的 @playwright/experimental-ct-react、-ct-react17 和 -ct-vue 三个包已经被移除、不再发布;如果你还在用它们,要停在 Playwright 1.62,先照迁移指南迁完再升。这一条决定了你是「新建」还是「迁移」,跳过它后面全是坑。
第三,你得有一个能渲染组件的 dev server。 文档把 gallery 定义成应用代码——它属于你,不属于 Playwright。仓库内置的 skill 文件 packages/playwright-core/src/tools/skills/playwright-component-testing/SKILL.md 把这一步分成两种情况:应用跑在 Vite 上(有 vite.config.*)的,gallery 由现有 dev server 提供,应用的插件、别名、CSS 自动生效;其它情况(Next.js、webpack、根本没有 dev server)则要另起一个小的独立 Vite server 来提供这张页面,需要把 vite 和框架插件加进 devDependencies。
第四,语言绑定。 这篇讲的是 JavaScript/TypeScript 绑定,事实源文件名后缀是 -js。其它语言绑定有没有对应能力,我们没有在对应的语言专属文档里找到相关说明,不做推测。
步骤:仓库文档写明的五步
第 1 步:装 skill,让编码 agent 写 gallery
gallery 是框架相关的那一小块胶水,文档给的建议是别自己写。命令原样是:
npx playwright init-skills
这个命令在 packages/playwright/src/program.ts 里定义,描述是 Install Playwright agent skills,带一个 --loop <loop> 选项,取值只有 claude 和 agents 两个,默认 claude。它调用的 installSkills 定义在 packages/playwright-core/src/tools/utils/installSkills.ts,里面的 allSkills 是 playwright-cli、playwright-component-testing、playwright-trace 三个——注意这条命令是一次把三个都装上,不是只装组件测试那一个。落盘位置由 path.join(baseDir, .${target}, 'skills', skill) 决定,默认 baseDir 是当前工作目录,所以默认情况下你会在项目里看到 .claude/skills/playwright-component-testing/。
安装完成后给 agent 的那句话,文档里也原样给了:
Set up component testing using the playwright-component-testing skill.
如果你想自己写,规范在 skill 目录下的 references/gallery-spec.md,React 和 Vue 的说明分别在 references/react.md 和 references/vue.md。契约本身不长,值得知道:这张页面暴露 window.mount({ story, props }) 和 window.unmount() 两个函数,把解析出来的组件渲染进 id="root" 的元素;未知的 story 或渲染抛错时 Promise 要 reject;并且要跨调用复用同一个 root——gallery-spec.md 明确写了,正因为渲染进同一个 root,框架才会走 reconcile 而不是重建,组件内部状态才保得住,这就是 update() 的实现基础。
第 2 步:改 playwright.config.ts
文档给的配置片段原样如下:
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,
},
});
这三行 use 各自在改什么,文档逐条解释了:mount 会导航到 baseURL,所以 baseURL 必须指向 gallery 而不是应用首页;serviceWorkers: 'block' 是防止应用自己的 service worker 返回缓存响应,把你在测试里写的 page.route() mock 盖掉;reuseContext: true 让一个 worker 进程内的多个测试复用同一个浏览器上下文。上面那个端口和路径是仓库示例里的取值,你的 dev server 端口不同就要跟着改,webServer.url 也要一起改。
这里就是与端到端测试共用配置的分界线:这些开关只能放在 components 这个 project 的 use 里,不能提到顶层。docs/src/test-api/class-testoptions.md 里 TestOptions.reuseContext 标着 since: v1.62,并且带一个 discouraged 标记,原文的意思是:这个选项拿隔离性换速度,是给驱动 story gallery 的组件测试用的,端到端测试请不要设它——每个测试一个全新的浏览器上下文,是 Playwright Test 的核心保证之一。baseURL 同理:端到端用的是应用地址,组件测试用的是 gallery 地址,两者天然冲突,只能分 project 写。
第 3、4 步:写 story 和 spec
story 就放在组件旁边,每个具名导出是一个场景:
import { Button } from './Button';
export const Primary = () => <Button title='Submit' />;
export const Disabled = () => <Button title='Submit' disabled />;
测试文件里通过 story id 挂载:
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');
});
story id 的规则文档写得很具体:src/ 下的路径去掉 .story.* 扩展名,再加导出名,于是 src/components/Button.story.tsx 的 Primary 就是 components/Button/Primary;任何唯一的后缀也能解析,写成 mount('Button/Primary') 一样有效。
mount 的第二个参数是可选的 props,class-fixtures.md 里限定为「plain, serializable props」。返回的 locator 被额外挂了两个方法:update(props) 用新 props 重渲染同一个 story、不重新挂载、组件状态保留;unmount() 卸载当前 story。要做 props 变化的测试就用前者:
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');
回调怎么断言,是这套方法论里最需要转弯的地方:不要把回调从 Node.js 传进浏览器,而是让 story 自己持有状态、自己提供回调,再把观察到的值记进组件旁边一个 hidden 的表单里,测试去断言那个输入框的值。文档给的 React 例子就是在 story 里 useState,然后渲染一个 <form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>。
第 5 步:跑
npx playwright test --project=components
以上配置与代码片段为按仓库文档与代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
边界:文档明说不保证的部分
reuseContext 是 Experimental,而且隔离是 best-effort。 这一点写在 class-testoptions.md 里,不是我推的:测试之间 Playwright 会尽力重置组件测试常碰的状态(cookie、缓存、local storage、IndexedDB、注销 service worker、关掉多余的页面、清掉 route/binding/init script、重新套用配置的存储状态与视口),但原文明确说这是尽力而为、不是隔离的保证。文档还点名了几类不会被重置的东西:测试里用 grantPermissions 授出去的权限、通过 setGeolocation / setOffline / setExtraHTTPHeaders 做的运行期改动,以及浏览历史、window.name 这类浏览器进程级状态。
同一节还列了几条附加限制,撞上任何一条你的配置就不是你以为的样子:开启 video 录制时这个选项被忽略;相邻两个测试之间只有少数几个 context 选项允许不同(文档列的是 colorScheme、forcedColors、reducedMotion、contrast、screen、userAgent、viewport、testIdAttribute),在 test.use() 里改动其它选项(例如 locale 或 storageState)会静默地强制换一个新 context;不要和指向共享浏览器的 connectOptions 一起用;contextOptions 里的 recordHar 不被支持,不会产出 HAR 文件。
访问组件实例是不支持的。 文档的 FAQ 直说:在测试代码里访问组件的内部方法或实例,既不推荐也不支持,要从用户视角去观察和交互,内部效果通过 story 记进 DOM。
story id 是字符串。 迁移一节把这条单列出来了:重命名或移动 story 会在运行时把 spec 打挂,而不是编译期。缓解办法是用 mount<typeof Story> 这种泛型写法,至少把 props 在编译期绑到 story 上。
Windows 侧的一处差异。 docs/src/test-webserver-js.md 的 webServer 参数表写明:gracefulShutdown 用来控制怎么关掉 dev server 进程,不设时进程组会被强制 SIGKILL;但 Windows 不支持 SIGTERM 和 SIGINT 信号,所以这个选项在 Windows 上被忽略。也就是说 Linux/macOS 上你可以让 Vite 收到 SIGTERM 后自己收尾,Windows 上没有这条路,不要在 dev server 的退出钩子里放清理逻辑然后指望它执行。同一张参数表里 reuseExistingServer 的说明没有带任何平台限定:设成 true 时,port 或 url 上已经有服务就复用它,没有才执行 command 起一个;设成 false 时,若已有进程在监听该端口或地址,它会直接抛错。
怎么验证配对了
最直接的一步不用跑测试:把 gallery 的 URL 在浏览器里打开,在 DevTools 控制台里执行
await window.mount({ story: 'components/Button/Primary' })
文档说得很明白,这正是 mount fixture 在做的事。如果这一句能把组件渲染进 #root,说明 gallery 契约实现对了;如果 story id 不存在或渲染抛错,window.mount 会 reject,对应到测试里就是 await mount(...) 抛错并带上真实的调用栈——文档原话是 rejects 会 surface 成测试里 mount() 的抛出。
然后再跑 npx playwright test --project=components。断言用 toHaveValue()、toHaveText() 这类 web-first 断言,文档强调它们会自动重试直到状态落定,不需要你手动 await 或轮询。截图对比时要截 mount 返回的那个根 locator,而不是整个 page,免得把 gallery 里的其它东西也拍进基线。
最后提一句思路上的转变:story 不只是测试用的夹具,它同时是可 grep、可 review 的组件状态文档,打开 gallery 就能逐个渲染来看。把「每个值得测的组合都值得起个名字」这件事接受下来,剩下的写法就顺了。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。