Playwright 三层 mock 怎么选:路由拦截、HAR 回放、API 打桩

2026-08-18

写端到端测试的人迟早会遇到同一个岔路口:这条用例挂在后端数据上,后端一改数据它就红,可它测的明明是前端逻辑。到这一步你要开始 mock 了。而 Playwright 仓库里关于 mock 的文档不是一份,是三份——docs/src/mock.mddocs/src/mock-browser-js.mddocs/src/network.md。它们讲的不是同一件事的三种写法,是三个拦截位置。选错层,你会写出一堆看着能跑、其实什么都没拦住的代码。

下面按「拦在哪一层」把三者分开,然后从你的实际处境倒推该选谁。

第一层:路由拦截,拦在请求发出的那一刻

入口是 page.route()context.route()docs/src/network.md 里写得很直白:不需要配置任何东西,给浏览器上下文定义一个自定义 Route 就行。

docs/src/api/class-route.md 列出了拿到 Route 之后能做的几件事:abort() 掐掉、continue() 带改写地放行、fulfill() 直接造一个响应回去、fetch() 先把真实响应取回来但不 fulfill,好让你改完再 fulfill。docs/src/mock.md 的第一个例子就是纯造响应:

await page.route('*/**/api/v1/fruits', async route => {
  const json = [{ name: 'Strawberry', id: 21 }];
  await route.fulfill({ json });
});

这段里的 Strawberry21 只是仓库示例里的取值,不是什么约定。第二个例子换了个思路——请求照发,只在响应体上打补丁:const response = await route.fetch(),拿到 json 之后 json.push(...),最后 route.fulfill({ response, json })Route.fetchdocs/src/api/class-route.md 里标注自 v1.29 起可用。

有几条容易被忽略的规则写在 docs/src/api/class-page.mdPage.route 一节里:多条 route 都匹配时,最后注册的那条优先;page.route 优先于 context.route;以及一句独立的提示——启用路由会禁用 http 缓存。想让多个 handler 依次都跑到,得用 Route.fallback(),文档写明它与 continue() 的区别是「其它匹配的 handler 会先被调用」,注册顺序的反向就是执行顺序。另外 Page.route 有个 times 选项(自 v1.15 起可用),文档写「默认每次都用」——这是仓库当前文档里的默认行为描述,随版本可能变动。

这一层覆盖不到什么docs/src/network.md 末尾单开一节讲 Service Worker。原话的意思是,如果你用着原生的 context.route / page.route 却发现网络事件不见了,就把 serviceWorkers 设成 'block';同一节还点名了 Mock Service Worker(MSW)这类工具——它自己会装一个 Service Worker 接管网络请求,于是这些请求对 route 就是不可见的。这是路由拦截最硬的一条边界,而且它不报错,只是「没拦到」。

第二层:HAR 回放,拦的还是同一层,但响应来自文件

page.routeFromHAR() / context.routeFromHAR()docs/src/api/class-page.md 标注自 v1.23 起可用)本质上仍然是路由拦截,只是响应不再由你手写,而是从一个 HTTP Archive 文件里查出来。docs/src/mock.md 把它拆成三步:录一份 HAR、把 HAR 跟测试一起提交到源码管理、测试里从 HAR 提供响应。

录制不需要额外工具,把 update 设成 true 就行:

await page.routeFromHAR('./hars/fruit.har', {
  url: '*/**/api/v1/fruits',
  update: true,
});

回放就是把 update 关掉或删掉。url 选项决定只有匹配这个 glob 的请求才走 HAR,不给就是全部走 HAR。

真正决定你会不会被它咬到的,是 docs/src/mock.md 里那段匹配规则:HAR 回放对 URL 与 HTTP method 是严格匹配,POST 请求连 payload 也严格匹配;多条录制同时命中时,选 header 匹配最多的那条;录制里若是一次重定向,会被自动跟进。再配上 docs/src/api/class-page.mdnotFound 选项的语义——'abort' 是把没找到的请求掐掉,'fallback' 是放它走真实网络,文档写明默认是 abort(这是仓库当前文档里的默认值,随版本可能变动)。两条合起来读就很清楚了:后端换了个 query 参数、改了路径,你的请求就匹配不上,然后被默认 abort 掉,页面上表现为请求失败,而不是「mock 没生效」。

还有两个选项影响文件本身:updateModefullminimal,文档说 minimal 只记录回放所需的信息,省掉 sizes、timing、page、cookies、security 这些回放时用不到的部分,默认是 minimalupdateContentembedattach,决定资源内容是内联进 HAR 还是作为独立文件/ZIP 条目存放。这两个选项标注自 v1.32 起可用,上面的默认值同样是仓库当前文档里的说法。文件名以 .zip 结尾时会被当成归档,里面是 HAR 加上分开存放的网络负载;解压出来手改也可以,负载路径按解压后的 har 文件相对解析。

除了 update,仓库也给了命令行录制的办法:

# Save API requests from example.com as "example.har" archive.
npx playwright open --save-har=example.har --save-har-glob="**/api/**" https://example.com

