☰
Playwright E2E测试实践:从用例设计到CI流水线稳定运行
2026/10/26 16:03:13 网站建设 项目流程

E2E 测试这个词,干过几年后端或者全栈的人基本都听过,但真正在项目里把它跑起来、跑得稳、跑得有用的团队,说实话不算多。很多人对 E2E 的印象停留在"又慢又脆,除了给老板演示没啥用",也有人觉得"单元测试都写了,还要 E2E 干嘛"。这些想法我都经历过,直到我认认真真用 Playwright 搭过一套端到端测试体系,并且把它接进 CI 每天跑,我才意识到:E2E 不是万能的,但它能抓住单元测试和接口测试都抓不住的那类问题——用户真实操作路径上的集成故障。这篇文章,我把我从选型、设计目录结构、写用例、管理 profile 配置文件,到进 CI 之后踩坑排查的全过程,原原本本写出来,给正在犹豫要不要引入 E2E、或者已经引入但是用例天天挂的团队一个参考。

1. 为什么我建议团队把 E2E 测试放进 CI 流水线

1.1 E2E 测试到底是什么、解决什么问题

E2E 全称 End-to-End,中文叫端到端测试,意思是站在真实用户的角度,通过浏览器、客户端或者真实网络环境,把一条完整的业务链路从头到尾走一遍。举个例子:用户打开登录页,输入账号密码,点击登录,进入控制台,创建一条订单,退出登录。这一个流程里涉及前端页面渲染、JavaScript 逻辑、API 请求、后端业务处理、数据库读写,甚至可能还有消息队列和第三方服务。单元测试只验证某个函数"输入 A 得到 B",接口测试只验证某个接口"发请求 C 得到响应 D",但这条链路串起来的时候,可能出现前端传参字段名错了、后端返回结构改了但前端没适配、某个中间状态被缓存放错了,这一类问题只有在真实跑一遍完整流程时才会暴露。

E2E 测试解决的就是这个"集成断层"问题。它不关心某个模块内部实现得好不好,它只关心一件事:从用户视角出发,整个系统能不能协同工作,完成用户想要的结果。这就像验收一辆车,零部件单独测都是好的,但装在一起能不能正常点火、挂挡、上路,必须整车跑一圈才知道。E2E 就是汽车出厂前的那一圈试驾。

1.2 E2E 测试的适用场景和收益

我在多个项目里落地 E2E 后的体感是:它最适合用在核心业务流程比较固定的系统上,比如电商的下单支付流程、后台管理系统的关键操作链路、SaaS 产品的核心订阅流程。这些流程一旦坏了,业务直接受损,用户马上流失,而且靠人工回归测试,每次版本上线都要花半天到一天时间,效率极低。

引入 E2E 之后,最直接的收益是回归测试成本大幅下降。以前发版前要拉上测试同学手动把核心流程点一遍,现在只要触发 CI 流水线跑一遍 E2E 用例,十几分钟出结果,挂了还能直接看到是哪个步骤失败、页面上报了什么错。另一个隐性收益是倒逼设计规范化。E2E 用例要稳定运行,页面元素必须有稳定的标识,比如>await page.locator('#username').fill('admin'); await page.locator('#password').fill('123456'); await page.locator('button[type="submit"]').click(); await page.locator('.order-btn').click();

这种写法的致命问题是,当 UI 稍微调整,比如#username改成了#name,你需要去所有用例里全局搜索替换。如果用例有一百个,光是改这种重复逻辑就能改到怀疑人生。而且这种写法把"页面长什么样"和"业务要做什么"完全耦合在一起,可读性也差。

Page Object 模式(PO 模式)就是解决这个问题的。每个页面封装成一个类,页面上的关键元素和操作动作作为类的方法。测试用例里只调用方法,不直接接触选择器。这样 UI 变化只影响页面类,业务断言保持不变。写起来是这种感觉:

// pages/login.page.ts export class LoginPage { constructor(private page: Page) {} async goto() { await this.page.goto('/login'); } async login(username: string, password: string) { await this.page.locator('#username').fill(username); await this.page.locator('#password').fill(password); await this.page.locator('button[type="submit"]').click(); await this.page.waitForURL('/dashboard'); } }

用例文件里调用:

test('用户登录后进入控制台', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('admin', '123456'); await expect(page).toHaveURL(/\/dashboard/); });

