去年我接手过一个半死不活的测试项目:本地跑 Playwright 用例能全绿,但是一推到 GitHub 就红得彻底。说法挺多——有人说是网络问题,有人说是浏览器没装,也有人建议重试几次碰碰运气。后来我花了一下午把 GitHub Actions 工作流完整搭起来,才搞清楚大部分失败根本不是测试代码的问题,而是整套环境根本没有被当作“工程”来对待。
这篇文章就围绕这个主题展开:怎么把 Playwright 从“你电脑上的脚本”变成“团队里每个人都能依赖的工程质量闸门”。
你需要解决的问题很明确:在 GitHub Actions 上安装依赖、准备浏览器、跑测试、出报告、失败可排查、日常可维护。下面这些内容是我从零开始一步步做过、踩过坑之后整理出来的完整路径,适合已经开始写 E2E 测试、但还没把 CI 真正用起来的开发者。如果你正处于“本地能跑、CI 不能跑”的状态,这篇文章应该能直接帮你找到原因和方案。
1. 为什么必须把 Playwright 搬进 CI
先说一个反常识的结论:本地能跑通的测试,在 CI 里跑不通才是常态。这不是 Playwright 的锅,也不是 GitHub Actions 的锅,而是因为我们下意识把“本地环境”当成了“标准环境”。你的电脑上有特定的 Node 版本、特定的浏览器缓存、特定的一套系统依赖,这些在全新的 runner 上全部不存在。CI 的根本价值就在这里:用一个干净环境证明你的测试脚本真正可复现,而不是碰巧能跑。
1.1 本地通过不代表测试有效
我在本地遇到过这样的尴尬:用例里写了await page.waitForTimeout(5000),本地因为跑得快,5 秒刚好等到了数据渲染。推到 CI 后,runner 性能波动、接口响应慢,5 秒不够,用例直接超时。代码一行没改,结果截然不同。
类似的问题还有:本地访问http://localhost:3000能正常打开页面,CI 里却没有这个服务;本地装过 Chrome 的某个版本,CI 里没装,启动浏览器直接报 Executable doesn‘t exist。这些问题本质上都不是测试代码错了,而是环境假设不成立。CI 的作用就是把所有隐藏的环境假设全部逼出来,逼到明面上解决。
提示:如果你发现自己需要在本地反复安装依赖、手动清理数据库、单独设置环境变量才能跑通测试,请把这些步骤全部写进 CI 工作流。CI 跑不通的部分,多半就是测试还不够工程化的部分。
1.2 工程化测试的四个基本盘
我把一个真正可用的 Playwright CI 拆成了四个基本要求,缺一个都会在后续使用中出问题:
- 可复现:每次运行时依赖版本、浏览器版本、Node 版本完全一致。
- 可观察:失败时能看到日志、截图、录屏、trace 文件,而不是只有一行“Error”。
- 可追溯:每次运行的时间和触发原因是明确的,能关联到具体代码提交。
- 可维护:新增用例、调整运行频率、切换环境时不改基础设施代码。
这四个基本盘在本地做起来感觉不到,但放到 CI 里就是工作流文件的每一段。后面每一节都会围绕它们展开。
1.3 什么场景最需要这个方案
如果你的项目处于下面任一状态,这套 CI 方案都值得动手搭建:
- 团队超过两个人,测试结果不能只依赖某一个人的电脑。
- 上线前手工回归花的时间越来越长,需要把核心流程自动化。
- 测试数量少说有三五十条,跑一次超过五分钟,需要并行化。
- 每次改造前端架构,都担心把老功能改坏。
满足上面任何一条,都说明你已经不是“试试看”的阶段了。先把 CI 做好,后续所有关于测试的工作才有意义。
2. 从零准备:先让 Playwright 在本地跑起来
很多人喜欢一上来就写 GitHub Actions 的 YAML,结果 workflow 写得再漂亮,本地跑不起来一切都是空中楼阁。我建议按照“本地先跑通 → 再来 CI”的顺序走,不是因为 CI 复杂,而是因为本地环境能帮你最快发现测试代码本身的问题。
2.1 初始化测试项目与目录约定
在已有项目的根目录下初始化一个 E2E 测试目录是比较常见的做法。我会新建一个e2e文件夹,在里面单独维护一套package.json,避免和业务代码的依赖混在一起。
mkdir e2e && cd e2e npm init -y npm install -D @playwright/test npx playwright install chromium以上这三步做完就有了最基础的运行环境。npx playwright install chromium这一步是为了把 Chromium 浏览器下载到本地缓存目录。如果不执行这步,直接跑测试会遇到缺少浏览器可执行文件的问题。
初始化的时候我通常会顺手建好目录结构:
e2e/ ├── tests/ # 测试用例 ├── fixtures/ # 登录态、测试数据等公共内容 ├── utils/ # 自定义工具函数 ├── playwright.config.ts └── package.json这样做的目的是让用例、公共方法、配置各自有明确的归属。测试到后面数量多了,目录混乱带来的维护成本远大于写几行代码的时间。
2.2 写首条可用用例而不是 demo
初始化工具会生成一个示例 spec 文件,很多人就顺手把它留着了。我的建议是先删掉,然后写一条针对自己项目核心页面的冒烟用例,哪怕只是“打开首页并确认标题出现”。
import { test, expect } from '@playwright/test'; test(‘首页核心内容展示正常’, async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: '欢迎回来' })).toBeVisible(); });这段代码看起来简单,但它确定了三件事:测试入口的导航方式、断言方式、以及运行环境里BASE_URL怎么配置。在本地跑的时候可以先用相对路径,在 CI 里再通过环境变量注入完整地址。
运行下面命令,看到绿色通过,再继续往下做:
DOMAIN=http://localhost:3000 npx playwright test --reporter=list2.3 提交前的两个关键检查
如果你用的代码仓库是 Git 管理的,提交之前检查两件事:
第一,确认package-lock.json已经生成并提交到仓库。这个文件锁定了依赖的精确版本,CI 里执行npm ci的时候不会遇到本地和远端依赖不一致的问题。
第二,确认.gitignore里写入了下面这些目录:
node_modules/ test-results/ playwright-report/ blob-report/test-results和playwright-report是运行后生成的结果目录,不需要进版本库。如果不提前忽略,很容易在一次 CI 失败后把一堆二进制文件误提交上去。
3. 第一个 GitHub Actions 工作流:从能跑升级到跑得稳
本地测试稳定之后,下一步就是上 CI。我先给一个最小可用的工作流文件,然后逐行解释每段代码的用意。
3.1 最小可用 workflow 结构
name: e2e on: push: branches: [main] pull_request: jobs: e2e: runs-on: ubuntu-latest steps: - name: 拉取代码 uses: actions/checkout@v4 - name: 安装 Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' cache-dependency-path: e2e/package-lock.json - name: 安装 NPM 依赖 working-directory: e2e run: npm ci - name: 安装 Chromium 和系统依赖 working-directory: e2e run: npx playwright install --with-deps chromium - name: 运行 Playwright 测试 working-directory: e2e env: DOMAIN: ${{ secrets.E2E_BASE_URL }} run: npx playwright test - name: 上传测试报告 if: always() uses: actions/upload-artifact@v4 with: name: playwright-report path: e2e/playwright-report/这个文件对新手来说已经可以跑了,但每一行背后都有讲究。
3.2 浏览器安装与系统依赖为什么容易功败垂成
npx playwright install --with-deps chromium是最容易踩坑的一行。Playwright 不仅仅是下载浏览器二进制文件,在 Ubuntu 环境下还需要安装一堆系统动态库。比如 Chromium 依赖libnss3、libatk、libgbm等库,缺失时会报各种 verbose 错误。
如果只用npx playwright install chromium,许多情况下测试会启动失败,错误信息却指向“浏览器崩溃”或“无法连接”。加了--with-deps后,Playwright 会调用系统的包管理工具安装全部系统依赖,避免这类问题。虽然这一步会在 CI 上多花 1-2 分钟,但换来的是稳定。
3.3 第一次跑通后的三个细节
第一份工作流跑通之后,有三件事值得马上处理:
第一,添加concurrency配置,避免同一个人连续推送多次 commit 时排队运行大量测试:
concurrency: group: e2e-${{ github.ref }} cancel-in-progress: true效果是同一分支上的新提交会自动取消还在跑的旧任务,省时间也省流量。
第二,给测试报告加上失败时的日志输出。我会在运行测试那一步加上一个参数:
npx playwright test --reporter=list这样 Console 上能看到每一步用例的执行状态。默认的html报告虽然信息全,但在 Actions 日志界面里不如list直观。正式报告仍然会上传成 artifact,不会丢失。
第三,确认上传报告的if: always()没有写成if: success()。这一点特别关键,它决定了测试失败时报告是否留存。默认情况下某一步失败后,后面的步骤不会继续执行。如果你老老实实写着正常上传,测试一旦失败,报告就永远看不到。always()会强制让上传动作无论测试通过还是失败都执行。
注意:上传测试结果除了
playwright-report/目录,我还会额外上传test-results/目录。这个目录里包含失败时的截图、trace 文件和录屏,排障时比 HTML 报告更有用。
4. 让 CI 高效:缓存依赖、并行分片和定时运行
能跑稳定之后就要考虑效率。如果一套测试跑 40 分钟,再稳定的 CI 也留不住。合理使用缓存和并行能力,通常能把时间压缩一半以上。
4.1 浏览器和 Node 依赖的双层缓存
很多人在actions/setup-node里配了cache: 'npm',以为依赖的问题就搞定了。实际上这只缓存了node_modules目录。Playwright 浏览器下载的二进制文件缓存地址并不在node_modules里,默认在用户目录下的~/.cache/ms-playwright。
想让浏览器缓存也生效,需要单独再增加一个缓存步骤:
- name: 缓存 Playwright 浏览器 uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }}这里用package-lock.json的哈希作为 key 的一部分,原因是 Playwright 版本升级时浏览器二进制文件通常也会变化。如果 key 一直不变,旧浏览器缓存会在新版本下失效,反而更难排查。
这个缓存步骤能省掉 CI 里重新下载浏览器的时间。我实测下来,在冷启动 runner 上下载 Chromium 大约 50-100 秒,有了缓存后这一步几乎可以忽略。
提示:如果你想彻底避开下载浏览器这一步,也可以直接使用 Playwright 官方发布的 Docker 镜像作为 runner 的基础镜像。这种做法适合用自托管 runner 或者容器化程度较高的团队,但对大部分 GitHub Actions 用户来说,加缓存已经足够。
4.2 并行跑用例:shard 与 matrix 的组合
当用例数量超过 50 条,单线程跑测试明显不够用了。Playwright 本身支持多个 worker 并行,但在一台 runner 上 worker 数量受限于机器的核数和资源。想进一步横向扩展,就需要使用 shard 分片能力。
strategy: matrix: shard: [1, 2, 3, 4] steps: - name: 运行 Playwright 测试 working-directory: e2e run: npx playwright test --shard=${{ matrix.shard }}/4这段代码会创建 4 个 runner,每个 runner 跑 1/4 的测试用例。如果原本整套测试要跑 8 分钟,分片之后理论上可以压缩到 2-3 分钟。
分片的前提是用例彼此独立。如果某个用例依赖前一个用例产生的数据,比如前一个用例创建了一个用户,后一个用例再用这个用户登录,这种串联在分片后会出现随机失败。工程化改造的重点之一,就是让每条用例都能独立准备自己的测试数据、独立清理数据。
分片之后一定要保留每个分片的测试报告。可以在上传 artifact 的时候用name: playwright-report-${{ matrix.shard }}区分,也可以把所有分片合并成一个最终报告,后者实现成本稍高,但查看体验更好。前期建议先做前者,够用。
4.3 定时回归与手动触发:别让 PR 承担所有压力
PR 触发适合代码变更时验证新改动,但 E2E 测试还有一个更重要的用途:定时回归。很多 bug 是依赖注入、第三方服务变更、环境数据变化导致的,这些你不推代码也可能发生。
在 workflow 的on里加上schedule:
on: schedule: - cron: '0 2 * * *'这行表示每天凌晨 2 点跑一次完整回归。实际使用时建议避开业务高峰期,选择凌晨或早上,减少测试环境的数据噪声。
同时加上手动触发:
on: workflow_dispatch:加了这一段后,任何人需要临时在某个分支跑一次全量 E2E,可以直接在 Actions 页面点击按钮触发。这个操作在排查问题时尤其有用,不用重新 push 一个空 commit。
5. 失败以后怎么办:打通排障链路
CI 的意义不是让所有测试都通过,而是在失败时能快速定位问题。很多团队卡在“知道测试挂了,但不知道挂在哪里”的阶段,恰恰是报告和 trace 配置不到位。Playwright 提供了一套相当好的排障工具,问题在于如何把它接入工作流。
5.1 用 trace 回放测试的每一步
Playwright 的 trace 功能会记录测试运行过程中的页面快照、DOM 状态、网络请求和浏览器控制台日志,之后可以在 Trace Viewer 里逐步回放。这个能力对偶发问题简直是救命工具。
我通常在playwright.config.ts中这样配置:
use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }retain-on-failure的含义是只有测试失败才会保留 trace 和视频。这样不会在每次运行时都产生大量文件,同时确保失败时有最完整的信息。trace 文件会输出到test-results目录,随 CI 报告一起上传。
提示:全量开启 trace 会降低测试速度,尤其是长页面复杂交互场景。前期建议只保留失败时的 trace。如果遇到极其难复现的偶发问题,再临时开一次
trace: 'on'获取完整链路。
5.2 artifacts 上传的时机坑
很多人的报告上传失败,是因为没有理解 GitHub Actions 的步骤执行规则。步骤默认情况下会从前往后执行,一旦某一步返回非零退出码,后续步骤就会被跳过。
测试失败时npx playwright test返回非零,紧接着的上传步骤如果写的是普通run,就不会执行。解决方式就是第一节里强调的if: always()。上传步骤一定不要省略这个条件。
我看到的另一个问题是只上传 HTML 报告不传test-results。HTML 报告能显示标题和堆栈,但如果想看 trace、视频、截图,就需要在 Trace Viewer 里打不开。建议两个目录一起传:
- name: 上传失败结果 if: always() uses: actions/upload-artifact@v4 with: name: test-results path: e2e/test-results/5.3 从失败日志开始的定位流程
实际排查问题的顺序我通常是这样:
拿到失败信息后先看是哪个文件哪一行报错。多数情况错误信息会在Error下面直接给出原因,比如定位元素超时、网络请求失败、断言不匹配。接着看截图。截图上如果是页面空白,基本是环境地址或服务启动问题;如果是某个元素缺失,就需要进一步看 trace 里在前几步发生了什么。
trace 比日志更接近真实场景。点击某个步骤能直接看到该时刻的页面快照、请求状态和网络返回,很多看起来像是测试代码错误的问题,实际上是对应用行为理解不足。比如前端用了弹窗确认,但代码还在等页面跳转,trace 里能看到很清楚的前因后果。
6. 工程化治理:让测试策略可维护
工作流能跑、能并行、失败了能排查,这只是工具层面到位。再往下走,要把这套方案当作长期基础设施来维护,需要考虑策略分层、环境隔离和稳定性治理。
6.1 用标签划分冒烟测试与完整回归
所有用例放在一个运行池里跑,随着数量增长会越来越不可控。我的做法是给用例打上标签,按风险等级分层运行。
test('登录后核心导航可用 @smoke', async ({ page }) => { // ... }); test('结算流程走通 @regression', async ({ page }) => { // ... });然后通过命令行参数分别跑不同层级的用例:
npx playwright test --grep "@smoke" npx playwright test --grep "@regression"策略上,PR 里的 check 只跑@smoke,夜间 schedule 跑完整@regression。这样既保证了每次代码合并前有关键流程验证,又不会让开发等太久。
6.2 BASE_URL 与环境隔离
E2E 测试最忌讳在共享环境里乱跑。多个并行分片同时往环境里写数据时,会出现互相踩数据的情况,测出来不是被测系统的错,而是测试之间互相干扰。
我的做法是把运行环境地址抽成环境变量,在 workflow 里通过 GitHub 的 repository settings 配置:
env: DOMAIN: ${{ vars.E2E_BASE_URL }}每个环境(比如 staging 和 production)提前创建独立的变量组。用例内部永远不要硬编码地址,统一从process.env.DOMAIN读取。
6.3 三种最常见的用例不稳定根源及治理
我遇到过最多的三类失败原因基本与业务代码无关:
第一类是硬等待。waitForTimeout(3000)写起来很容易,却是一个定时炸弹。改用显式等待是正确路径,例如expect(locator).toBeVisible()。它的内部会反复轮询直到条件满足,时间上更合理。如果需要等请求发起,可以page.waitForResponse()精准等待某一个接口。
第二类是对动态数据做死断言。页面渲染的时间戳、生成订单号、随机用户名如果直接断言具体值,必然随机失败。正确做法是断言“数据结构”而不是“数据内容”。比如用expect(orderId).toMatch(/^ORD-\d+$/)判断订单号格式,而不是断言它等于某个固定字符串。
第三类是测试数据污染。用例执行失败后留下脏数据,下一次运行时产生冲突。解决方案是让每次产生的数据带唯一标识,比如在用户名后面追加Date.now()。用例结束后尽量清理,但如果清理逻辑本身不稳定,至少保证数据唯一性。
下面是我总结的一份常见问题速查表,可以当作排障手册:
| 现象 | 首选检查项 | 常见原因 |
|---|---|---|
| 报错 Executable doesn‘t exist | 检查浏览器安装步骤 | 只装了playwright没有执行install |
| 浏览器启动即崩溃 | 检查系统依赖是否完整 | 缺少--with-deps参数 |
| 页面定位超时 | 查看截图和网络请求 | BASE_URL 指向错误环境 |
| 首轮通过二轮失败 | 查看测试数据是否唯一 | 共享数据被并行分片互相覆盖 |
| CI 上比本地慢很多 | 检查 worker 数量和分片策略 | 单个 runner 资源不足,或依旧串行 |
| 失败后看不到报告 | 检查上传步骤条件 | 缺少if: always() |
| 缓存不生效 | 查看缓存 key 是否变化 | 路径写错或依赖文件未提交 |
这张表基本覆盖了我过去一年处理过的大部分 CI 问题,你可以直接把它复制到自己的项目文档里当作团队知识储备。
这套方案从初始化到工程化,每一步都不需要多复杂的知识,但每一步都需要耐心理清环境假设。我后来在团队里推行这套流程时,最明显的体验是:测试从“看运气”变成“看证据”,失败发生后反馈到开发的速度快了很多,质量讨论也有据可依。至于后面要不要继续做视觉回归、性能预算监控、跨浏览器矩阵,都是在这套 CI 骨架上自然长出来的能力,先把地基打稳,后面的事就顺了。