docs/src/mock.md 里这条命令的 js / python 版本用双引号包 glob,java 的 mvn exec 版本用的是单引号。Windows 侧要留意:PowerShell 与 CMD 对引号的处理跟 POSIX shell 不一样,照抄前先确认那串 **/api/** 是原样传给了程序而不是被 shell 提前展开或吞掉——这是通用的 shell 常识,不是该项目文档的内容。另一个 Windows 侧容易踩的点回源自 docs/src/api/class-page.mdrouteFromHARhar 参数写明「若是相对路径,则相对当前工作目录解析」。./hars/fruit.har 这种写法在 IDE 里跑和在仓库根跑,解析出来可能是两个位置。

第三层:浏览器 API 打桩,拦的根本不是网络

前两层都在 HTTP 这条线上。但有些东西压根不走 HTTP。docs/src/mock-browser-js.md(注意文件名带 -js,这是 JavaScript 专属页)开头就交代了它的适用场景:有些浏览器 API 还是实验性的,或者并非所有浏览器都完整支持,Playwright 通常不为这类 API 提供专门的自动化接口,这时候就用 mock 去测应用的行为。它举的例子是 battery API。

做法是 page.addInitScript()docs/src/api/class-page.md 写明这个脚本的执行时机:文档创建之后、页面自身的任何脚本运行之前,并且每次导航、每个新附加的子帧都会重新执行。这个时机是关键——页面可能在加载很早期就调用了那个 API,所以打桩必须赶在它前面。

await page.addInitScript(() => {
  const mockBattery = {
    level: 0.75,
    charging: true,
    chargingTime: 1800,
    dischargingTime: Infinity,
    addEventListener: () => { }
  };
  // Override the method to always return mock battery info.
  window.navigator.getBattery = async () => mockBattery;
});

同一份文档还处理了两个真实会碰到的麻烦。一是只读属性:直接给 navigator.cookieEnabled 赋值不起作用,但只要属性是 configurable 的,就能用 Object.defineProperty(Object.getPrototypeOf(navigator), 'cookieEnabled', { value: false }) 覆盖掉。二是验证页面到底调了哪些 API:用 page.exposeFunction() 把一个记录函数暴露给页面,在打桩对象里调它,最后把收集到的调用序列跟预期比对。

顺带一提,addInitScript 有个 exposeFunctions 选项(docs/src/api/params.md 里的说明标注 langs: js,自 v1.62 起可用),开了之后传进去的函数会经由 Page.exposeFunction 暴露到页面里。文档同时写明默认是 false,且此时传函数会被静默丢弃——「静默」两个字值得记一下。这是仓库当前文档里的默认值,随版本可能变动。

Python 绑定这边不要照抄上面的 JS 写法:docs/src/api/class-page.mdPage.addInitScriptpython 段落列的是 pathscript 两个可选参数,示例是 page.add_init_script(path="./preload.js")。而 mock-browser-js.md 整页只有 JS 版本,exposeFunctions 也只标了 js。

这一层的边界docs/src/api/class-page.md 有一条明确的免责——通过 BrowserContext.addInitScriptPage.addInitScript 安装的多个脚本,求值顺序是未定义的。你要是打了两个互相依赖的桩,这条会咬你。另外,前两层文档里反复出现的 Service Worker 提示,在 mock-browser-js.md 里我们没有看到对应说明——这两处放在一起只能说明文档没在这一页提这件事,具体行为我们没有依据,不做推断。

从处境倒推:你该选哪一层

  • 你想要的响应能一眼手写出来,就那么一两个接口:第一层,route + fulfill。别为它录 HAR。
  • 真实响应结构很复杂,你只想动其中一两个字段:还是第一层,但走 route.fetch() 拿真响应,再 fulfill({ response, json })docs/src/mock.md 明说这种做法适用于「必须发出这次 API 请求,但响应需要被打补丁以保证可复现」的场景。
  • 要挡的是几十个接口,手写 payload 不现实:第二层,routeFromHARupdate: true 录一次,然后关掉 update 回放。
  • 你要拦的不是 HTTP:第三层,addInitScript。判据很简单——打开 DevTools 的网络面板看得见吗?看不见的东西,前两层碰不到。
  • 要拦的是 WebSocketdocs/src/mock.md 另有 routeWebSocket()docs/src/api/class-page.md 标注自 v1.48 起可用),既能整段假造通信,也能 connectToServer() 连真服务器再改中间的消息。它算第一层的旁支,不在本文三层对照的主线里。

三种维护成本,各自从哪里来

路由拦截的成本在「契约漂移」。mock 数据写死在测试代码里,后端改了字段名,你的测试照样绿——因为它压根没碰后端。仓库文档没有为此提供任何机制,这不是缺陷,是这层拦截位置的必然结果。

HAR 的成本在「文件是快照」。它要进源码管理,docs/src/mock.md 明说了这一点,也说了改 mock 数据的办法是打开 hars 目录里那个哈希命名的 .txt 文件手动编辑 JSON。快照会过期,而且过期时的表现是严格匹配失败加默认 abort,不是一句友好的报错。再叠上 update: true 会覆写文件——谁在本地随手跑了一次带 update 的测试,HAR 就变了。

API 打桩的成本在「保真度由你负责」docs/src/mock-browser-js.md 里那个 BatteryMock 类把这件事暴露得很清楚:为了测应用能不能正确反映电量变化,你得自己实现 addEventListener、自己维护监听器数组、自己在 _setLevel() 里挨个回调。文档的原话是要确保 mock 对象「触发与浏览器实现相同的事件」。真实 API 的行为细节有多少,你的桩就欠多少。

至于三者的运行开销、录制文件体积这类问题——仓库文档里没有相应说明,我们也没有做过实测,这一点不比。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

本文对照的是同一项目内的两种用法,依据均为上述仓库内容,不对两种用法做优劣排名, 选型结论只在仓库文档写明的能力边界内成立。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。