这套模式我强烈建议团队在第一天就引入,它前期会多花一点封装的时间,但后期维护成本低一个量级。页面的大多数操作其实是固定套路:打开页面、填写表单、点击按钮、等待结果。把固定套路沉淀在 Page 类里,新人接手时也只需要翻开 pages 目录就知道每个页面有哪些操作,不用每个用例都从头看一遍。

3. 核心实操:从零搭建一套可用的 E2E 测试框架

3.1 初始化项目和安装依赖

我先说下环境要求:Node.js 建议 18 以上,npm 或者 pnpm 都行,我用的是 npm。第一步先建目录,初始化 package.json:

mkdir e2e && cd e2e npm init -y npm install -D @playwright/test @types/node typescript

安装完 Playwright 之后,还需要安装浏览器内核。这里要注意,Playwright 并不是用你本机装的 Chrome,它会下载自己管理的浏览器实例,这样版本可控、环境隔离,不会受你本机浏览器升级的影响。

npx playwright install chromium

如果想测 Firefox 和 WebKit,再执行npx playwright install firefox webkit。我平时主力只跑 Chromium,因为项目的用户分布中 Chrome 系浏览器占比最高,Firefox 和 WebKit 我放在发布前全量回归时跑,CI 里并行执行,时间成本也没有想象中高。

接下来生成 playwright.config.ts。初始化配置文件我建议用命令生成,再手动修改参数:

npx playwright test --config=playwright.config.ts --generate-config

不过实际上更快的办法是自己创建一个配置文件,内容不复杂。我的一份基础配置长这样:

import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 60000, // 单用例超时 60 秒 expect: { timeout: 10000 }, // 断言超时 10 秒 fullyParallel: true, // 用例并行执行 retries: process.env.CI ? 2 : 0, // CI 环境下失败重试 2 次 workers: process.env.CI ? 2 : undefined, reporter: [ ['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }] ], use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', headless: true, screenshot: 'only-on-failure', video: 'retain-on-failure', trace: 'retain-on-failure', viewport: { width: 1440, height: 900 }, }, projects: [ { name: 'chromium', use: { browserName: 'chromium' }, }, ], });

这些配置的意思是我逐个解释一下。timeout指的是整个测试用例的超时时间,如果一个用例从开始到结束超过 60 秒还没完成,就判定失败,防止用例卡死拖慢整体进度。expect.timeout是断言的超时时间,比如await expect(page.locator('.success')).toBeVisible()这个操作最多等 10 秒。retries是失败重试次数,CI 环境下很多失败是网络抖动或者环境准备问题导致的,重试两次能筛掉大部分偶发问题。screenshot、video、trace这三个配置对排查问题至关重要——失败时自动截图、录视频、保存网络与页面快照,出了错打开 HTML 报告就能看到当时的现场,不用再连猜带蒙。baseURL是基础地址,配合配置文件里的 profile 使用,切换环境只需要改环境变量,不需要改动用例代码。

3.2 编写第一个 E2E 用例:登录到创建订单

基础配置搞定之后,我来写一个实际的用例。以电商后台的"登录后创建订单"为例,这个用例覆盖了登录流程、页面跳转、表单提交、列表数据校验,属于 E2E 中最典型的一类。

先创建页面封装:

// pages/login.page.ts import { Page, expect } from '@playwright/test'; export class LoginPage { constructor(private page: Page) {} async goto() { await this.page.goto('/login'); await expect(this.page.locator('h1')).toContainText('登录'); } async login(username: string, password: string) { await this.page.locator('[data-testid="username"]').fill(username); await this.page.locator('[data-testid="password"]').fill(password); await this.page.locator('[data-testid="login-submit"]').click(); await this.page.waitForURL('/dashboard'); } }
// pages/order.page.ts import { Page, expect } from '@playwright/test'; export class OrderPage { constructor(private page: Page) {} async gotoNewOrder() { await this.page.goto('/orders/new'); } async createOrder(customerName: string, amount: string) { await this.page.locator('[data-testid="customer-name"]').fill(customerName); await this.page.locator('[data-testid="order-amount"]').fill(amount); await this.page.locator('[data-testid="order-submit"]').click(); await expect(this.page.locator('[data-testid="success-toast"]')).toBeVisible(); } async verifyOrderInList(customerName: string) { await this.page.goto('/orders'); await expect(this.page.locator('[data-testid="order-item"]', { hasText: customerName })).toBeVisible(); } }

然后写用例:

