Playwright Route API 实战:网络请求拦截与 Mock 接口测试

发布时间:2026/7/28 6:10:41
Playwright Route API 实战:网络请求拦截与 Mock 接口测试 1. 项目概述为什么我们需要拦截和修改网络请求在自动化测试和爬虫开发的日常工作中我们经常会遇到一个令人头疼的问题测试环境依赖的第三方接口不稳定。比如你正在为一个电商应用编写下单流程的自动化测试脚本脚本需要调用支付网关的接口来验证支付成功后的页面跳转。然而这个支付网关的测试环境可能时不时返回500错误或者响应速度极慢甚至直接不可用。这会导致你的测试脚本频繁失败而失败的原因并非你的应用代码有问题而是外部依赖的“猪队友”拖了后腿。另一种常见场景是你需要测试应用在接收到特定异常数据时的表现。例如你想知道当用户账户余额接口返回一个负数时前端页面是会显示“余额异常”还是直接崩溃。但在正常情况下你几乎不可能让真实的第三方服务给你返回一个负数的余额。这时候如果能“欺骗”一下浏览器让它以为接收到的响应数据就是我们预设的问题就迎刃而解了。Playwright 的 Route API 就是为了解决这类问题而生的利器。它允许我们在浏览器页面发起网络请求时进行拦截并可以做出三种选择继续请求、中止请求或者提供一个自定义的响应。这个功能的核心价值在于它将测试脚本的稳定性和可控性从外部环境的束缚中解放了出来。我们不再需要跪求第三方服务提供稳定的测试环境也不用在测试数据准备上大费周章。通过 Mock模拟接口响应我们可以精准地构造出各种正常、边界乃至异常的场景从而对我们的应用进行更全面、更可靠的验证。简单来说掌握了 Route API你就相当于在浏览器和网络之间安装了一个“过滤器”和“应答机”。你可以查看所有进出的“包裹”请求和响应可以决定是否放行甚至可以自己伪造一个“包裹”塞回去。这对于前端开发、测试工程师以及需要处理复杂网络交互的爬虫开发者来说是一项不可或缺的核心技能。2. Route API 核心原理与能力拆解要玩转 Route API首先得理解它在 Playwright 架构中的位置以及其核心的工作机制。这并非一个黑盒魔法理解了原理才能用得得心应手。2.1 请求生命周期与路由拦截点Playwright 对网络请求的管理非常细致。一个典型的 HTTP/HTTPS 请求在浏览器中会经历多个阶段。Route API 主要作用于“请求已发出但尚未到达网络”的这个关键时刻。具体来说当使用page.route(url, handler)或browserContext.route(url, handler)方法注册一个路由时Playwright 就开始监听匹配该 URL 模式的所有请求。一旦请求被拦截控制权就交给了我们提供的handler回调函数。这个函数会接收到一个Route对象。此时请求就像被按下了暂停键等待我们的指令。我们必须在handler中调用Route对象的以下三个方法之一来决定请求的最终命运route.continue([overrides]): 放行请求允许它继续发往目标服务器。我们可以选择性地修改请求头、请求方法POST/GET等甚至请求体postData。route.fulfill([options]): 立即终止请求并直接返回一个我们自定义的响应。这是实现 Mock 接口最常用的方法我们可以指定状态码、响应头和响应体。route.abort([errorCode]): 中止请求模拟请求失败。可以指定失败类型如‘aborted’中止、‘accessdenied’拒绝访问、‘timedout’超时等用于测试网络错误场景。这里有一个关键细节handler函数必须是异步的async并且必须对每个被拦截的请求调用上述三个方法之一。如果忘记调用或者因为异常导致没有调用这个请求就会被永远挂起导致页面加载卡死或超时。这是新手最容易踩的坑。2.2 匹配模式精准捕获目标请求URL 匹配模式是路由的“触发器”。Playwright 支持多种匹配方式字符串匹配page.route(‘https://api.example.com/user’, handler)只会拦截这个精确的 URL。通配符匹配使用*匹配任意字符序列。例如page.route(‘**/api/*/user*’, handler)可以匹配https://a.com/api/v1/user/profile和https://b.com/api/v2/users。正则表达式匹配提供最灵活的匹配能力。例如page.route(/\/api\/v\d\/order\/\d/, handler)可以匹配所有类似/api/v1/order/123、/api/v2/order/456的路径。注意匹配模式过于宽泛如**可能会拦截到页面必需的资源如 CSS、JS、图片导致页面渲染异常。务必精确匹配通常建议从浏览器的开发者工具“网络”标签中复制确切的请求 URL 或路径模式。2.3 与类似工具的对比你可能听说过其他工具也能修改网络请求比如 Charles/Fiddler 等抓包工具或者浏览器原生提供的window.fetch和XMLHttpRequest重写。Route API 与它们有何不同vs. Charles/Fiddler这些是系统级的代理工具独立于浏览器和测试脚本。它们功能强大但配置相对复杂且难以与自动化测试流程集成。Route API 是代码级的、程序化的控制可以直接写在测试脚本里与测试用例的生命周期如beforeEachafterEach完美结合实现动态、条件化的 Mock。vs. 浏览器原生重写通过page.evaluate注入代码来覆盖window.fetch和XMLHttpRequest虽然可行但这是一项“黑客”行为可能破坏页面原有逻辑且无法拦截通过img、script标签发起的请求。Route API 由 Playwright 在浏览器引擎底层实现拦截更彻底、更稳定不影响页面逻辑。因此Route API 的核心优势在于“可编程性”和“集成性”它让网络请求的 Mock 和修改成为了自动化测试脚本的一个有机组成部分。3. 实战场景一Mock 第三方接口响应这是 Route API 最经典的应用场景。我们的目标是当页面尝试调用一个不稳定的或我们无法控制的第三方接口时直接返回我们预设好的“假”数据让测试流程顺利进行下去。3.1 基础 Mock返回静态 JSON 数据假设我们有一个用户中心页面它会调用https://api.demo.com/user/info来获取当前用户的昵称和头像。我们想 Mock 这个接口让它返回固定的用户信息。const { chromium } require(‘playwright’); (async () { const browser await chromium.launch({ headless: false }); const context await browser.newContext(); const page await context.newPage(); // 关键步骤注册路由拦截器 await page.route(‘https://api.demo.com/user/info’, async route { // 构造一个模拟的响应 const mockResponse { status: 200, contentType: ‘application/json’, body: JSON.stringify({ userId: 10001, nickname: ‘测试用户’, avatar: ‘https://demo.com/avatar/default.jpg’ }) }; // 使用自定义响应完成此次请求 await route.fulfill(mockResponse); }); // 导航到页面页面发起的 /user/info 请求将被拦截并返回上述 mock 数据 await page.goto(‘https://your-app.com/user-center’); // 此时页面应该显示昵称为“测试用户” // ... 后续的断言和操作 await browser.close(); })();实操心得route.fulfill的body参数必须是字符串。如果是 JSON 对象记得用JSON.stringify()转换。contentType头非常重要。设置为‘application/json’能确保浏览器正确解析响应体。如果 Mock 的是 HTML 或文本则需设置为‘text/html’或‘text/plain’。这个 Mock 是“一次性”的只针对这个page对象生效。如果新开一个标签页newPage需要重新注册路由或者使用browserContext.route()在上下文级别进行拦截这样该上下文下的所有页面都会生效。3.2 高级 Mock动态响应与条件判断真实的测试场景往往更复杂。我们可能需要根据请求的不同参数返回不同的响应数据。例如一个搜索接口https://api.demo.com/search?keywordxxx当关键词为“playwright”时返回相关结果为空时返回错误为“error”时模拟服务器500错误。await page.route(/https:\/\/api\.demo\.com\/search\?.*/, async route { // 获取被拦截的请求对象 const request route.request(); // 获取URL对象方便解析查询参数 const url new URL(request.url()); const keyword url.searchParams.get(‘keyword’); let mockResponse; if (!keyword) { // 关键词为空返回业务错误 mockResponse { status: 400, body: JSON.stringify({ code: 40001, message: ‘关键词不能为空’ }) }; } else if (keyword ‘error’) { // 模拟服务器内部错误 mockResponse { status: 500, body: ‘Internal Server Error’ }; } else if (keyword ‘playwright’) { // 返回特定关键词的模拟数据 mockResponse { status: 200, contentType: ‘application/json’, body: JSON.stringify({ list: [ { id: 1, title: ‘Playwright 入门指南’ }, { id: 2, title: ‘Playwright Route API 详解’ } ], total: 2 }) }; } else { // 其他关键词返回空结果 mockResponse { status: 200, body: JSON.stringify({ list: [], total: 0 }) }; } await route.fulfill(mockResponse); });这个例子展示了 Route API 的灵活性。我们可以在handler函数里编写任意逻辑基于请求的 URL、方法、头信息甚至请求体来动态决定返回什么。这为测试诸如“分页加载”、“搜索过滤”、“表单提交验证”等交互逻辑提供了极大的便利。3.3 集成到测试框架Pytest/Jest在实际项目中我们通常不会把 Mock 代码写在单个脚本里而是集成到测试框架中使其更模块化、可复用。以 Pytest 为例我们可以使用fixture来管理路由的注册和卸载import pytest from playwright.sync_api import Page, Route import json pytest.fixture(scope“function”) # 每个测试函数运行一次 def mock_user_api(page: Page): “””Mock 用户信息接口的 fixture””” def handle_route(route: Route): mock_data {“userId”: 1001, “name”: “Fixture User”} route.fulfill(status200, bodyjson.dumps(mock_data)) # 注册路由 page.route(“**/api/user”, handle_route) yield page # 将配置好 Mock 的 page 对象提供给测试用例 # 测试结束后可以在这里清理路由Playwright 通常会自动清理 def test_user_profile(mock_user_api): page mock_user_api page.goto(“/user-profile”) # 断言页面显示了 Mock 的用户名 assert page.locator(“.user-name”).inner_text() “Fixture User”这样每个需要 Mock 用户接口的测试用例只需要引用这个fixture即可代码非常清晰。对于 Jest 也是类似思路可以使用beforeEach钩子来设置路由。4. 实战场景二修改请求与响应数据除了完全 MockRoute API 另一个强大功能是“修改”。即让请求正常发生但在其发出前或返回后对数据进行篡改。这常用于测试数据验证、敏感信息脱敏或模拟特定数据格式。4.1 修改请求参数Request有时我们需要测试后端对异常请求参数的处理但前端页面无法产生这样的参数。这时可以在请求发出前修改它。例如测试一个登录接口我们想验证当密码字段为空时后端是否返回正确的错误提示。await page.route(‘https://api.demo.com/login’, async route { const request route.request(); // 获取原始的请求数据对于 POST 请求通常是 JSON 或 FormData 字符串 const postData request.postData(); let requestBody; try { requestBody JSON.parse(postData); } catch { // 如果不是 JSON可能是其他格式这里简单处理 await route.continue(); return; } // 修改请求体将密码置空 requestBody.password ‘’; // 携带修改后的请求体继续请求 await route.continue({ postData: JSON.stringify(requestBody) }); });注意事项修改postData时必须确保其字符串格式与请求的Content-Type头匹配如application/json。route.continue({overrides})还可以修改headers和method。例如你可以给所有请求添加一个特定的认证头或者将某个 GET 请求改为 POST 请求进行测试。4.2 修改响应数据Response修改响应比完全 Mock 更“轻量”它允许真实的请求到达服务器并返回我们只是在响应数据返回给页面之前对其进行加工。这需要用到route.continue()并配合page.on(‘response’)事件但更优雅的方式是使用route.fulfill来“劫持并替换”响应。更常见的模式是先发起一个真实请求获取数据然后修改它最后将修改后的数据返回。这可以通过fetch或直接使用route.request().response()来实现但后者在 Playwright 中获取原始响应稍显复杂。一个更直接的方法是结合route.continue()和响应事件监听但逻辑会绕。更实用的“修改响应”场景其实可以看作是一种“先请求后 Mock”的模式。例如我们想让一个商品列表接口的每个商品价格都打五折用于测试前端的价格展示逻辑。await page.route(‘https://api.demo.com/products’, async route { // 1. 先放行请求获取原始响应 const response await route.fetch(); // 这是 Playwright 提供的一个便捷方法 // 2. 获取原始响应体 const originalBody await response.json(); // 3. 修改数据所有价格打五折 const modifiedBody originalBody.map(product ({ ...product, price: product.price * 0.5 })); // 4. 用修改后的数据履行响应 await route.fulfill({ response, // 继承原始响应的状态码和大部分头信息 body: JSON.stringify(modifiedBody), headers: { ...response.headers(), // 继承原始头 ‘content-type’: ‘application/json’ // 确保 content-type 正确 } }); });这里使用了route.fetch()方法它会在后台发起一个请求不经过页面并返回响应对象非常方便。这种方式既保证了请求的真实性比如接口鉴权是有效的又实现了对响应数据的定制化修改。5. 常见问题排查与性能优化在实际使用 Route API 的过程中你可能会遇到一些意想不到的问题。下面是我总结的一些典型“坑”及其解决方案。5.1 请求被挂起页面卡住现象页面加载到一半不动了开发者工具里看到某个请求一直处于Pending状态。原因这是最常见的问题。你的路由handler函数没有调用route.continue()route.fulfill()或route.abort()中的任何一个。可能是函数里有条件分支没有覆盖到或者函数执行过程中抛出了异常。排查确保handler是async函数。在handler函数内部用try...catch包裹核心逻辑并在catch块中调用route.continue()作为保底。检查所有逻辑分支确保每个分支都结束了路由。await page.route(‘**/api/**’, async route { try { // 你的业务逻辑... if (someCondition) { await route.fulfill(/* ... */); } else { // 确保 else 分支也处理了路由 await route.continue(); } } catch (error) { console.error(‘路由处理出错:’, error); // 出错时也继续请求避免卡死 await route.continue(); } });5.2 Mock 未生效请求依然走到了真实接口现象代码写了路由但页面仍然发起了真实网络请求并可能返回错误。原因URL 匹配错误最常见。请求的 URL 与路由注册的模式不匹配。可能是协议http/https、域名、端口或路径的细微差别。路由注册时机过晚在page.goto()之后才注册路由。页面导航时发起的初始请求如 HTML 文档内联的 API 调用可能已经发出。页面内发生了导航或跳转跳转到了新的域名或路径原有的路由匹配失效。排查在路由handler里第一行打印route.request().url()确认拦截到的 URL 是否符合预期。将路由注册代码放在page.goto()之前。对于单页应用SPA或可能跳转的页面考虑使用browserContext.route()在更广的范围内拦截。5.3 性能影响与优化建议滥用路由拦截会对测试执行速度产生负面影响。每个匹配的请求都会增加 JavaScript 的执行开销。优化建议精确匹配尽量使用最精确的 URL 字符串或正则表达式避免使用**这样的宽泛模式拦截所有请求。按需启用不要在测试开始时全局注册所有 Mock 路由。使用 Pytest 的fixture或 Jest 的beforeEach只为当前测试套件需要的接口注册路由。并在测试结束后及时清理虽然 Playwright 的 Context 关闭时会自动清理。避免在路由 handler 中执行重型操作比如进行复杂的计算或发起额外的网络请求。如果必须修改响应且逻辑复杂考虑将修改逻辑抽离成纯函数。使用route.fetch()谨慎route.fetch()会额外发起一次网络请求虽然方便但增加了延迟。如果只是为了获取一个固定的 Mock 数据不如直接构造。5.4 处理 CORS 预检请求OPTIONS当你 Mock 的接口域名与页面域名不同跨域时浏览器可能会先发起一个OPTIONS方法的预检请求Preflight Request。如果你的路由只拦截了GET或POST这个OPTIONS请求会被放行到真实服务器。如果真实服务器没有正确配置 CORS可能会导致后续请求失败。解决方案在路由中同时处理OPTIONS请求直接返回一个包含正确 CORS 头的响应。await page.route(‘https://api.other-domain.com/**’, async route { const request route.request(); if (request.method() ‘OPTIONS’) { // 处理预检请求 await route.fulfill({ status: 200, headers: { ‘Access-Control-Allow-Origin’: ‘*’, // 或具体的页面域名 ‘Access-Control-Allow-Methods’: ‘GET, POST, PUT, DELETE, OPTIONS’, ‘Access-Control-Allow-Headers’: ‘Content-Type, Authorization’, }, body: ‘’ // 预检响应体为空 }); } else { // 处理实际的 GET/POST 请求 // ... 你的 Mock 逻辑 await route.fulfill(/* ... */); } });6. 复杂场景综合应用一个完整的测试用例让我们综合运用以上知识设计一个相对完整的测试场景测试一个“发表评论”的功能。需求页面加载时从https://api.demo.com/article/123获取文章内容。用户填写评论表单点击提交请求发送到https://api.demo.com/comment。提交成功后页面会重新调用文章接口获取包含新评论的文章数据。测试目标Mock 文章接口返回一篇预设的文章。Mock 评论提交接口模拟成功提交。在评论提交成功后修改下一次文章接口的响应使其包含我们刚刚“提交”的评论以验证页面刷新逻辑。const { test, expect } require(‘playwright/test’); test(‘发表评论并刷新列表’, async ({ page }) { let commentSubmitted false; const mockArticle { id: 123, title: ‘测试文章’, content: ‘…’, comments: [] }; // 拦截文章接口 await page.route(‘**/api/article/123’, async route { if (commentSubmitted) { // 评论提交后返回包含新评论的文章数据 const responseWithComment { …mockArticle, comments: [{ id: 999, text: ‘这是一条测试评论’, user: ‘Tester’ }] }; await route.fulfill({ status: 200, contentType: ‘application/json’, body: JSON.stringify(responseWithComment) }); } else { // 首次加载返回原始文章数据 await route.fulfill({ status: 200, contentType: ‘application/json’, body: JSON.stringify(mockArticle) }); } }); // 拦截评论提交接口 await page.route(‘**/api/comment’, async route { const request route.request(); const postData JSON.parse(request.postData()); // 这里可以验证提交的数据例如 expect(postData.text).toBe(‘这是一条测试评论’); // 模拟提交成功 commentSubmitted true; // 设置标志位 await route.fulfill({ status: 200, body: JSON.stringify({ success: true, commentId: 999 }) }); }); // 导航到文章页 await page.goto(‘https://your-app.com/article/123’); // 验证文章标题 await expect(page.locator(‘.article-title’)).toHaveText(‘测试文章’); // 验证初始评论列表为空 await expect(page.locator(‘.comment-list li’)).toHaveCount(0); // 填写并提交评论 await page.locator(‘.comment-input’).fill(‘这是一条测试评论’); await page.locator(‘button[type“submit”]’).click(); // 等待页面刷新评论列表例如等待新评论出现 await expect(page.locator(‘.comment-list li’)).toHaveCount(1); await expect(page.locator(‘.comment-list li:first-child’)).toContainText(‘这是一条测试评论’); });这个例子展示了如何利用一个外部变量 (commentSubmitted) 在不同路由处理函数之间共享状态从而模拟出“先提交后刷新”的完整用户交互流程。这种模式在测试有状态变化的交互时非常有用。Route API 的深度和灵活性远不止于此结合 Playwright 的其他 API如网络事件监听、请求/响应对象操作你可以构建出极其复杂和强大的网络行为模拟彻底掌控测试环境。关键在于理解其工作原理并大胆地在实际项目中实践和组合这些模式。