news 2026/9/7 14:47:46

Playwright 模拟浏览器 API:用 addInitScript 测试实验性与只读浏览器特性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Playwright 模拟浏览器 API:用 addInitScript 测试实验性与只读浏览器特性

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),并监听chargingchangelevelchange事件刷新界面。

创建 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 }); });

关键点有两个:

  1. 定位到原型cookieEnabled定义在Navigator.prototype上而非实例上,必须对Object.getPrototypeOf(navigator)操作才能覆盖实例读取;
  2. configurable 前提:若目标属性被声明为configurable: falsedefineProperty会抛出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 对象挂到windowwindow.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 的完整调用链,理解"为什么它一定先于页面脚本执行":

  1. 客户端入口:client/page.ts 中Page.addInitScript先把函数/源码序列化为字符串(evaluationScript),再经 RPC 通道this._channel.addInitScript({ source })发给服务端;
  2. 服务端登记:server/page.ts 与 server/browserContext.ts 将脚本存入上下文级列表——这意味着同一 context 下之后打开的每个页面、以及页面的每次导航/每个 iframe,都会自动带上这些脚本;
  3. 浏览器端下发:各浏览器驱动各自实现注入——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.tsPage.addInitScript客户端实现
packages/playwright-core/src/server/chromium/crPage.tsChromium 端注入实现

【免费下载链接】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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 14:46:35

自媒体多平台分发插件技术拆解:从API对接到自动化发布

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 14:40:54

从AI助手到组织资产:提示词、工作流与知识库的沉淀方法论

我见过太多这样的场景:团队里总有一两个“AI 用得特别好”的同事,做方案、写邮件、拉数据的速度快得吓人。但只要这个人外出培训或者休个假,其他人的效率立刻就掉回来,仿佛那个“高光时刻”从来没存在过。 我后来想明白一件事&am…

作者头像 李华
网站建设 2026/9/7 14:38:57

区间测速技术规范解读:从平均速度计算到系统运维实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华