// tests/order.spec.ts import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; import { OrderPage } from '../pages/order.page'; test.describe('订单核心链路', () => { test('登录并创建一条订单', async ({ page }) => { // 登录 const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('admin', 'admin123'); // 创建订单 const orderPage = new OrderPage(page); await orderPage.gotoNewOrder(); await orderPage.createOrder('张三', '199.00'); // 列表验证 await orderPage.verifyOrderInList('张三'); }); });

写这个用例时有几个细节值得强调。第一,选择器全部使用>npx playwright test

默认跑test目录下所有.spec.ts文件。我只想跑单个文件:

npx playwright test tests/order.spec.ts

想带 UI 调试模式看每一步执行过程:

npx playwright test tests/order.spec.ts --headed

3.3 profile(配置文件)如何管理多环境

现在来说"e2e profile1"这个热搜词里的关键点。为什么需要 profile 管理?因为你的代码不可能只跑一套环境。开发环境、测试环境、预发布环境、生产环境,它们的域名不同、测试账号不同、有些功能开关也不同。如果每次都手动改配置文件里的 BASE_URL,第一效率低,第二容易改错然后一把梭把测试跑到了生产环境上,后果不堪设想。

我的做法是用 TypeScript 文件来定义不同的 profile,然后在playwright.config.ts里动态读取环境变量来选择加载哪个 profile。

// config/profile.base.ts export const baseConfig = { timeout: 60000, screenshot: 'only-on-failure' as const, video: 'retain-on-failure' as const, trace: 'retain-on-failure' as const, };
// config/profile.dev.ts import { baseConfig } from './profile.base'; export default { ...baseConfig, baseURL: 'http://localhost:3000', users: { admin: { username: 'admin', password: 'dev123' }, }, };
// config/profile.staging.ts import { baseConfig } from './profile.base'; export default { ...baseConfig, baseURL: 'https://staging.example.com', users: { admin: { username: 'admin-staging', password: 'staging123' }, }, };

然后在 playwright.config.ts 里动态加载:

import { defineConfig } from '@playwright/test'; import profileDev from './config/profile.dev'; import profileStaging from './config/profile.staging'; const PROFILE_MAP = { dev: profileDev, staging: profileStaging, }; const profileName = process.env.E2E_PROFILE || 'dev'; const activeProfile = PROFILE_MAP[profileName] || profileDev; export default defineConfig({ use: { baseURL: activeProfile.baseURL, ... }, });

运行的时候指定 profile 即可:

E2E_PROFILE=dev npx playwright test E2E_PROFILE=staging npx playwright test

这个方案的核心思路就是:用例代码永远不知道自己在跑哪个环境,它只认 baseURL 和注入的账号数据。环境切换完全靠运行时环境变量控制。测试数据也建议用 profile 里配置的账号,不要用测试用例里硬编码的账号,这样切换环境后自动化脚本可以配套切换数据,而不用改动代码。

有个坑我要特别提一句:不要在 profile 文件里暴露真实的生产环境密码,更不要提交到 Git 仓库。我见过有团队把 production 的账号密码明文写在 profile 文件里,然后仓库是公开的,这种泄密风险非常致命。正确做法是用环境变量或者 secrets 管理工具(如 CI 平台的 secret 功能)来注入生产环境凭据,profile 文件只保留非敏感配置和账号占位符。

3.4 数据清理与测试数据准备

E2E 测试和单元测试一个很大的区别是:E2E 会产生真实的数据。比如你创建了一个订单,这个订单就写进了数据库。如果你每次跑测试都创建新订单,数据库里的垃圾数据会越积越多,而且下次跑测试时可能因为数据重复而导致断言失败。因此在设计 E2E 测试时,测试数据的管理方案必须提前想清楚。

我的经验是两条路结合走。对于可以安全删除的数据,测试前置阶段通过接口直接造数,测试结束通过接口清理数据。比如创建订单之前,先通过后端 API 把测试用的客户数据准备好,测试跑完再把这条客户数据删掉。这样避免每次都走 UI 去填一大堆表单,速度也快很多。对于删除操作会影响业务流程的数据,比如支付成功的订单,就不要强行清理,而是通过测试标识来区分,比如客户名称统一加前缀E2E_TEST_,后续统计时一眼就能识别和过滤。

造数和清理数据我都会封装在 fixtures 里,让用例代码干净整洁。例如:

