Playwright Component Testing 深度拆解:组件挂载到底干了啥,视觉快照又怎么塞进 CI
你写过单元测试,大概率也写过 E2E 测试。但有一种东西卡在中间------组件测试(Component Testing)。
不是渲染个 DOM 断言一下文本内容那种。是把组件真正挂到浏览器里,跑真实渲染引擎,还能截图做视觉回归。Playwright 从 1.22 开始搞了个 CT(Component Testing)模式,干的就是这事。
听起来很美好。但实际用下来,坑不少,概念也容易混。这篇聊聊它底层到底是怎么把组件塞进浏览器的,以及视觉快照在 CI 里跑,怎么才能不翻车。
CT 模式和 E2E 的本质区别
先把一个常见误解说清楚。
很多人觉得 CT 模式就是"E2E 的简化版"。不是。架构完全不一样。
E2E 模式下,Playwright 启动浏览器,访问一个 URL,页面是你的完整应用。测试代码跑在 Node.js 端,通过 CDP 协议控制浏览器。
CT 模式呢?没有完整应用。Playwright 自己起了个 dev server,把你的组件用 Vite 编译打包,然后塞进一个空壳 HTML 页面里。测试代码同样跑在 Node.js 端,但组件渲染发生在浏览器里。
关键区别在这:
makefile
E2E: 测试 → 浏览器 → 你的完整应用(带路由、状态管理、后端)
CT: 测试 → 浏览器 → 一个只有目标组件的沙箱页面
CT 更像是把 Vitest + jsdom 的组合,换成了真实浏览器。渲染结果是真的,布局计算是真的,CSS 也是真的。这就是它比 jsdom 方案值钱的地方。
组件挂载机制:mount() 背后发生了什么
CT 的核心 API 就一个:mount()。看着简单。
ts
// React 组件测试
import { test, expect } from '@playwright/experimental-ct-react'
import { UserCard } from './UserCard'
test('渲染用户名', async ({ mount }) => {
const component = await mount(<UserCard name="张三" />)
await expect(component).toContainText('张三')
})
但这个 mount() 背后的链路比你想的长得多。
编译阶段
Playwright CT 启动时会跑一个 Vite dev server。你的组件代码、依赖、CSS 都走 Vite 的编译管线。这意味着:
- 支持 TypeScript、JSX/TSX、Vue SFC
- 支持 CSS Modules、Tailwind、PostCSS
- HMR 是关掉的(测试场景没意义)
它会生成一个特殊的入口文件,大概长这样:
ts
// Playwright 内部生成的入口(简化版)
import { render, unmount } from '@playwright/ct-core/react'
// 监听来自 Node.js 端的消息
window.__playwright_mount = async (component, props, slots) => {
const rootEl = document.getElementById('root')
// 真正调用 React/Vue 的渲染函数
render(rootEl, component, props)
return rootEl
}
注意这个 window.__playwright_mount------Node.js 端的测试代码调 mount() 时,实际上是通过 page.evaluate() 把组件信息序列化传给浏览器,浏览器端再执行真正的框架渲染。
React vs Vue 挂载差异
React 和 Vue 的挂载逻辑不一样,Playwright 内部做了适配。
React 这边:
tsx
// Playwright 内部的 React mount 适配(简化)
import { createRoot } from 'react-dom/client'
let root = null
export function render(element, component, props) {
root = createRoot(element)
root.render(createElement(component, props))
// ⚠️ React 18 的 createRoot 是异步批量更新
// mount() 返回时组件可能还没渲染完
// Playwright 内部会等 requestAnimationFrame 确保渲染落地
}
export function unmount() {
root?.unmount() // 测试之间清理
}
Vue 这边多了个 createApp:
ts
// Vue mount 适配(简化)
import { createApp, h } from 'vue'
let app = null
export function render(element, component, props) {
app = createApp({
render: () => h(component, props)
})
// Vue 的 mount 是同步的,调完 DOM 就有了
app.mount(element)
}
export function unmount() {
app?.unmount()
}
Vue 的 app.mount() 是同步的,挂载完 DOM 立刻就绑定了。React 18 的 createRoot 是异步批量更新,Playwright 得额外等一帧确保渲染完成。这个差异在写测试时基本感知不到(mount() 返回的 Promise 已经帮你处理了),但如果你在调试奇怪的时序问题,知道这点有用。
传 props、slots、事件
Vue 的 slots 和 React 的 children 走的序列化路径不同。props 里如果有函数(比如事件回调),没法直接序列化传给浏览器。Playwright 的处理方式是在 Node.js 端创建一个代理,通过消息通道桥接:
tsx
// 测试里写回调
test('点击触发事件', async ({ mount }) => {
let clicked = false
const component = await mount(
<Button onClick={() => { clicked = true }} />
// ⚠️ 这个箭头函数不是直接传给浏览器的
// Playwright 会在浏览器端创建一个 proxy 函数
// 浏览器端触发 → 通过 CDP 通知 Node.js 端 → 执行你的回调
)
await component.click()
expect(clicked).toBe(true)
})
这个桥接机制大部分时候透明的。但有个边界情况:回调里如果要拿浏览器端的对象(比如原生 Event 对象的某些属性),可能拿不到。因为跨进程序列化有损。之前项目里碰到过一次,event.currentTarget 传过来是 null,排查了半天才意识到是序列化的问题。
视觉回归快照:看起来简单,CI 里全是坑
CT 模式的杀手级功能之一就是视觉回归测试。截个图,和基线对比,像素级发现 UI 变化。
基本用法不复杂:
ts
test('UserCard 视觉一致性', async ({ mount }) => {
const component = await mount(
<UserCard name="张三" avatar="/test-avatar.png" role="管理员" />
)
// 等字体加载完,不然截图大概率不一致
await page.waitForLoadState('networkidle')
await expect(component).toHaveScreenshot('user-card.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 像素差异
})
})
本地跑,一切正常。推到 CI 上,翻车开始。
字体渲染:最常见的翻车原因
同一套代码,Mac 上截图和 Linux CI 上截图,字体渲染就是不一样。操作系统的字体光栅化引擎不同、抗锯齿策略不同、甚至默认字体都不同。
解决办法有几个层次:
yaml
# GitHub Actions 示例
jobs:
visual-test:
# 方案一:用 Playwright 官方 Docker 镜像(推荐)
# 字体环境固定,渲染结果可复现
container:
image: mcr.microsoft.com/playwright:v1.42.0-jammy
steps:
- uses: actions/checkout@v4
- name: Install deps
run: npm ci
# 方案二:如果不用 Docker,至少装上常用字体
# - name: Install fonts
# run: |
# apt-get update
# apt-get install -y fonts-noto fonts-noto-cjk
- name: Run CT visual tests
run: npx playwright test --project=ct
我个人更倾向 Docker 方案。原因很实际------你在本地 debug 的时候可以用同一个镜像跑,和 CI 环境完全一致。fonts-noto 那套方案能解决大部分问题,但偶尔还是有细微差异,够你抓狂的。
快照基线管理
基线图片(baseline snapshots)要不要提交到 Git?
两派观点。我的看法:提交。原因:
bash
提交基线到 Git:
✅ PR review 时能直接看到 UI 变化的 diff
✅ 新成员 clone 下来就能跑
❌ 仓库体积会膨胀(每个快照几十 KB,组件多了加起来不少)
用外部存储(S3/OSS):
✅ 仓库干净
❌ CI 配置复杂度翻倍
❌ 本地跑测试还得拉远程基线
如果组件数量在 200 个以内,直接提交到 Git 问题不大。超过这个量级再考虑外部存储。
更新基线的工作流
这是个容易被忽略但很重要的工程问题:谁来更新基线?怎么更新?
json
// package.json
{
"scripts": {
"test:ct": "playwright test --project=ct",
"test:ct:update": "playwright test --project=ct --update-snapshots",
"test:ct:report": "playwright show-report"
}
}
CI 里的策略建议是这样:
yaml
# CI 中视觉测试失败时,把 diff 图上传为 artifact
- name: Run visual tests
run: npx playwright test --project=ct
continue-on-error: true
- name: Upload visual diff
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diff-report
path: test-results/
retention-days: 7
测试挂了,开发者下载 artifact,看看 diff 图是不是预期内的变化。是的话,本地跑 --update-snapshots 更新基线,提交。不是的话,说明 UI 出了 bug。
这套流程不算优雅,但够用。比起那些自动更新基线的方案(见过有人在 CI 里自动 commit 更新后的快照),安全得多。自动更新听着爽,但等你哪天真有个 UI bug 被自动"更新"掉的时候,你就知道肉疼了。
playwright-ct.config 的关键配置
CT 模式的配置和普通 E2E 不一样,有几个参数容易忽略:
ts
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react'
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.tsx', // 和 E2E 测试文件区分开
use: {
ctPort: 3100, // dev server 端口,别和你本地开发服务冲突
ctViteConfig: {
// 可以传 Vite 配置
// 比如你用了 alias,这里也得配
resolve: {
alias: {
'@': '/src',
},
},
},
},
// 视觉测试只跑一个浏览器就行,跨浏览器渲染差异太大
// 多浏览器 = 多套基线 = 维护噩梦
projects: [
{
name: 'ct',
use: {
...devices['Desktop Chrome'],
// 固定视口大小,不然截图尺寸不一致
viewport: { width: 1280, height: 720 },
},
},
],
// 快照配置
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
animations: 'disabled', // 关掉动画,不然截图时机不稳定
},
},
})
animations: 'disabled' 这个一定要开。之前不知道这个配置,有个带 transition 的组件,CI 上截图有时候截到动画中间帧,测试随机挂。
什么时候该用 CT,什么时候别用
说了这么多好处,也得说说不适合的场景。
适合 CT 的:
- 设计系统 / 组件库------每个组件独立,视觉一致性是核心需求
- 复杂交互组件------比如日期选择器、富文本编辑器,jsdom 模拟不了真实行为
- 需要验证 CSS 表现的场景------布局、响应式断点、主题切换
不适合的:
- 纯逻辑组件(没啥 UI),用 Vitest 就够了,没必要启动浏览器
- 需要完整应用上下文的测试(路由跳转、全局状态),这是 E2E 的活
- 组件依赖大量外部服务的 mock,CT 里 mock 网络请求比 E2E 麻烦
还有个现实问题:CT 模式目前还是 experimental 前缀。API 可能变。我在生产项目里用了大半年,核心功能稳定,但边角 case 偶尔踩到。比如 Vue 的 <Teleport> 组件在 CT 里表现就有点奇怪,渲染到 body 之后截图范围可能对不上。
聊到这
Playwright CT 的思路其实挺对的------组件测试就应该跑在真实浏览器里。jsdom 模拟得再好,CSS 布局和视觉渲染是模拟不了的。
但"对"和"好用"之间还有段距离。配置那套东西、CI 里的字体问题、快照基线管理,每一项都是工程成本。如果你在做组件库或者设计系统,这些成本值得投入。如果只是普通业务项目里几个页面组件,Vitest + Testing Library 可能性价比更高。
视觉回归这块,技术上已经成熟了,卡的主要是流程。谁来 review diff、谁来决定更新基线、怎么避免基线腐化------这些不是技术问题,是团队协作问题。工具给你的是能力,怎么用好还是得靠人。