ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Playwright 实战:一套代码统一 Web、移动端与 API 自动化测试

Playwright 实战:一套代码统一 Web、移动端与 API 自动化测试

做自动化测试这些年,我见过太多同学在 Web、移动端、API 自动化测试之间来回切换,最终维护三套脚本、三套报告和三条流水线,整个人被重复劳动拖垮。后来认真啃下 Playwright,我才意识到,这三件事其实是可以被同一套代码逻辑串起来的。这篇博文不是官方文档的复述,而是我从选型、搭框架到处理真实业务场景的完整记录,适合正在做接口与 Web 自动化融合、又对移动端 H5 测试有需求的测试开发和前端工程师,也适合想用更少代码换取更高维护性的团队负责人参考。

这几年我前后参与过不少自动化项目,踩过的坑比写过的用例还多。越到后期我越确信,衡量一个自动化框架的好坏,不是看它能跑多少个用例,而是看它能不能用一套思维模型覆盖大多数测试活动。Playwright 的价值就在这里:它把浏览器自动化、接口请求、移动端模拟整合成同一套 API,底层有统一的会话管理,上层有统一的断言和报告。这不仅仅是一个工具选型问题,而是测试体系设计思路的变化。

1. 为什么是 Playwright:三端统一不是营销话术

1.1 三套脚本时代,问题从来不在脚本数量

早年间我接手的自动化项目,典型状态是“三座孤岛”。Web 端用老牌的 WebDriver 方案跑 UI 回归,接口测试用现成的 HTTP 请求类库维护一套独立脚本,移动端 H5 又要靠浏览器远程调试协议另起炉灶。每一套脚本都有自己的登录态维护方式,都有自己的数据准备逻辑,连断言风格都南辕北辙。

最痛苦的是数据同步。Web 端测试在界面上注册了一个用户,接口测试那边为了造数据又要调用注册接口再走一遍;移动端 H5 想复用 Web 端登录态,基本只能靠手动复制 Cookie。于是大量时间不是花在编写用例上,而是花在解决“三套脚本各自为政”的兼容和同步问题上。测试人员看起来会很多工具,实际效率极低。

后来换了 Playwright,我才真正体会到,Web、移动端、API 自动化测试放在一个框架里不是不能做,关键是底层抽象要统一。Playwright 把一个浏览器页面、一个移动端浏览器模拟、一个 API 会话,都抽象成可以独立创建和销毁的“上下文”。你不需要为不同端准备不同的 SDK,只需要在同一个测试文件里,按需创建浏览器页面或 API 请求上下文。这个设计让“一套代码搞定三端”真正落地,而不是玩概念。

1.2 核心功臣:BrowserContext 与 APIRequestContext 的对称设计

先说 BrowserContext,它是 Playwright 里最值得理解的抽象。每一个 BrowserContext 都是全新的浏览器“隔间”,自带独立的 Cookie、存储和缓存。测试用例之间互不污染,移动端模拟只需要改 context 的参数,多用户登录场景也只需要创建多个 context。这个能力对自动化非常重要,因为它把“环境隔离”变成了开箱即用的能力,而不是靠你手动清理数据。

接口测试那边对应的是 APIRequestContext,它可以独立发起 HTTP 请求,也可以共享浏览器页面生成的登录态。这两个 Context 的对称设计,是 Playwright 能统一三端测试的核心。UI 测试里创建一个浏览器页面,本质上是在一个 BrowserContext 里操作真实浏览器;接口测试里创建一个 APIRequestContext,本质上是在同一个会话模型里做无头请求。两者都能读取和写入 storageState,所以“接口登录,UI 复用”只需要几行代码。

选用 Playwright 还有一个现实理由:它默认实现了自动等待。老式自动化的三大顽疾——找不到元素、元素被遮挡、动画未结束,在 Playwright 里大部分都能被 actionability 检查机制拦截在报错之前。框架会主动等待元素可见、可点击、稳定,再执行操作。它固然不是万能的,但确实把“写 sleep 等页面加载”的坏习惯从根上断掉了,这也是我后来向团队力推 Playwright 的原因。

2. 从零搭建一套可复用的自动化工程

2.1 目录规划与依赖安装

很多初学者止步于安装依赖,因为 Playwright 第一次运行时还要下载浏览器内核。以 JavaScript/TypeScript 生态为例,先初始化一个 Node 项目,然后安装测试库和浏览器。实际执行效果如下:

mkdir auto-test && cd auto-test npm init -y npm install -D @playwright/test npx playwright install