// fixtures/order.fixture.ts import { APIRequestContext } from '@playwright/test'; export async function createTestOrder(apiContext: APIRequestContext, customerName: string) { const res = await apiContext.post('/api/orders', { data: { customerName, amount: 100 }, }); return res.json(); } export async function cleanupTestOrder(apiContext: APIRequestContext, orderId: string) { await apiContext.delete(`/api/orders/${orderId}`); }

如果你的后端还没有提供完备的管理 API,也可以直接连测试数据库的数据表做清理,但这需要测试环境有直接的数据库访问权限,而且操作的时候要格外小心,别把其他同事正在用的数据弄没了。

4. 常见问题与排查技巧实录

4.1 用例不稳定:元素定位失败的排查

我刚开始推 E2E 的时候,团队里最大的抵触声音是"用例三天两头挂,挂得莫名其妙"。后来我复盘了所有失败用例,发现 80% 的失败集中在元素定位上。典型的报错是:

Timeout 30000ms exceeded. waiting for locator('[data-testid="submit"]') to be visible

这种超时一般有四种原因。第一是选择器写错了,元素根本没有这个 testid。排查方法是打开浏览器的开发者工具,在 Console 里直接执行document.querySelectorAll('[data-testid="submit"]')确认存在性。第二是页面渲染是异步的,元素确实存在但还没出现在 DOM 里,或者虽然存在但被隐藏了,Playwright 的自动等待会等到可操作才继续。第三是有多个元素匹配了同一个选择器,Playwright 会直接报错并提示你使用更精确的选择器。第四是页面发生了意料之外的跳转,比如按钮点击后报错被拦截到了 404 页面,后续元素自然找不到。

排查这类问题,我先教大家一个终极利器——Playwright 的 trace 报告。在配置里打开 trace 之后,每次失败用例都会生成一份完整的执行记录,包括每一步的 DOM 快照、网络请求、控制台日志、浏览器截图。你打开 report 页面,能看到页面在失败那一瞬间的真实状态。对比"预期的状态"和"实际的状态",很快就能定位原因。

还有个我踩过很多次的小坑:有时候元素定位超时是因为页面上有多个 tab 或者 iframe,Playwright 默认只在主 frame 里查找元素。如果页面内嵌了 iframe,要用page.frameLocator('#iframe-id').locator(...)这种方式才能定位到。这个在写用例的时候就要提前意识到,不要等到失败了再排查。

4.2 跑完一套用例需要太久怎么办

E2E 测试慢是个绕不开的话题。一个用例如果涉及完整的登录、创建订单、列表验证,大概需要 20 到 50 秒。一百个用例就是半小时以上,这在 CI 流水线里是难以接受的。我用三个手段来解决。

第一个手段是并行。Playwright 的fullyParallel: true和workers参数可以控制并发线程数。CI 机器上我实测 8 个 worker 并行跑,100 个用例的全量回归大概 8 到 12 分钟。要注意的是并行度不是越高越好,worker 数越高,对 CPU、内存、数据库的压力越大,如果被测应用扛不住并发操作,反而会增加失败率。我一般建议从 4 个 worker 开始压测,找到当前机器和应用的稳定阈值。

第二个手段是借助过 Web 认证跳过重复登录。如果一个用例从一个已经登录好的状态开始,能省掉每个用例登录的 5 到 10 秒。Playwright 的storageState功能支持把登录后的 cookie 保存起来,每个用例复用。实现方式是在全局 setup 里登录一次并保存状态,然后在项目配置里指定 storageState:

// global-setup.ts import { chromium } from '@playwright/test'; export default async function globalSetup() { const browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); await page.goto('/login'); await page.locator('[data-testid="username"]').fill('admin'); await page.locator('[data-testid="password"]').fill('admin123'); await page.locator('[data-testid="login-submit"]').click(); await page.waitForURL('/dashboard'); await context.storageState({ path: 'auth-state.json' }); await browser.close(); }

配置里启用:

projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'auth-state.json', }, }, ],

第三个手段是给用例分级。我把用例分成 smoke、regression、full 三档。smoke 用例只覆盖最核心的链路,每次 PR 合并时跑,控制在 15 分钟以内;regression 覆盖核心业务的所有关键路径,每天深夜跑一次;full 是在大版本发布前手动触发,全量回归。这样既保证了核心链路的快速反馈,又兼顾了全量回归的安全性。

4.3 CI 中运行 E2E 的注意事项

把 E2E 跑在本地容易,跑在 CI 里你会发现很多新问题。我从 GitHub Actions 和 GitLab CI 里都踩过不少坑,整理几个印象深刻的点。

