Playwright Component Testing 拆到底:组件怎么挂上去的,快照怎么在 CI 里不翻车

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。

相关推荐
左夕2 小时前
最基础的类型检测工具——typeof, instanceof
前端·javascript
yuki_uix2 小时前
递归:别再"展开脑补"了,学会"信任"才是关键
前端·javascript
用户5757303346244 小时前
🐱 从“猫厂”倒闭到“鸭子”横行:一篇让你笑出腹肌的 JS 面向对象指南
javascript
码路飞5 小时前
GPT-5.4 Computer Use 实战:3 步让 AI 操控浏览器帮你干活 🖥️
java·javascript
进击的尘埃5 小时前
Service Worker 离线缓存这事,没你想的那么简单
javascript
进击的尘埃5 小时前
HTTP/3 的多路复用和 QUIC 到底能让页面快多少?聊聊连接迁移和 0-RTT
javascript
Maxkim6 小时前
前端工程化落地指南:pnpm workspace + Monorepo 核心用法与实践
前端·javascript·架构
小兵张健18 小时前
开源 playwright-pool 会话池来了
前端·javascript·github
codingWhat21 小时前
介绍一个手势识别库——AlloyFinger
前端·javascript·vue.js