Playwright Component Testing 拆到底:组件怎么挂上去的,快照怎么在 CI 里不翻车
你写过单元测试,大概率用的 Jest + Testing Library。组件渲染在 jsdom 里,跑得飞快,但你心里清楚------jsdom 不是真浏览器。CSS 不生效,IntersectionObserver 要 mock,canvas 直接摆烂。
Playwright Component Testing(下面简称 CT)干的事情不一样:它把你的 React/Vue 组件丢进真实浏览器里跑。听起来像 E2E?不是。它没有完整的应用启动流程,只挂载你指定的那个组件。
这篇聊两件事:CT 模式下组件到底怎么挂上去的,以及视觉回归快照在 CI 里怎么搞才不会三天两头炸。
CT 的架构:三个进程在打配合
CT 跑起来之后,背后其实有三个角色:
scss
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Test Runner │────▶│ Dev Server │────▶│ Browser │
│ (Node.js) │ │ (Vite) │ │ (Chromium) │
└─────────────┘ └──────────────┘ └──────────────┘
│ │ │
测试代码 编译组件 真实渲染
断言逻辑 HMR/bundling 真实 DOM/CSS
Test Runner 就是 Playwright Test 那套,跑在 Node 里。Dev Server 默认是 Vite(也支持 Webpack,但说实话现在没什么理由选 Webpack 了)。Browser 是 Chromium/Firefox/WebKit,真家伙。
关键在于:你的测试代码跑在 Node 里,但组件渲染在浏览器里。这俩是通过 WebSocket 通信的。
这意味着什么?你在测试里 console.log 一个组件的 props,打印在终端。但组件内部的 console.log 打印在浏览器 DevTools 里。刚上手的时候在这个地方困惑过一阵。
组件挂载:mount 背后发生了什么
先看最简单的用法:
tsx
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react'
import { Button } from './Button'
test('点击后文案变化', async ({ mount }) => {
const component = await mount(<Button label="提交" />)
await expect(component).toContainText('提交')
await component.click()
await expect(component).toContainText('已提交')
})
看着跟 Testing Library 差不多对吧?但 mount 这一步,链路完全不同。
React 的挂载链路
mount(<Button label="提交" />) 执行时,大致经过这几步:
markdown
1. Playwright 把 JSX 序列化成一个描述对象(组件路径 + props)
2. 通过 WebSocket 发给浏览器端的 "mount handler"
3. 浏览器端拿到描述,动态 import 对应的组件模块
4. 调用 React.createElement + ReactDOM.createRoot 挂载到一个空的 #root 上
5. 返回一个 Locator 给 Node 端,后续操作都通过这个 Locator
关键代码藏在 playwright-ct-react 包的 registerSource 里:
tsx
// 简化版的浏览器端挂载逻辑
window.__playwright_mount = async (rootElement, component) => {
// component.type 是组件的 import 路径,不是组件本身
// Vite 已经帮你编译好了,这里直接 resolve
const Component = await __playwright_resolve(component.type)
const root = ReactDOM.createRoot(rootElement)
root.render(React.createElement(Component, component.props, ...component.children))
return root
}
注意那个 __playwright_resolve------你的组件路径是在编译阶段就确定的。Playwright 的 Vite 插件会扫描测试文件里所有的 import,提前打包好。所以如果你动态拼组件路径,是跑不通的。
Vue 的挂载链路
Vue 的流程类似,但多了一步:
ts
// Vue 的浏览器端挂载
window.__playwright_mount = async (rootElement, component) => {
const Component = await __playwright_resolve(component.type)
const app = createApp(Component, component.props)
// Vue 特有:可以注入 plugins、provide 等
if (component.hooksConfig) {
await applyHooks(app, component.hooksConfig)
}
app.mount(rootElement)
return app
}
Vue 比 React 多了个 hooksConfig,对应测试里的 beforeMount 钩子:
ts
// Vue CT 独有的能力:挂载前注入 router、pinia 等
test('带路由的页面组件', async ({ mount }) => {
const component = await mount(UserProfile, {
props: { userId: '123' },
hooksConfig: {
// 这个配置会传到浏览器端,在 mount 之前执行
router: true,
pinia: { initialState: { user: { name: 'test' } } }
}
})
})
不过这个 hooksConfig 需要你自己在 playwright/index.ts 里实现对应的 hook 处理逻辑。Playwright 不会帮你自动注入 Vue Router 或 Pinia------它只提供机制,策略你自己定。
一个常见的坑:样式隔离
CT 模式下,组件是挂载在一个空白 HTML 页面上的。你的全局样式、CSS reset、主题变量------统统没有。
tsx
// ❌ 组件依赖全局 CSS 变量,但 CT 模式下没加载
// 渲染出来的按钮是白底黑字,跟线上完全不一样
test('按钮样式', async ({ mount }) => {
const component = await mount(<ThemedButton />)
// 样式全是错的,测了个寂寞
})
// ✅ 在 playwright/index.tsx 里引入全局样式
// 这个文件是浏览器端的入口,这里 import 的样式会生效
import '../src/styles/globals.css'
import '../src/styles/theme.css'
playwright/index.tsx(React)或 playwright/index.ts(Vue)是浏览器端的入口文件。全局样式、Provider、插件都在这里搞。很多人第一次用 CT 时组件渲染得乱七八糟,十有八九是这个文件没配好。
视觉回归快照:原理不复杂,工程化才是坑
Playwright 的截图对比用起来一行代码的事:
ts
await expect(component).toHaveScreenshot('button-primary.png')
第一次跑,生成基准图。第二次跑,截新图,像素级对比。不一样就报错,同时生成三张图:expected、actual、diff。
对比算法
默认用的是 pixelmatch,逐像素比较。可以配容差:
ts
await expect(component).toHaveScreenshot('card.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 的像素差异
// 或者用绝对值
// maxDiffPixels: 100, // 允许 100 个像素不同
threshold: 0.2, // 单个像素的颜色容差(0~1)
})
threshold 是给单个像素用的,处理抗锯齿之类的细微差异。maxDiffPixelRatio 是全局的,多少比例的像素不同算"变了"。
这两个值怎么调,完全看你的场景。图表类组件建议放宽一些,纯文本布局可以严一点。没有万能参数,都是试出来的。
CI 集成:这才是真正花时间的地方
本地跑 CT 没什么问题。一上 CI 就各种翻车。
问题一:字体渲染差异
同一个组件,macOS 和 Linux 渲染出来的字体就是不一样。亚像素渲染、字体 hinting、默认字体族------全不同。
yaml
# GitHub Actions 示例
jobs:
visual-test:
# ✅ 固定操作系统版本,别用 latest
runs-on: ubuntu-22.04
container:
# ✅ 用 Playwright 官方镜像,字体和依赖都预装了
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test --project=ct
用 Playwright 官方 Docker 镜像是最稳的方案。它预装了各种字体包,渲染结果跟别的用同一镜像的环境高度一致。
但这就引出一个问题:本地开发用 macOS,CI 用 Linux 容器,基准图用谁的?
css
方案 A:基准图在 CI 上生成,本地开发只跑不对比
方案 B:本地也跑 Docker,保持环境一致
方案 C:维护两套基准图(别选这个,维护成本会让你后悔)
我个人倾向方案 A。基准图只在 CI 上生成和更新,提交到仓库里。本地开发时跑功能测试就行,视觉对比交给 CI。
问题二:快照更新的工作流
快照文件要不要提交到 Git?要。不然 CI 没有基准图可以对比。
但问题来了:快照更新的流程怎么搞?
yaml
# 快照更新的 CI workflow
name: Update Snapshots
on:
workflow_dispatch: # 手动触发
pull_request:
types: [labeled] # 或者打标签触发
jobs:
update:
if: github.event.label.name == 'update-snapshots'
runs-on: ubuntu-22.04
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- run: npm ci
- run: npx playwright test --update-snapshots
# 自动 commit 更新后的快照
- name: Commit updated snapshots
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "**/*.png"
git diff --staged --quiet || git commit -m "chore: update visual snapshots"
git push
这里有个取舍:自动 commit 快照更新方便是方便,但你得确保 review 流程能 cover 住。不然某个 PR 偷偷改了样式,自动更新快照,没人看 diff 就合了------视觉回归测试等于白做。
我见过比较靠谱的做法是:快照变化时 CI 把 diff 图片贴到 PR comment 里,reviewer 必须肉眼确认。
yaml
- name: Upload diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diff
path: test-results/ # Playwright 默认把 diff 图存这
retention-days: 7
问题三:CI 耗时优化
CT 比 Jest 慢,这没法回避。每个测试都要启动浏览器、编译组件、渲染、截图。一个 200 个组件的项目,全跑一遍可能要 5~10 分钟。
几个实际能压缩时间的手段:
ts
// playwright-ct.config.ts
export default defineConfig({
// 并行跑,worker 数量看 CI 机器配置
workers: process.env.CI ? 4 : undefined,
// 只跑 Chromium 就够了,视觉一致性不需要三个浏览器
projects: [
{
name: 'ct',
use: {
...devices['Desktop Chrome'],
// 固定视口,避免截图尺寸不一致
viewport: { width: 1280, height: 720 },
},
},
],
// Vite 配置
ctViteConfig: {
build: {
// 关掉 sourcemap,CI 上不需要
sourcemap: false,
},
},
})
另一个大招:只跑变更相关的测试。
yaml
# 只跑改动文件关联的 CT 测试
- name: Run affected tests
run: |
CHANGED=$(git diff --name-only origin/main...HEAD -- 'src/components/**')
if [ -n "$CHANGED" ]; then
# 把改动的组件路径转成测试文件路径
TEST_FILES=$(echo "$CHANGED" | sed 's/\.tsx$/.spec.tsx/' | tr '\n' ' ')
npx playwright test $TEST_FILES
else
echo "No component changes, skipping CT"
fi
粗暴但有效。精确的依赖分析可以用 madge 之类的工具做,但大部分项目用文件名匹配就够了。
CT 的边界:什么时候不该用
CT 不是万能的。几个场景它搞不定或者性价比不高:
路由跳转、多页面流程------这是 E2E 的活。CT 只能挂单个组件(或组件树),没有路由层。
复杂的后端交互 ------CT 里 mock API 比 E2E 还麻烦。组件内部的 fetch 在浏览器里执行,你得用 page.route() 拦截,不能用 Node 端的 mock。
ts
test('列表加载', async ({ mount, page }) => {
// 注意:API mock 要在 mount 之前设置
await page.route('/api/users', async route => {
await route.fulfill({
json: [{ id: 1, name: 'test' }]
})
})
const component = await mount(<UserList />)
await expect(component.getByText('test')).toBeVisible()
})
纯逻辑组件------如果一个组件没有视觉输出(比如一个纯粹管理状态的 Provider),用 Jest 测就行了,没必要启动浏览器。
我的一个经验法则:CT 适合测"长什么样"和"交互后变成什么样"。纯逻辑用 Jest,跨页面流程用 E2E。三层测试不是互相替代的关系。
聊到这
Playwright CT 这套东西,架构上挺优雅的------Vite 编译、真实浏览器渲染、Node 端断言,各司其职。但工程化层面的坑不少,尤其是视觉快照上 CI 之后,字体渲染、环境一致性、快照更新流程,每个都得花时间调。
我觉得它最大的价值不在于替代 Jest,而是补上了 Jest + jsdom 覆盖不到的那块------组件在真实浏览器里长什么样、交互起来对不对。如果你的项目有设计系统或者组件库,CT + 视觉快照这套组合拳值得投入。如果只是业务页面,E2E 可能性价比更高。
对了,CT 目前还是 @playwright/experimental-ct-*,带着 experimental 前缀。API 稳定性上偶尔会有 breaking change,升级的时候留意一下 changelog。