CI 环境的最大特点是资源受限、网络不稳定、默认没有图形界面。首先确保你用的是 headless 模式,不然连图形环境都没有。其次,CI 上要注意服务依赖的启动顺序。如果 E2E 测试的是部署好的环境,要等待服务健康检查通过之后再开始跑测试。我的做法是在 CI 脚本里加一个 curl 探活循环,服务没起来绝不跑测试。

until curl -sf http://staging.example.com/health; do echo "waiting for service..." sleep 5 done

另一个容易忽略的问题是 CI 上跑 Playwright 安装浏览器依赖。npx playwright install chromium在安装时会下载浏览器二进制文件并安装系统依赖库,在 Ubuntu 镜像里可能还需要:

npx playwright install --with-deps chromium

如果不加--with-deps,可能出现浏览器能启动但页面渲染异常之类的怪问题。

视频录制和 trace 在 CI 上会消耗不少磁盘空间,需要设置保留策略。我一般只保存失败用例的视频和 trace,成功用例不保留,减少无用文件。

再就是账密等敏感信息的注入。CI 上配置环境变量比本地更严谨,不要直接写在.gitlab-ci.yml或者 GitHub Actions workflow 文件里,要从 CI 平台的 Secret 管理界面设置,然后在配置文件里读取:

const stagingAdminPassword = process.env.STAGING_ADMIN_PASSWORD;

这条做不好,等于把生产环境的钥匙挂在门口。

4.4 一个让我排查了一整天的问题:并发修改同一份数据

最后分享一个我在落地 E2E 后遇到的最头疼的问题,这个问题让我意识到 E2E 不仅仅是写用例,更要对业务数据的隔离性有深刻理解。

我的测试场景是:多 worker 并行跑一组用例,每个用例都会创建一个新的客户和订单,然后断言列表里出现对应数据。表面上看起来每个用例的数据是独立的,客户名用的是随机后缀,但实际运行的时候总是出现偶发性的断言失败。排查了很久,通过 trace 发现,在用例 A 创建的订单提交后,列表页却显示的是用例 B 刚创建的订单,一瞬间数据串了。进一步看后端日志,发现两个用例创建订单时使用同一个测试客户 ID,而客户 ID 是业务里的唯一标识,数据库在写入时做了去重,导致前一个用例的数据被后一个覆盖了。

这个问题的根因是测试数据没有做到完全隔离。虽然客户名称随机了,但客户 ID 的生成规则在业务层有缓存,两个请求在前端展示时拿到了相同的数据。解决方案是保证每个用例从头到尾独立创建客户,使用时间戳或者 UUID 作为客户主键,同时避免多个 worker 共享同一份测试账号数据(比如同一个账号同时只能创建一个订单之类)。对于必须共享数据的场景,就把相关用例串行执行,不要放在并行 worker 里。

这个问题给我的启发是:E2E 测试框架本身的稳定性之外,业务数据模型对测试友好度的影响极大。一个设计良好的业务系统,应该天然支持多租户或者说数据隔离,这不仅是测试的需求,更是实际生产环境里多用户并发操作的基础能力。

5. 给新手的建议:E2E 落地路线图

如果你所在的团队还没有引入 E2E,我建议不要一上来就追求大而全。先选一条最核心的业务链路,用 Playwright 写好一个用例,接进 CI 跑起来,让团队感受到"每次提交都自动跑核心流程"带来的安全感。然后再逐步扩展用例覆盖范围,把 Page Object 结构建起来,把 profile 配置理清楚。这个循序渐进的过程既能避免一开始就陷入高维护成本的泥沼,也能让团队成员逐步建立对 E2E 的信心。

我个人在实际操作中最大的体会是:E2E 测试不是写得越多越好,而是每一层都要精。与其花几天时间写几十个低层次的重复用例,不如花同样的时间把 10 个核心业务的链路用例打磨到稳定通过。稳定性是 E2E 的生命线,一个三天两头莫名失败的测试套件,最后的结果就是被团队嫌弃、废弃,前期的所有投入都打了水漂。

最后再分享一个小技巧:把 E2E 用例当成产品来维护,写代码的时候可以随手 add 注释说明"这个用例覆盖了什么业务场景、失败时最可能是什么模块出了问题"。将来故障时,任何人看到失败的用例,都能秒懂它在守护什么业务,排查起来事半功倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询