执行完 npx playwright install 之后,框架会安装配套的浏览器内核。这一步在网络不稳定时容易慢,但属于一次性成本。如果公司网络受限,可以配置镜像变量,也可以后续在 CI 机器上提前缓存。我个人的建议是装完之后先运行官方自带的检测命令,确认环境可跑再写业务代码。

项目目录不必花哨,保持清晰即可。我会按职责拆成几个目录,让接口用例、UI 用例和工具函数互不纠缠:

auto-test/ ├── playwright.config.ts ├── package.json ├── tests/ │ ├── api/ │ ├── web/ │ └── mobile/ ├── utils/ │ ├── login.ts │ └──>import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30_000, expect: { timeout: 5_000, }, use: { baseURL: 'https://example.com', locale: 'zh-CN', viewport: { width: 1280, height: 720 }, screenshot: 'only-on-failure', trace: 'retain-on-failure', }, reporter: [['html', { open: 'never' }]], });

baseURL 是一个容易被低估的配置。所有用例里的页面跳转和接口请求都可以写成相对路径,Playwright 会自动拼接 baseURL。这样从测试环境切到预发环境,只需要改配置,不用全局替换 URL。timeout 和 expect.timeout 也要区分清楚,前者是操作超时,后者是断言轮询超时,两者作用层面不同,在排查稳定性问题时会非常有用。

2.3 第一个 Web 自动化脚本:把“登录-操作-断言”写透

配置完成之后,来写第一个真实可用的 Web 自动化用例。目标场景是:打开登录页,输入账号密码,点击登录,验证跳转后的页面出现用户昵称。这是最普通的 UI 用例,但写好它需要理解 Playwright 的自动等待和断言重试机制:

import { test, expect } from '@playwright/test'; test('用户登录后能看到个人中心入口', async ({ page }) => { await page.goto('/login'); await page.getByLabel('用户名').fill('tester001'); await page.getByLabel('密码').fill('passw0rd'); await page.getByRole('button', { name: '登 录' }).click(); await expect(page.getByText('欢迎回来,tester001')).toBeVisible(); });

这里没有一句 sleep,也没有显式等待某个元素出现。page.getByLabel 会优先找关联表单标签的输入框,getByRole 会按无障碍角色定位按钮,click 操作前 Playwright 会自动等待按钮可点。断言 toBeVisible 也不是只判断一次,而是在 expect.timeout 时间内反复检查元素是否出现。

这套写法的收益在复杂交互里体现得更明显。以前自动化最怕的“点击后加载 5 秒”场景,现在框架会等在元素真正可交互之后才继续。我常和团队说,Playwright 把“自动化测试的稳定性”问题从代码层转移到了框架能力层,你要做的不是写延时补丁,而是把选择器写准确,把交互动作描述清楚。

3. 接口自动化:从单一接口到全链路数据打通

3.1 用 APIRequestContext 直接做接口测试

很多人以为 Playwright 只能玩浏览器,其实它的 request fixture 单独拿来做接口自动化完全够用,而且比额外引入一个 HTTP 客户端更划算。分享一段实际的接口用例写法:

import { test, expect } from '@playwright/test'; test('查询订单列表接口返回结构正确', async ({ request }) => { const response = await request.get('/api/orders', { params: { page: 1, pageSize: 20, status: 'paid', }, }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.code).toBe(0); expect(Array.isArray(body.data.list)).toBeTruthy(); expect(body.data.list.length).toBeGreaterThan(0); });

这段代码最让人觉得省心的地方是,接口请求也走同一个配置体系,baseURL 统一生效,超时时间统一管理。request fixture 在每次测试结束时会自动清理连接,不会出现“上一个用例的连接还挂着”这种历史遗留问题。断言方式和 UI 测试完全一致,团队成员不需要切换思维模型。

接口测试还有一个隐藏优势:运行速度远快于 UI 测试,适合放在流水线的前置阶段。我的习惯是先用接口用例把核心业务逻辑覆盖住,再用 UI 用例验证关键用户路径。这样既拿到覆盖率,又不会让整套回归用例慢到没人愿意跑。

3.2 登录态复用:接口认证结果注入浏览器

接口自动化和 Web 自动化真正融合的标志,是登录态的无缝共享。传统做法是在 UI 用例里一步步输入账号密码,每跑一个用例就登录一次,非常耗时。Playwright 提供的方案是先用接口会话完成登录,把认证结果保存成 storageState,再在浏览器测试里复用它。

实际操作是这样的:

import { test as setup } from '@playwright/test'; import { request } from '@playwright/test'; setup('获取并保存登录态', async () => { const context = await request.newContext({ baseURL: 'https://example.com', }); const response = await context.post('/api/login', { data: { username: 'tester001', password: 'passw0rd', }, }); expect(response.ok()).toBeTruthy(); await context.storageState({ path: 'state/user01.json' }); await context.dispose(); });

接着在 web 用例的配置里声明沿用这份登录态:

test.use({ storageState: 'state/user01.json' }); test('登录后直接进入订单页', async ({ page }) => { await page.goto('/orders'); await expect(page.getByText('我的订单')).toBeVisible(); });

这个方案帮我省掉了大量重复登录代码。更重要的是,登录逻辑如果哪天从账号密码改成了验证码或单点登录,只需改 setup 阶段那一个文件,所有 UI 用例都会跟着生效。这就是“接口测试为 UI 测试提供数据底座”的现实意义。

3.3 接口 Mock 与请求拦截:前端联调利器

接口自动化不只是“调通真实接口”,有时还需要制造特殊响应。比如验证支付超时提示、异常错误码的展示,直接改后端数据往往成本极高。Playwright 的 page.route 可以在浏览器里拦截请求并重写响应,让你用一条用例验证多种异常场景。

比如模拟订单详情接口返回 5xx 错误:

test('接口异常时展示兜底提示', async ({ page }) => { await page.route('**/api/order/**', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'internal error' }), }); }); await page.goto('/order/123456'); await expect(page.getByText('服务繁忙,请稍后重试')).toBeVisible(); });

这种能力在日常手工测试里很难稳定复现,但自动化里只是几行代码的事。拦截请求还有一个轻量用法:记录页面发出的所有请求,用来断言某个埋点上报是否触发。把请求拦截器和 APIRequestContext 配合起来,等于拥有了一张可以随时改造数据流的“路由表”,接口层的可控性一下子就上来了。

4. 移动端:从模拟器到真机调试的完整思路

4.1 移动端 H5 模拟:一套浏览器内核跑出手机视口

移动端自动化有两条路线:一条是模拟移动端浏览器环境,一条是接入真机设备。Playwright 在第一种路线上做得非常丝滑。很多移动端 H5 问题本质上是布局和人机交互问题,页面加载逻辑和 Web 是同源的,所以先在浏览器模拟里验证关键链路,性价比最高。

移动端环境的本质变化有三个:视口变小、触摸事件代替鼠标事件、可能还有设备像素比差异。下面这段配置能快速进入“手机模式”:

import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { baseURL: 'https://example.com', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true, userAgent: 'PlaywrightMobile/1.0 Mobile Safari-like Browser', }, });

你还可以直接在某个用例文件里用 test.use 局部覆盖这些参数,实现一份代码跑 Web 与移动端两种视口。我在项目里常用这种“参数化移动端”策略,让 Web 核心用例在桌面视口和移动视口各跑一遍,用不到 10% 的额外代码量把 H5 兼容性覆盖住。

4.2 移动端交互验证:触摸手势与滚动加载

移动端用例里最常见的操作是轻点、滚动和下拉刷新。Playwright 的 tap 可以直接通过 touchscreen 接口触发,适合验证按钮点击和 tab 切换;滚动加载则可以通过触发滚动事件或使用鼠标滚轮模拟。不过我不建议把太多精力花在还原复杂手势上,H5 测试的核心是业务流程可见、可操作、数据加载正常。

一个典型的移动端用例长这样:

import { test, expect } from '@playwright/test'; test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); test('首页 banner 在移动端展示并可跳转', async ({ page }) => { await page.goto('/'); const banner = page.getByTestId('home-banner'); await expect(banner).toBeVisible(); await banner.tap(); await expect(page).toHaveURL(/\/activities\//); });

这里有一个很容易踩的坑:桌面端用 click 习惯了,直接迁移到移动端模拟后,某些 touch 相关组件不响应 mouse 事件。所以移动端用例里尽量用 tap 模拟真实触屏操作,不要复用桌面端的 click。否则用例在本地模拟里看着过了,一到真机趋势就不一样。

4.3 真机调试:从模拟世界到现实设备的连接方案

模拟环境不能覆盖全部真实问题,比如弱网、刘海屏、系统 WebView 差异。Playwright 本身不直接管理移动真机,但可以借助远程调试协议,把设备上的浏览器页面暴露给 Playwright 控制。思路是:让真机打开 H5 页面,通过端口转发建立连接,再用 connectOverCDP 控制它。

流程大概分三步:

1. 开启安卓设备开发者选项和 USB 调试 2. 用设备调试工具转发 target 端口到本机 3. 在 Playwright 脚本里用 chromium.connectOverCDP 连接这个端口

这种方式特别适合“只做验证不做流程回归”的场景,比如真机上点开页面看是否白屏、关键布局是否错位、某个 WebView 能力是否可用。真机用例因为环境不稳定,不适合大规模跑流水线,更适合在发版前做冒烟验证。想从模拟一步到位切真机是不现实的,正确姿势是“模拟做主要回归,真机做最终验证”,两层互补才稳。

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

5.1 元素明明存在却一直报“不可交互”

这是 Playwright 头号新手问题。报错说元素在页面上,但点击超时。原因大多是元素被其他层叠元素遮挡,或者它确实可见但还处于不可点击状态。第一个排查手段是看失败截图和 trace,很多情况下截图里一眼就能看到弹窗遮住了按钮。

如果排查截图发现元素没有遮挡,那就要检查样式上的 pointer-events、position 和过渡动画。比如按钮在某个动画结束后才能接收事件,Playwright 会一直等到可点超时。我的习惯是先用 page.locator 的 waitFor 确认元素已挂载,再判断是否有不可见遮罩层;实在不行用 expect 的 toBeEnabled 先确认可操作性,逐步缩小问题边界。

5.2 自动等待失灵:超时与不断重试的真相

自动等待不是魔法,它只在 Playwright 能识别“稳定”的条件时有效。遇到接口响应慢、页面长列表滚动加载,自动等待也可能失效。官方推荐的做法是尽量少用自定义等待,改用较高的 expect 超时和合理的操作重试策略,而不是再写一个暴力 sleep。

针对轮询场景,我建议把核心断言的 timeout 单独调大,让框架反复检查最终状态:

await expect(page.getByText('数据加载完成')).toBeVisible({ timeout: 15_000, });

这比“固定等五秒再断言”健壮得多。框架在轮询期间只要目标出现,用例立刻继续,不会浪费额外时间。调试时如果怀疑某个断言一直在重试,可以打开 trace viewer 查看每一帧的状态变化,比猜代码高效得多。

5.3 并发跑用例时的资源隔离问题

Playwright 默认并行执行多个文件,每个 worker 拥有独立浏览器进程。资源隔离做得好的时候,并发是优势;一旦用例里引入了共享状态,比如写同一个文件、共用同一个测试账号,就会出现莫名其妙的不稳定。

我踩过一次特典型的坑:多个用例并发登录同一个账号,结果其中一个把用户挤下线,其他全部失败。后来改成每个测试环境账号只允许单 worker 使用,或者在用例里构造唯一用户名,这个问题才彻底消失。另一个常见的坑是并发时写相同目录下的临时文件,建议所有临时文件都写入系统临时目录,并根据测试文件名称加后缀区分。

5.4 调试工具箱:trace、慢动作与有头模式

如果只能给新手推荐一个调试功能,我一定推荐 trace 文件。它记录页面每个操作的快照、网络请求和控制台日志,比任何日志定位问题都直观。配置里可以打开 retain-on-failure 模式,只有失败用例才保留 trace 文件,既不拖慢正常执行,又能在失败时拿到完整现场。

遇到复杂交互时,我会单独跑一个有头模式加慢动作的调试命令,相当于给浏览器装上“慢速观察镜”:

npx playwright test --headed --slow-mo=500

参数 slow-mo 表示每个操作后停顿 500ms,可以清楚看到按钮点击前后发生了哪些界面反馈。还有一个 --debug 参数会在每个操作前暂停,等于一步一步确认状态。这套组合拳足够解决绝大多数脚本调试问题,没必要在一开始给项目塞进各种自定义调试轮子。

最后聊聊我的个人体会

单测、接口测试、UI 测试之间本来不应该有严格壁垒,真正的壁垒来自工具分裂和思维分裂。Playwright 让我看到一种更朴素的工程方式:同一个团队,同一套 API,同一份报告,把 Web、移动端 H5 和接口自动化放在一起维护。它的自动等待和 Context 设计帮我砍掉了大量低质量代码,也让新成员上手周期从几周压缩到几天。

如果现在让我给刚开始尝试这套方案的人一个建议,我会说:不要一上来就追求“三端全部自动化”,而是先从一个最薄弱的环节入手。比如先用 APIRequestContext 把接口用例沉淀下来,再把登录态注入到 Web 用例,最后把移动端视口作为同一条用例的另一种参数。这个路线每一阶段都有可见收益,团队更容易建立信心,最终才能把“一套代码搞定三端”变成一个稳定可持续的工程现实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进