Playwright网络请求拦截与模拟:从原理到实战的完整指南
1. 项目概述:为什么我们需要拦截与模拟网络请求?
在自动化测试和网页爬虫的世界里,我们常常会遇到一个核心矛盾:我们想要测试或抓取的目标页面,其行为严重依赖于外部网络请求。比如,一个电商网站的“加入购物车”按钮,点击后通常会向一个/api/cart/add的接口发起请求;一个数据分析页面的图表,其数据来源于一个返回 JSON 的/data/report接口。如果我们只是机械地点击和等待,测试会变得脆弱(依赖不稳定的后端服务),爬虫会变得低效(需要加载大量无关资源)。
这就是 Playwright 的“网络请求拦截与模拟”能力大显身手的地方。简单来说,它允许我们在浏览器页面发起请求和接收响应的关键链路上“架设关卡”,进行监听、审查、修改甚至完全替换。这不仅仅是测试工程师的利器,对于需要精确控制网络环境的开发者、逆向工程师或数据抓取专家来说,同样不可或缺。通过这个功能,我们可以实现诸如:屏蔽不必要的广告和追踪脚本以提升测试速度、模拟后端接口返回的特定数据以测试前端各种状态(成功、失败、超时)、修改请求头以绕过某些反爬机制、甚至直接拦截并保存页面发出的关键数据。
我见过太多团队在遇到偶发的第三方服务超时导致测试失败时,只能无奈地将其标记为“不稳定用例”。也见过爬虫项目因为一个动态令牌(token)的获取逻辑复杂而举步维艰。掌握 Playwright 的请求拦截与模拟,你就能将这些不可控的外部依赖,转变为完全可控的测试或抓取环节,从而构建出更稳定、更快速、更强大的自动化方案。接下来,我将带你从原理到实战,彻底吃透这项进阶技能。
2. 核心原理:Playwright 如何操控网络请求?
要玩转拦截与模拟,首先得明白 Playwright 是在哪个环节“动的手脚”。它并非在操作系统层面拦截流量,也不同于 Fiddler、Charles 等代理工具。Playwright 的魔力来源于其对Chromium/WebKit/Firefox浏览器内核的深度控制。
2.1 请求生命周期与拦截点
一个典型的 HTTP/HTTPS 请求在浏览器中会经历以下几个主要阶段:
- 发起:页面 JavaScript 调用
fetch()或XMLHttpRequest,或者浏览器解析到<script src="...">、<img src="...">等标签。 - 请求:浏览器构建请求对象,包含 URL、方法、头信息、载荷等。
- 网络传输:请求被发送到网络。
- 接收响应:服务器返回响应,包括状态码、头和响应体。
- 处理:浏览器根据响应内容进行处理(如执行 JS、渲染图片、解析 JSON)。
Playwright 的拦截主要作用于第2步之后、第4步之前。它通过page.route()方法,在浏览器内核中注册一个路由处理器。当页面发起的请求匹配到我们设定的规则(URL 模式)时,浏览器内核会“暂停”这个请求,并将其控制权交给我们的 Playwright 脚本代码。此时,我们可以做出三种选择:
- 继续(Continue):放行原请求,让其正常发送到网络。
- 履行(Fulfill):直接拦截并返回一个我们自定义的响应,请求不会真正到达服务器。
- 中止(Abort):直接中断该请求,模拟网络错误或资源加载失败。
2.2page.route()与route对象详解
page.route()是这一切的入口。它的基本语法是:
await page.route(urlPattern, handler);urlPattern:一个字符串或正则表达式,用于匹配要拦截的请求 URL。例如'**/*.api*'会拦截所有包含.api的请求。handler:一个回调函数,接收一个Route对象作为参数。我们所有的“拦截”逻辑都在这个函数里编写。
Route对象是我们操作请求的核心手柄,它提供了几个关键方法:
route.request():获取被拦截的请求对象(Request),我们可以读取其 URL、方法、头、postData 等信息。route.continue([overrides]):放行请求。可以选择性地提供overrides参数来修改请求的 URL、方法、头或 postData。route.fulfill([options]):拦截并返回自定义响应。options可以指定状态码、头、响应体(body)等。route.abort([errorCode]):中止请求,模拟失败。errorCode可以是'failed','aborted','timedout','accessdenied','connectionclosed'等。
理解了这个流程,我们就知道,所谓的“模拟”,本质上就是在handler函数中,根据条件判断,选择调用route.fulfill()返回我们准备好的假数据。
注意:
page.route()需要在页面发起目标请求之前就被设置好。通常我们在page.goto()之前或页面加载早期的某个时机进行路由注册。如果请求已经发出去了再拦截,就来不及了。
3. 实战演练:从基础拦截到高级模拟
理论说得再多,不如动手实操。让我们通过几个由浅入深的例子,来掌握这项技能。
3.1 基础操作:拦截与修改请求
场景:测试一个搜索页面,我们希望修改搜索关键词,或者给所有请求加上一个特定的认证头。
const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch({ headless: false }); const page = await browser.newPage(); // 拦截所有请求,并添加一个自定义请求头 await page.route('**/*', async route => { // 获取原始请求头 const headers = { ...route.request().headers(), 'x-custom-token': 'my-secret-token-123', // 添加自定义头 }; // 继续请求,但使用修改后的头 await route.continue({ headers }); }); // 拦截特定API请求,并修改其请求体 await page.route('**/api/search', async route => { const request = route.request(); // 只处理POST请求 if (request.method() === 'POST') { // 获取原始POST数据(假设是JSON) const postData = JSON.parse(request.postData() || '{}'); // 修改搜索关键词 postData.keyword = 'playwright'; // 继续请求,但携带修改后的数据 await route.continue({ postData: JSON.stringify(postData), }); } else { // 非POST请求,直接放行 await route.continue(); } }); await page.goto('https://your-test-site.com'); // ... 后续操作 await browser.close(); })();实操心得:
- 使用
**/*可以匹配所有请求,但要小心性能,因为每个请求都会进入处理函数。尽量使用更精确的 URL 模式。 route.request().headers()返回的是一个普通对象,修改后传递给route.continue({headers: newHeaders})。注意,这里需要提供完整的 headers 对象,它会覆盖原有的。- 修改请求体时,务必确保序列化格式(如 JSON)正确,并且内容类型(Content-Type)头可能需要同步更新。
3.2 核心技能:模拟 API 响应(Mocking)
这是最常用的功能,用于前端状态测试。
场景:测试一个用户仪表盘页面,需要模拟“加载成功(有数据)”、“加载成功(空数据)”、“加载失败(服务器错误)”、“加载超时”等多种情况。
// 模拟成功响应(有数据) await page.route('**/api/user/dashboard', async route => { const mockData = { status: 'success', data: { username: '测试用户', level: 'VIP', notifications: 5, recentActivities: [ { id: 1, action: '登录', time: '2023-10-01 10:00' }, { id: 2, action: '修改资料', time: '2023-10-01 09:30' }, ], }, }; // 履行请求,直接返回模拟数据 await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockData), }); }); // 模拟失败响应(如500错误) await page.route('**/api/user/profile', async route => { await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'Internal Server Error' }), }); }); // 模拟超时(通过延迟履行来模拟) await page.route('**/api/slow-report', async route => { // 等待3秒后再响应,模拟网络延迟 await page.waitForTimeout(3000); await route.fulfill({ status: 200, body: 'Delayed Response', }); }); // 模拟空数据 await page.route('**/api/user/messages', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [] }), // 空数组 }); });注意事项:
contentType必须与body的实际格式匹配,否则前端可能无法正确解析。对于 JSON,一定是'application/json'。- 模拟超时或延迟时,注意不要阻塞整个 Playwright 的事件循环。
page.waitForTimeout是可行的,但对于复杂的异步逻辑,需要更精细的控制。 - 一个常见的需求是:根据请求参数返回不同的模拟响应。这可以通过解析
route.request().url()或postData()来实现条件判断。
3.3 性能优化:拦截并阻塞非必要资源
网页加载慢,很多时候是因为加载了太多第三方广告、分析脚本、字体或大图。在自动化测试中,我们并不需要它们。
场景:加速测试用例执行,让页面只加载核心 HTML、CSS 和 JS。
// 定义需要阻塞的资源类型模式 const blockedResourceTypes = [ 'image', 'stylesheet', // 小心!阻塞CSS可能会影响布局测试 'media', 'font', 'other', // 其他类型,如beacon, ping等 ]; // 定义需要阻塞的域名(广告、分析等) const blockedDomains = [ 'www.google-analytics.com', 'doubleclick.net', 'googlesyndication.com', 'adservice.google.com', 'fonts.googleapis.com', ]; await page.route('**/*', async route => { const request = route.request(); const resourceType = request.resourceType(); const url = request.url(); // 1. 按资源类型阻塞 if (blockedResourceTypes.includes(resourceType)) { // 对于图片、字体等,直接中止,节省带宽和解析时间 await route.abort(); return; } // 2. 按域名阻塞 const urlObj = new URL(url); if (blockedDomains.some(domain => urlObj.hostname.includes(domain))) { await route.abort(); return; } // 3. 其他请求正常继续 await route.continue(); }); await page.goto('https://news.example.com'); // 加载速度会显著提升重要提示:阻塞
'stylesheet'会使得页面失去所有样式,变成“裸奔”的 HTML。这对于测试核心功能可能没问题,但如果你需要测试 UI 布局或样式,就不能阻塞 CSS。通常,阻塞图片、字体和来自广告域名的脚本是安全且有效的优化手段。
3.4 高级应用:修改响应内容
有时我们不想完全模拟一个响应,而是想对真实的服务器响应做一些“篡改”,比如修改其中的某个字段,或者注入一段调试脚本。
场景:在测试环境中,将服务器返回的用户邮箱地址统一替换为一个测试邮箱,以避免向真实用户发送邮件。
await page.route('**/api/user/info', async route => { // 首先,继续请求,获取真实响应 const response = await route.fetch(); // 关键API:获取原始响应 const originalBody = await response.text(); let modifiedBody = originalBody; try { const data = JSON.parse(originalBody); if (data.email) { data.email = 'test@example.com'; // 篡改邮箱字段 modifiedBody = JSON.stringify(data); } } catch (e) { // 如果不是JSON,则按文本处理(例如,注入一个脚本标签) if (response.headers()['content-type']?.includes('text/html')) { modifiedBody = originalBody.replace('</head>', '<script>console.log("Injected!”)</script></head>'); } } // 履行修改后的响应 await route.fulfill({ response, // 继承原始响应的状态和大部分头信息 body: modifiedBody, // 注意:如果修改了内容长度,可能需要删除或更新 'content-length' 头 headers: { ...response.headers(), // 'content-length': Buffer.byteLength(modifiedBody, 'utf-8').toString(), // 重新计算长度 }, }); });核心技巧:
route.fetch()是这里的关键。它允许我们“窥探”并获取原始请求的响应,然后基于此进行修改。这比完全模拟(mock)更灵活,因为它保留了除我们修改部分外的所有真实数据。- 修改 JSON 响应体时,要小心保持 JSON 格式的有效性。
- 修改 HTML 或文本响应时,字符串替换要精准,避免破坏原有结构。
- 如果修改了响应体的长度,务必要处理
content-length头,否则可能导致浏览器解析错误。一种简单的做法是在fulfill的headers中不传递这个头,浏览器通常会自己处理。
4. 模式匹配与路由管理
精准地拦截目标请求,离不开强大的 URL 模式匹配。Playwright 支持两种模式:字符串模式和正则表达式模式。
4.1 字符串模式(Glob Pattern)
这是最常用的方式,语法直观。
**/*:匹配所有 URL。**/*.js:匹配所有以.js结尾的请求(JavaScript 文件)。**/api/*:匹配所有路径中包含/api/的请求。https://example.com/api/*:匹配特定域名下的 API 请求。**/*analytics*:匹配 URL 中包含analytics字符串的请求。
4.2 正则表达式模式
当字符串模式不够用时,可以使用正则表达式实现更复杂的匹配。
// 匹配所有包含版本号 v1 或 v2 的 API 请求 await page.route(/\/api\/v[12]\//, handler); // 匹配所有图片,但排除 .svg 格式 await page.route(/\.(jpg|jpeg|png|gif|webp)$/, handler);4.3 路由的启用与禁用
我们可能需要在测试的不同阶段动态控制路由。
// 添加路由,并保存其引用 const userRouteHandler = async route => { /* ... */ }; await page.route('**/api/user/**', userRouteHandler); // 注意:page.route() 不返回句柄,我们需要通过模式来取消 // 取消所有路由(不推荐,会取消全部) // await page.unroute('**/*'); // 更精细的做法:使用一个标志位在handler内部控制 let mockUserApi = true; await page.route('**/api/user/**', async route => { if (!mockUserApi) { await route.continue(); // 标志位为false时,直接放行 return; } // ... 原有的模拟逻辑 }); // 在测试中动态切换 mockUserApi = false; // 后续的 /api/user/ 请求将不会被模拟更优雅的方式是利用 Playwright 的context.route()和browserContext.unroute()。因为路由可以绑定到上下文(Context)级别,管理起来更清晰。
5. 常见问题与排查技巧实录
在实际使用中,你肯定会遇到各种“坑”。下面是我总结的一些典型问题及其解决方案。
5.1 拦截不到请求?
这是最常见的问题。
- 检查时机:确保
page.route()在目标请求发起之前就被调用。最好的做法是在page.goto()或执行会触发请求的操作之前设置路由。 - 检查模式:URL 模式是否写错了?使用
console.log(route.request().url())在 handler 里打印一下,看看实际请求的 URL 是什么,再调整你的模式。 - 请求是否来自页面:
page.route()只能拦截由该页面上下文发起的请求。如果是 iframe 内发起的请求,你需要先获取到 iframe 对象,然后使用iframe.route()。同样,Service Worker 或 Web Worker 发起的请求也可能拦截不到。 - 导航请求:对于页面导航(如
page.goto)本身的请求,page.route()也是可以拦截的。
5.2 模拟的响应导致页面行为异常?
页面收到了你的模拟数据,但 JavaScript 报错或功能不正常。
- 检查响应格式:确保
contentType和body格式完全匹配服务器真实返回的格式。用浏览器开发者工具的 Network 面板查看真实响应是什么样子,然后精确模仿。 - 检查响应结构:模拟的 JSON 数据结构和字段名是否与前端代码期望的一致?少一个字段或者字段类型不对都可能引发错误。
- 顺序问题:有些页面可能依赖多个接口按特定顺序返回。如果你的模拟改变了响应速度或顺序,可能会破坏前端逻辑。可以考虑在 handler 中加入
await page.waitForTimeout(100)来模拟网络延迟,保持与真实环境类似的时序。
5.3 如何拦截并保存请求/响应数据?
用于调试或数据抓取。
const allRequests = []; await page.route('**/api/data/**', async route => { const request = route.request(); const requestData = { url: request.url(), method: request.method(), headers: request.headers(), postData: request.postData(), }; // 方式1:继续请求,并保存响应 const response = await route.fetch(); const responseBody = await response.text(); allRequests.push({ request: requestData, response: { status: response.status(), headers: response.headers(), body: responseBody, }, }); // 履行原始响应(或修改后的) await route.fulfill({ response, body: responseBody }); }); // 测试结束后,可以将 allRequests 写入文件 // const fs = require('fs'); // fs.writeFileSync('requests.json', JSON.stringify(allRequests, null, 2));5.4 处理动态参数和认证
请求的 URL 中可能包含时间戳、随机数或动态令牌。
- 使用正则表达式:如果动态部分有规律,如
/api/item/12345,可以用\/api\/item\/\d+来匹配。 - 在 Handler 内判断:如果无法用模式精确匹配,就在 handler 里通过
route.request().url()进行完整的字符串或逻辑判断。 - 传递认证信息:通常认证信息在请求头(如
Authorization: Bearer ...)中。你可以在page.route()的 handler 里,通过route.continue({headers: {...}})为匹配的请求统一添加认证头,而无需修改前端代码。这对于测试需要登录状态的页面非常有用。
5.5 性能问题:拦截器过于复杂
如果你为**/*注册了一个非常复杂的 handler,每个请求(包括图片、CSS等)都会执行这段代码,可能会拖慢测试速度。
- 精确匹配:尽量使用最具体的 URL 模式。
- 尽早返回:在 handler 里,如果判断不需要处理,尽快调用
route.continue()或route.abort()并return,减少不必要的逻辑执行。 - 分而治之:不要把所有逻辑写在一个大的 handler 里。可以为不同的 URL 模式注册多个独立的、更简单的 handler。
6. 综合案例:构建一个健壮的测试 Mock 服务器
让我们把这些知识点串联起来,看一个更贴近真实项目的例子:为一个前后端分离的博客系统编写测试,我们需要模拟文章列表、文章详情、评论提交等接口。
const { test, expect } = require('@playwright/test'); const fs = require('fs').promises; // 测试数据 const mockArticles = [ { id: 1, title: 'Playwright入门', summary: '自动化测试利器' }, { id: 2, title: '网络请求拦截', summary: '进阶技巧' }, ]; const mockArticleDetail = { id: 1, title: 'Playwright入门', content: '这里是详细的文章内容...', comments: [ { id: 101, user: '张三', text: '好文!' } ] }; test('博客系统前端测试', async ({ page }) => { // 1. 设置请求拦截与模拟 await page.route('**/api/articles', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: mockArticles }), }); }); // 动态路由:根据文章ID返回不同内容 await page.route(/\/api\/article\/\d+/, async route => { const request = route.request(); const url = request.url(); // 简单地从URL中提取ID const match = url.match(/\/api\/article\/(\d+)/); const articleId = match ? parseInt(match[1], 10) : 1; const detail = { ...mockArticleDetail, id: articleId }; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: detail }), }); }); // 模拟提交评论(POST请求) await page.route('**/api/comment', async route => { const request = route.request(); if (request.method() === 'POST') { // 可以在这里验证提交的数据 const postData = JSON.parse(request.postData()); console.log('提交的评论数据:', postData); // 模拟成功创建 await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ success: true, message: '评论提交成功', commentId: Date.now(), // 模拟一个ID }), }); } else { await route.continue(); } }); // 2. 屏蔽无关资源,加速测试 await page.route('**/*.{png,jpg,jpeg,svg,gif,woff,woff2}', route => route.abort()); // 3. 导航到页面并开始测试 await page.goto('http://localhost:3000'); // 断言:文章列表应该正确显示模拟数据 await expect(page.locator('.article-list li')).toHaveCount(mockArticles.length); await expect(page.locator('.article-list li:first-child')).toContainText(mockArticles[0].title); // 操作:点击第一篇文章 await page.locator('.article-list li:first-child a').click(); // 断言:文章详情页应显示对应内容 await expect(page.locator('h1.article-title')).toContainText(mockArticleDetail.title); // 操作:提交一条评论 await page.locator('textarea#comment').fill('这是一条测试评论'); await page.locator('button[type="submit"]').click(); // 断言:页面显示提交成功的反馈(模拟接口返回的message) await expect(page.locator('.alert-success')).toContainText('评论提交成功'); }); // 清理或恢复(如果需要) test.afterEach(async ({ page }) => { // 取消所有路由,避免影响其他测试 await page.unroute('**/*'); });这个案例展示了如何将拦截、模拟、优化和断言结合在一起,构建出一个不依赖后端真实服务的、完全可控的前端自动化测试用例。通过这种方式,你的测试将变得极其稳定和快速,并且能轻松覆盖各种边界情况(如网络错误、空数据、异常数据等)。
掌握 Playwright 的网络请求拦截与模拟,就像是获得了自动化测试和网页抓取领域的“上帝视角”。你能看到所有进出的数据流,并能随心所欲地修改它们。从提升测试稳定性,到优化执行性能,再到实现复杂的抓取逻辑,这项技能的价值会随着你项目的深入而不断显现。刚开始可能会觉得模式匹配有点绕,或者模拟的数据结构对不上,但多踩几次坑,多看看 Network 面板里的真实数据,你很快就能得心应手。记住,关键是要理解请求的生命周期,知道你在哪个环节介入,能做什么操作。剩下的,就是大胆地去尝试和组合这些能力,来解决你遇到的实际问题了。
