Playwright 模拟浏览器 API:用 addInitScript 测试实验性与只读浏览器特性
【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwright
当被测应用依赖浏览器原生特性(如 Battery Status API)时,这些 API 往往因浏览器支持不一致或属于实验性接口而无法被 Playwright 直接控制。本文以 Playwright 官方文档mock-browser-js.md为核心,完整讲解如何用Page.addInitScript在页面加载前注入 Mock 对象、如何处理只读属性、如何用exposeFunction校验 API 调用序列,以及如何构造可动态更新的 Mock 类来验证 UI 对事件变化的响应。文末结合examples/mock-battery示例工程与playwright-core源码,剖析 init script 的注入时机与各浏览器的底层实现。
读完本文后,你将能够:为任意浏览器 API 编写可复用的 Mock 脚本、验证页面对 API 的调用行为(golden 比对)、模拟状态变更并断言 UI 实时刷新,并理解这些机制在 Playwright 三层架构(客户端、服务端、浏览器端)中的实际调用链。
为什么需要 Mock 浏览器 API
Playwright 对绝大多数浏览器特性都提供了原生自动化能力(如通过 context 选项设置时区、地理位置、设备描述符等)。但存在两类特性 Playwright 不提供专用 API:
- 实验性 API:尚未被所有浏览器完整支持的接口;
- 只读 / 受限 API:属性在 JS 层面只读,无法直接赋值。
官方文档给出的思路是:既然无法"配置"这些 API,就在页面 JS 运行之前把 API 本身替换成 Mock。文档以 Battery Status API 为例——一个用于读取设备电量、充电状态和剩余时间的接口。典型应用会用navigator.getBattery()获取一个BatteryManager对象,读取level(0~1 的电量比例)、charging(是否插电)、chargingTime/dischargingTime(充/放电剩余秒数,未知时为Infinity),并监听chargingchange与levelchange事件刷新界面。
创建 Mock:在页面加载前注入
页面可能在加载早期就调用目标 API,因此所有 Mock 必须在页面任何脚本执行前就位。最简单的方式是调用Page.addInitScript——其回调会在该页面上之后加载的每个文档(包括 iframe)的脚本执行前运行一次:
await page.addInitScript(() => { const mockBattery = { level: 0.75, charging: true, chargingTime: 1800, dischargingTime: Infinity, addEventListener: () => { } }; // 覆盖方法,使其始终返回 mock 电池信息。 window.navigator.getBattery = async () => mockBattery; });注入完成后,即可正常导航页面并断言 UI 状态:
// 在每个测试前配置 mock API。 test.beforeEach(async ({ page }) => { await page.addInitScript(() => { const mockBattery = { level: 0.90, charging: true, chargingTime: 1800, // 秒 dischargingTime: Infinity, addEventListener: () => { } }; // 覆盖方法,使其始终返回 mock 电池信息。 window.navigator.getBattery = async () => mockBattery; }); }); test('show battery status', async ({ page }) => { await page.goto('/'); await expect(page.locator('.battery-percentage')).toHaveText('90%'); await expect(page.locator('.battery-status')).toHaveText('Adapter'); await expect(page.locator('.battery-fully')).toHaveText('00:30'); });chargingTime: 1800(30 分钟)正是断言00:30的来源:示例应用的 demo-battery-api/src/index.js 中的readBattery会把chargingTime换算成时:分格式写入.battery-fully元素,并根据charging决定.battery-status显示Adapter还是Battery。
实战细节(来自示例工程):
examples/mock-battery中的 show-battery-status.spec.js 在addInitScript里多了一步delete window.navigator.battery;。原因在于被测页面的 src/index.js 的探测顺序是先查同步属性navigator.battery(旧版 API),再回退到navigator.getBattery():if (navigator.battery) { readBattery(navigator.battery); } else if (navigator.getBattery) { navigator.getBattery().then(readBattery); } else { document.querySelector('.not-support').removeAttribute('hidden'); }若某些浏览器环境中
navigator.battery存在,mock 的getBattery()将永远不会被调用。因此 Mock 时应把被测应用会探测的所有入口都覆盖或删除,这是文档未展开、但可直接复用的重要经验。
该示例工程的运行方式也值得参考:playwright.config.js 配置了webServer,以 9900 端口启动静态服务器托管demo-battery-api;package.json 中start脚本为http-server -c-1 -p 9900 demo-battery-api,测试通过page.goto('/')访问该服务。
Mock 只读 API:Object.defineProperty
部分 API 是只读的,直接赋值无效。例如:
// 这一行不会产生任何效果。 navigator.cookieEnabled = true;但只要属性是configurable(可配置的),就可以用Object.defineProperty在原型上覆盖:
await page.addInitScript(() => { Object.defineProperty(Object.getPrototypeOf(navigator), 'cookieEnabled', { value: false }); });关键点有两个:
- 定位到原型:
cookieEnabled定义在Navigator.prototype上而非实例上,必须对Object.getPrototypeOf(navigator)操作才能覆盖实例读取; - configurable 前提:若目标属性被声明为
configurable: false,defineProperty会抛出TypeError。此时需要在属性被固化前介入,或改用其他注入手段。
验证 API 调用:记录调用并与期望序列比对
有时不仅要 Mock 返回值,还要确认页面确实发起了预期的一组 API 调用(不多不少、顺序正确)。做法是在 Mock 中记录每次方法调用,再通过Page.exposeFunction把消息回传到 Node.js 侧的测试代码中:
test('log battery calls', async ({ page }) => { const log = []; // 暴露一个函数,用于从页面把消息推送到 Node.js 脚本。 await page.exposeFunction('logCall', msg => log.push(msg)); await page.addInitScript(() => { const mockBattery = { level: 0.75, charging: true, chargingTime: 1800, dischargingTime: Infinity, // 记录 addEventListener 调用。 addEventListener: (name, cb) => logCall(`addEventListener:${name}`) }; // 覆盖方法,使其始终返回 mock 电池信息。 window.navigator.getBattery = async () => { logCall('getBattery'); return mockBattery; }; }); await page.goto('/'); await expect(page.locator('.battery-percentage')).toHaveText('75%'); // 将实际调用与期望的 golden 序列比对。 expect(log).toEqual([ 'getBattery', 'addEventListener:chargingchange', 'addEventListener:levelchange' ]); });示例工程中的 verify-calls.spec.js 在此基础上还做了一个有价值的补充断言:
expect(log).toEqual([ 'getBattery', 'addEventListener:chargingchange', 'addEventListener:levelchange' ]); log = []; // 重置日志 await page.evaluate(() => window.mockBattery._setLevel(0.275)); expect(log).toEqual([]); // getBattery 不会被再次调用,页面复用了缓存实例。这说明被测应用缓存了getBattery()返回的实例、只通过事件驱动刷新——Mock 不仅用于造假数据,还能验证应用对 API 的使用契约(例如"只调用一次、依赖事件通知")。
更新 Mock:模拟状态变化与事件通知
要验证应用能否正确响应电池状态更新,Mock 对象必须像真实浏览器实现一样向已注册的监听器派发事件。官方文档用一个带监听器队列的 Mock 类实现:
test('update battery status (no golden)', async ({ page }) => { await page.addInitScript(() => { // Mock 类:电池状态变化时通知对应的监听器。 class BatteryMock { level = 0.10; charging = false; chargingTime = 1800; dischargingTime = Infinity; _chargingListeners = []; _levelListeners = []; addEventListener(eventName, listener) { if (eventName === 'chargingchange') this._chargingListeners.push(listener); if (eventName === 'levelchange') this._levelListeners.push(listener); } // 以下方法由测试调用。 _setLevel(value) { this.level = value; this._levelListeners.forEach(cb => cb()); } _setCharging(value) { this.charging = value; this._chargingListeners.forEach(cb => cb()); } } const mockBattery = new BatteryMock(); // 覆盖方法,使其始终返回 mock 电池信息。 window.navigator.getBattery = async () => mockBattery; // 把 mock 对象挂到 window 上,方便测试访问。 window.mockBattery = mockBattery; }); await page.goto('/'); await expect(page.locator('.battery-percentage')).toHaveText('10%'); // 更新电量到 27.5% await page.evaluate(() => window.mockBattery._setLevel(0.275)); await expect(page.locator('.battery-percentage')).toHaveText('27.5%'); await expect(page.locator('.battery-status')).toHaveText('Battery'); // 模拟接入电源适配器 await page.evaluate(() => window.mockBattery._setCharging(true)); await expect(page.locator('.battery-status')).toHaveText('Adapter'); await expect(page.locator('.battery-fully')).toHaveText('00:30'); });这里的关键手法:
- 把 mock 对象挂到
window(window.mockBattery = mockBattery),使测试代码可通过page.evaluate直接调用其内部方法_setLevel/_setCharging,从而在页面生命周期内主动"拨动"状态; - 监听器队列 + 手动派发:
addEventListener只收集回调,_setLevel/_setCharging在修改字段后同步遍历调用——与浏览器真实事件时序(先改值再派发)一致; - 软断言依赖自动等待:
expect(...).toHaveText()会轮询直到状态刷新,天然适配异步事件派发。
对应示例 update-battery-status.spec.js 的断言序列与文档一致:先 10%,改电量后 27.5% 且状态变Battery,再插电后状态变Adapter、充满时间显示00:30。
源码视角:addInitScript 的注入链路
从playwright-core源码可以确认 init script 的完整调用链,理解"为什么它一定先于页面脚本执行":
- 客户端入口:client/page.ts 中
Page.addInitScript先把函数/源码序列化为字符串(evaluationScript),再经 RPC 通道this._channel.addInitScript({ source })发给服务端; - 服务端登记:server/page.ts 与 server/browserContext.ts 将脚本存入上下文级列表——这意味着同一 context 下之后打开的每个页面、以及页面的每次导航/每个 iframe,都会自动带上这些脚本;
- 浏览器端下发:各浏览器驱动各自实现注入——Chromium 走 CDP(crPage.ts 中
addInitScript,默认作用于mainworld),WebKit 走 wkPage.ts,Firefox 走 ffPage.ts。从源码结构看,这正是 Mock 能在页面任何业务脚本之前生效的底层保证:脚本在文档创建时由浏览器引擎注入,而非等页面加载后由 Playwright 执行。
另外注意 client/page.ts 支持options.exposeFunctions参数——addInitScript可显式携带需要在脚本中可用的exposeFunction,使"注入 + 回传"两类机制可以组合在同一脚本内。
适用边界与最佳实践
- 注入时机:
addInitScript必须在page.goto之前调用(或放入test.beforeEach),否则首屏脚本已执行,Mock 对首次调用无效; - 作用范围:
page.addInitScript只作用于当前页面;跨页面共享 Mock 时,从源码签名看(client/browserContext.ts 同样实现了addInitScript)可改用 context 级注入; - 覆盖所有探测入口:如前文
navigator.battery所示,务必检查被测代码的 API 回退链,删除或覆盖所有旁路; - 只读属性:优先尝试
Object.defineProperty于原型上,确认属性为 configurable; - 与网络 Mock 区分:本文针对的是浏览器内置 API;若需 Mock 的是 HTTP 请求,应使用 Playwright 的路由拦截能力,可参见 docs/src/mock.md。
参考文件
| 文件 | 说明 |
|---|---|
| docs/src/mock-browser-js.md | 本文核心依据的官方文档 |
| examples/mock-battery/playwright.config.js | 示例工程配置(webServer 9900 端口) |
| examples/mock-battery/tests/show-battery-status.spec.js | 静态 Mock + UI 断言,含delete navigator.battery技巧 |
| examples/mock-battery/tests/verify-calls.spec.js | 调用序列 golden 比对与缓存行为验证 |
| examples/mock-battery/tests/update-battery-status.spec.js | 可事件通知的 Mock 类与状态更新断言 |
| examples/mock-battery/demo-battery-api/src/index.js | 被测应用:电池 API 探测与渲染逻辑 |
| packages/playwright-core/src/client/page.ts | Page.addInitScript客户端实现 |
| packages/playwright-core/src/server/chromium/crPage.ts | Chromium 端注入实现 |
【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwright
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考