ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Playwright WebSocket测试实战:从旁路监听到协议拦截

Playwright WebSocket测试实战:从旁路监听到协议拦截 最近帮团队补测一个实时IM模块用Playwright做WebSocket测试时连续翻车用例表面全绿但服务端推送丢失时页面其实没有正确响应偶发断连也查不到原因。后来把监听、拦截、模拟推送、异常断线这几条链路彻底捋了一遍才找到稳定可复现的测试方法。这篇内容不是把Playwright文档抄一遍而是从实际测试需求出发讲清楚什么时候用page.on(websocket)旁路观察什么时候用routeWebSocket做协议层拦截遇到“页面拿不到WebSocket实例”“服务端推送时序不稳”“连接被提前关闭”这些经典问题时怎么处理。适合正在用Playwright做前端自动化、尤其要覆盖实时推送场景的测试工程师也适合写E2E用例时不想被偶发网络问题折磨的同学参考。1. WebSocket测试到底难在哪它和HTTP是两套逻辑1.1 一次“假绿”测试给我的教训以前写接口测试时习惯了“请求-响应”模型发一个请求拿到状态码和响应体比对几个字段就能确定接口对不对。这套思路放到WebSocket上第一反应就是“页面能收到消息就等于测过了”。但这样很容易漏掉最关键的东西。有一次测一个行情推送页面测试用例只覆盖了“页面打开后能看到K线数据”跑起来也是绿的。但线上反馈“部分用户收不到某几档行情推送”。排查后发现问题根本不在UI渲染而在WebSocket消息帧服务端推了两条消息前端只消费了第一条第二条因为数据格式里的seq字段处理错误被丢弃了。UI页面看起来没有崩溃数据也停在旧值上普通断言根本发现不了。这件事之后我意识到WebSocket测试的核心是协议帧层面的验证而不是只看页面有没有反应。页面“有反应”是结果帧内容对不对、时序对不对、异常帧怎么处理才是测试要覆盖的主体。1.2 服务端推送是WebSocket测试的主战场WebSocket有两个方向的数据客户端发给服务端服务端主动推给客户端。对于UI自动化来说“客户端发消息”通常通过页面操作来触发相对好测难的是“服务端在不依赖用户操作的情况下随时推送一条消息页面要立刻正确响应”。这个难点在于时序不可控。HTTP接口测试可以主动发起请求去触发服务端逻辑但WebSocket推送往往是事件驱动的你不在测试里模拟这个“事件源”用例就只能等真实推送。真实推送在测试环境里又经常不稳定可能延迟几秒可能被别的任务抢占可能根本没触发。所以测试侧需要有能力从协议层直接注入一条服务端消息让页面“以为”服务器推了新数据。后面要讲的routeWebSocket就是干这个的。1.3 先厘清工具的定位Playwright不是通用WS客户端这个坑很多人踩过项目里只有后端WebSocket接口没有复杂页面交互却想用Playwright去测。直接用ws库、wscat甚至在线调试工具都更合适因为Playwright的优势不在“发消息”而在“驱动浏览器里的页面”。所以本文讨论范围是被测对象是浏览器中的页面WebSocket连接由页面发起测试需要验证的是握手是否成功、出站报文是否正确、服务端推送是否触发正确的UI更新、断线后页面表现是否符合预期。如果纯测后端协议不建议上Playwright那是另一套测试工具的事了。2. 把WebSocket帧“看”清楚page.on(websocket)的使用2.1 从一个最简单的监听器开始Playwright最早提供的是旁路监听能力。在页面发起WebSocket连接之前注册page.on(websocket)之后所有连接事件都能拿到page.on(websocket, ws { console.log(连接建立:, ws.url()); ws.on(framesent, e { console.log(客户端 - 服务端:, e.payload); }); ws.on(framereceived, e { console.log(服务端 - 客户端:, e.payload); }); ws.on(close, () { console.log(连接关闭); }); });这段代码本身不改变任何网络行为只做记录类似在WebSocket层挂了个tcpdump。好处是侵入性极低不用改动被测应用也不影响真实连接。需要注意page.on(websocket)必须在页面发起连接之前注册。如果页面一进入就自动连接则要配合page.waitForEvent使用const wsPromise page.waitForEvent(websocket, { predicate: ws ws.url().includes(/ws) }); await page.goto(/chat); const ws await wsPromise;这个写法解决了“监听器注册太晚导致漏掉连接”的问题。如果页面有多个WebSocket连接用predicate按URL过滤出目标连接避免干扰。2.2 帧数据怎么断言旁路监听到的payload可能是字符串也可能是二进制数据。做断言前要先判断类型盲拼字符串容易翻车const frames []; page.on(websocket, ws { ws.on(framesent, e frames.push({ dir: sent, payload: e.payload })); ws.on(framereceived, e frames.push({ dir: received, payload: e.payload })); }); await page.goto(/chat); await page.fill(#message, hello); await page.click(#send); await expect.poll(() { const sent frames.find(f f.dir sent f.payload.toString().includes(hello)); return sent ? true : false; }).toBe(true);payload.toString()对文本帧足够。如果项目消息是JSON、JSONB、或者带二进制包头建议先统一转成字符串或Buffer再丢给解析函数。我在实际项目里习惯封装一个decodePayloadfunction decodePayload(payload) { if (payload instanceof Buffer) return payload.toString(utf-8); if (typeof payload string) return payload; if (payload instanceof ArrayBuffer) return Buffer.from(payload).toString(utf-8); return String(payload); }expect.poll里有几毫秒到几十毫秒的等待窗口基本能覆盖帧事件到达和DOM渲染之间的缝隙。不要用page.waitForTimeout(1000)这种固定等待既拖慢用例又不稳定。2.3 codegen帮不上忙自己封装监听helper用npx playwright codegen录制页面操作时生成的脚本只包含UI操作比如点击、输入、跳转不会自动生成WebSocket监听代码。这点和接口Mock工具不一样不能指望录制一下就有帧断言。我在项目里会把“监听WebSocket并记录帧”封装成一个公共函数放在tests/utils/ws.ts里export function collectWebSocketFrames(page: Page, urlPattern?: string) { const frames: Array{ dir: sent | received; payload: string } []; page.on(websocket, ws { if (urlPattern !ws.url().includes(urlPattern)) return; ws.on(framesent, e { frames.push({ dir: sent, payload: decodePayload(e.payload) }); }); ws.on(framereceived, e { frames.push({ dir: received, payload: decodePayload(e.payload) }); }); ws.on(close, () { frames.push({ dir: received, payload: __CLOSE__ }); }); }); return frames; }有了这个helper测试代码就干净很多test(发送消息后出站帧包含文本内容, async ({ page }) { const frames collectWebSocketFrames(page, /ws); await page.goto(/chat); await page.fill(#message, hello); await page.click(#send); await expect.poll(() frames.some(f f.dir sent f.payload.includes(hello)) ).toBe(true); });2.4 连接层面的事故怎么观察WebSocket连接建立之后不一定一直是通的。心跳超时、服务端主动断开、网络切换都可能导致连接中断。旁路监听里close事件能告诉我们连接关闭了但页面可能没有任何报错提示看起来一切正常。这时就需要把连接生命周期事件记录下来ws.on(close, () { console.log(连接关闭: ${ws.url()}); });部分Playwright版本还提供socketerror事件能感知底层错误如果你的版本没有这个事件就用close日志加服务端日志一起看。我实际排查一次“页面偶尔收不到推送”的问题时就是靠监听close事件发现连接其实在20秒后被服务端关闭了页面却还在展示“已连接”状态。这就是典型的WebSocket状态同步bug如果不监听连接事件纯看UI根本不会暴露。3. 从“看”到“改”routeWebSocket的主动拦截3.1 routeWebSocket和page.on的分工page.on(websocket)像一个旁路抓包工具它只能看不能改。如果测试需要模拟服务端推送、修改客户端发出消息的内容、主动断开连接就得用page.routeWebSocket。从职责上讲一个是被动监听一个是主动接管。routeWebSocket的核心价值在于它能让页面以为自己在跟真实服务端通信实际上连接被测试脚本接管了。新版本Playwright里routeWebSocket的API设计更语义化常见写法是这样await page.routeWebSocket(**/ws, ws { // 页面发送到服务端的消息 ws.onMessage(message { console.log(页面 - 服务端:, message); return message; // 原样放行 }); // 连接关闭 ws.onClose(() { console.log(连接被关闭); }); // 模拟服务端推送给页面 ws.send(JSON.stringify({ type: push, content: hello from mock server })); });如果你用的是较早版本事件名可能是framesent/framereceived能力会弱一些。接新项目时建议直接用语义化API。这里有个关键点routeWebSocket之后真实服务器连接是否建立取决于你是否继续转发。如果你只是想mock就不需要连接真实服务端。这样测试环境完全可控不会受到后端状态影响。3.2 用routeWebSocket模拟服务端推送模拟推送是WebSocket测试里场景最多的需求。比如聊天室里别人发来一条消息服务端推送{ type: push, content: 新消息 }页面要自动插入到消息列表。实现思路在routeWebSocket回调里拿到WebSocketRoute实例保存到外部变量测试主体里随时可以向页面推送test(服务端推送消息后页面自动展示, async ({ page }, testInfo) { let socket: WebSocketRoute | undefined; await page.routeWebSocket(**/ws, ws { socket ws; ws.onMessage(message { // 模拟回声把客户端发的消息作为服务器推送返回 const data JSON.parse(String(message)); ws.send(JSON.stringify({ type: push, content: echo:${data.content} })); }); }); await page.goto(/chat); await expect.poll(() socket).toBeTruthy(); await page.fill(#message, 这条消息需要回声); await page.click(#send); await expect(page.locator(.message-item)).toContainText(echo:这条消息需要回声); });这里我用expect.poll(() socket).toBeTruthy()等待routeWebSocket回调被触发而不是waitForTimeout。因为页面加载到连接建立之间有网络延迟固定等待必然造成用例不稳定轮询等待是唯一稳妥姿势。还有一类更直接的场景页面某处点击“刷新行情”按钮前端会发一个{ type: subscribe }请求测试要验证收到订阅确认推送后页面从“加载中”变成“已更新”。这类用例不需要真实后端把routeWebSocket当mock server用就完了。3.3 修改页面向服务器发送的消息routeWebSocket还能在onMessage里修改客户端发出的消息把一个消息替换成另一个再放行给“服务端”。常见用途是异常输入测试。比如有个聊天输入框前端有长度限制但后端也必须有校验。测试时可以用routeWebSocket把消息替换成超长文本或者带特殊字符的内容模拟绕过前端校验的情况await page.routeWebSocket(**/ws, ws { ws.onMessage(message { const original String(message); if (original.includes(normal)) { return JSON.stringify({ type: send, content: a.repeat(5000) // 超长内容 }); } return message; }); });注意onMessage的返回值语义返回字符串或Buffer表示用新内容替换原消息返回null表示丢弃这条消息不让它发出。不同版本对返回值的支持有差异写完后最好用一条真实请求验证一下行为别指望所有版本行为一致。3.4 主动断开连接验证异常处理断线重连是WebSocket应用最容易出bug的地方。很多前端只在收到close事件时才更新UI但没人测试过服务端异常断开时页面是否还能恢复。routeWebSocket可以直接在测试中途关闭这个连接test(服务端断开连接后页面提示重连, async ({ page }) { let socket: WebSocketRoute | undefined; await page.routeWebSocket(**/ws, ws { socket ws; }); await page.goto(/chat); await expect.poll(() socket).toBeTruthy(); // 模拟服务端主动断开 socket.close(); await expect(page.locator(.connection-status)).toHaveText(连接断开); await expect(page.locator(.connection-status)).toHaveText(重连中, { timeout: 3000 }); });这种用例对稳定性要求很高因为断线后页面可能立即重连也可能过几秒再重连。如果你要验证“重连后重新订阅成功”还需要在routeWebSocket里处理第二次连接第二次连接建立后服务端主动推一条订阅确认消息页面状态才恢复正常。这属于多阶段场景测试代码结构要设计成“连接次数计数器”而非单个socket变量。4. 页面没有暴露WebSocket对象时三种可行的处理方式4.1 不要指望业务代码里能拿到ws实例看到“测试侧主动发消息”的需求很多人的第一反应是await page.evaluate(() { window.ws.send(hello); });这个思路没错但前提是页面把WebSocket实例挂到了全局变量上。真实项目里连接可能是VueUse的useWebSocket封装可能是Socket.IO的Manager内部对象也可能是React组件里闭包变量你根本接触不到那个实例。所以“从页面evaluate直接send”在大多数项目里是走不通的。如果你的被测应用恰好暴露了window.__socket或类似全局变量那直接用没问题但不要把测试设计建立在“内部实现细节”上。全局变量一旦被重构掉测试就无声无息地失效了。4.2 用自己的测试代码也可以新建一条连接有些场景下测试其实不需要操作页面里那条连接只需要跟同一条WebSocket服务端通信。那就简单了在page.evaluate里新建一个WebSocket连接用它来触发服务端推送然后观察页面的反应。await page.evaluate(() { const ws new WebSocket(wss://example.com/ws); ws.onopen () { ws.send(JSON.stringify({ type: trigger, target: notify })); }; });但这样做的缺点是网络层面的真实连接不可控服务端可能不响应、响应延迟、或者触发额外的业务逻辑。不如routeWebSocket稳定。4.3 最通用的办法退回协议层先抓包再模拟我的习惯是接到一个WebSocket测试任务先不急着写用例先用2.1里的旁路监听代码跑一遍真实页面把完整的帧记录抓下来。比如进入页面后会收到哪些握手确认、订阅请求是什么格式、服务端推送是JSON还是二进制、心跳频率多少。整理成一份协议说明后再决定用routeWebSocket怎么mock。这个步骤很多人会跳过直接凭前端代码猜协议格式结果mock出来的消息内容跟真实服务端对不上测试本身先出bug。协议整理时重点记录握手URL路径和参数?tokenxxx这种动态参数怎么处理连接建立后第一条消息是什么订阅、鉴权、心跳心跳消息格式和频率30秒一次60秒一次业务消息类型字段取值type或event字段有哪些值二进制帧的编解码规则有了协议记录routeWebSocket的ws.send才能写出正确的JSON或二进制数据。4.4 Electron应用里也能用同一套逻辑如果被测应用是Electron比如桌面聊天工具Playwright的_electron.launch能直接拿到渲染进程的Page对象。拿到Page之后page.on(websocket)和routeWebSocket照常生效监听逻辑和普通浏览器完全一致。有一点要注意Electron应用里的WebSocket有时由主进程发起而不是渲染进程。这种情况下Page对象监听不到。遇到这种架构先确认连接到底是从哪里建立的在主进程发起的测试方案就得通过主进程日志或者改用拦截系统网络层的方式不能硬套Page级API。5. 一个完整可跑的聊天室场景从零到稳定5.1 测试目标与页面结构假设被测页面是/chat结构如下一个输入框#message一个发送按钮#send一个消息列表.message-item一个状态区域.connection-status业务协议简化成两条客户端发送{ type: send, content: hello }服务端推送{ type: push, content: echo:hello }测试覆盖三个场景页面发送消息后出站帧内容包含“hello”且服务端日志能对上服务端推送一条消息页面自动显示在消息列表服务端主动断开连接页面状态从“已连接”变成“连接断开”5.2 mock服务端推送的完整测试代码import { test, expect, Page } from playwright/test; import type { WebSocketRoute } from playwright-core; test.describe(聊天室WebSocket场景, () { test(发送消息后页面展示服务端回声, async ({ page }) { let socket: WebSocketRoute | undefined; await page.routeWebSocket(**/ws, ws { socket ws; ws.onMessage(message { const data JSON.parse(String(message)); if (data.type send) { ws.send(JSON.stringify({ type: push, content: echo:${data.content} })); } }); }); await page.goto(/chat); await expect.poll(() socket).toBeTruthy(); await page.fill(#message, 第一条消息); await page.click(#send); await expect(page.locator(.message-item)).toContainText(echo:第一条消息); }); test(服务端主动推送消息后页面立即更新, async ({ page }) { let socket: WebSocketRoute | undefined; await page.routeWebSocket(**/ws, ws { socket ws; }); await page.goto(/chat); await expect.poll(() socket).toBeTruthy(); await socket.send(JSON.stringify({ type: push, content: 服务端推送的通知 })); await expect(page.locator(.message-item)).toContainText(服务端推送的通知); }); test(服务端断开连接后页面显示断线状态, async ({ page }) { let socket: WebSocketRoute | undefined; await page.routeWebSocket(**/ws, ws { socket ws; }); await page.goto(/chat); await expect(page.locator(.connection-status)).toHaveText(已连接); await expect.poll(() socket).toBeTruthy(); socket.close(); await expect(page.locator(.connection-status)).toHaveText(连接断开); }); });5.3 稳定性处理为什么用可见文本断言而不是sleep我看到很多同学在WebSocket用例里习惯写await page.waitForTimeout(3000)理由是“等推送过来”。这在本地可能能跑通但一放到CI上就随机失败因为推送延迟取决于机器负载。Playwright的toContainText、toBeVisible这类断言本身带自动重试最多等待timeout配置的时间。用它来等待“推送导致UI更新”这件事比手动sleep可靠得多。唯一的坑是如果routeWebSocket的ws.send已经在断言之前执行但页面渲染很慢超时之后用例失败日志里看不出问题。这时候可以把ws.send的调用时间点和页面渲染时间点都打印出来排查是“消息没到页面”还是“页面渲染慢”。5.4 多客户端推送验证的扩展再往上走一步WebSocket应用经常要验证“服务端向多个客户端推送每个客户端都收到”。用Playwright可以开两个Page两个Page都路由同一条WebSocket路径然后分别验证各自页面收到的推送。test(多客户端同时收到推送, async ({ browser }) { const context1 await browser.newContext(); const context2 await browser.newContext(); const page1 await context1.newPage(); const page2 await context2.newPage(); let socket1: WebSocketRoute | undefined; let socket2: WebSocketRoute | undefined; await page1.routeWebSocket(**/ws, ws { socket1 ws; }); await page2.routeWebSocket(**/ws, ws { socket2 ws; }); await page1.goto(/chat); await page2.goto(/chat); await expect.poll(() socket1).toBeTruthy(); await expect.poll(() socket2).toBeTruthy(); await socket1.send(JSON.stringify({ type: push, content: 全局广播 })); await socket2.send(JSON.stringify({ type: push, content: 全局广播 })); await expect(page1.locator(.message-item)).toContainText(全局广播); await expect(page2.locator(.message-item)).toContainText(全局广播); });这种场景主要用来发现“连接隔离”方面的问题如果一个房间的消息被推给所有房间或者某个用户id写死导致别人也收到这类用例能很快暴露。6. 实测踩过的坑WebSocket测试从“能跑”到“稳定”6.1 “stream disconnected before completion”到底是谁的锅这个报错我一开始也遇到过字面意思是“连接在完成之前被断开”。在测试环境里它经常表现为用例偶发失败而且是在WebSocket握手成功后的几秒内。排查路径我固定按这个顺序走在用例里用page.on(websocket)打印所有close事件确认关闭发生在哪一步、有没有服务端主动断开。抓取页面发出第一条消息到服务端的时间点对比服务端日志里连接的注册和注销时间。看是否有网关层或代理层做了空闲超时。很多测试环境的Nginx默认proxy_read_timeout较短心跳机制没配好时连接就会被网关静默断开。如果确认是心跳问题优先让开发修被测应用的心跳逻辑而不是测试里强行缩短间隔或者忽略报错。WebSocket测试要的是稳定复现问题不是把测试改绿。6.2 帧事件回调里不要抛异常第一次写page.on(websocket)的时候我在回调里直接做了JSON.parse遇到非JSON帧就抛异常结果整个用例在奇怪的位置挂掉还看不出是哪行代码出的错。正确做法是回调函数里只收集原始数据不做可能抛错的逻辑。解析和断言都放到测试主体里page.on(websocket, ws { ws.on(framereceived, e { frameLog.push({ dir: received, raw: e.payload }); // 不要在这里解析JSON不要断言 }); });这样即使某条帧格式异常测试也只会看到一条raw日志而不会中断事件循环。6.3 二进制帧与文本帧混用时的解析WebSocket虽然常用JSON文本但也支持二进制帧。有些应用为了性能握手和心跳用文本业务数据用二进制或者整个都走二进制协议。遇到这种情况先打印payload的构造函数名称看是string、Buffer还是ArrayBuffer。再决定解析方式function parsePayload(payload: string | Buffer | ArrayBuffer): any { if (typeof payload string) { return JSON.parse(payload); } if (payload instanceof Buffer) { return JSON.parse(payload.toString(utf-8)); } return JSON.parse(Buffer.from(payload).toString(utf-8)); }如果协议不是JSON而是自定义编解码那测试代码里也要实现对应的编解码逻辑。这块别偷懒否则后续所有断言都会变成“比对字符串片段”脆弱得不行。6.4 routeWebSocket的通配符写太宽或太窄page.routeWebSocket(**/ws, ...)里的匹配规则和page.route一样是glob通配。写太窄会拦截不到写太宽可能把不需要mock的连接也影响。比如页面同时存在/ws/chat和/ws/metrics只测聊天功能时路由别写成**/ws这会把监控连接也接管了。可以写成await page.routeWebSocket(**/ws/chat*, ws { ... });按实际路径收窄保证不影响其他功能。6.5 环境安装的几个常见坑在Linux上跑Playwright经常遇到浏览器启动失败报“依赖缺失”。解决方式是执行npx playwright install --with-deps容器或CI环境里如果没有root权限可以显式关闭浏览器沙箱use: { launchOptions: { chromiumSandbox: false } }但这只建议在可信环境里用普通本机开发不要全局关沙箱。离线环境装浏览器依赖时官方安装脚本会从网络下载。可以提前在一台联网机器上执行npx playwright install chromium然后把浏览器缓存目录整体拷贝到测试机的用户目录下。注意Playwright版本要一致版本不一致会提示找不到浏览器。6.6 代码组织把WebSocket工具抽成公共模块监听、收集帧、routeWebSocket这些逻辑如果每个测试文件都写一遍维护成本会很快失控。我在项目里会统一封装成两个函数// ws-utils.ts export function attachWebSocketLogger(page: Page, frames: FrameLog[]) { page.on(websocket, ws { ws.on(framesent, e frames.push({ dir: sent, payload: decodePayload(e.payload) })); ws.on(framereceived, e frames.push({ dir: received, payload: decodePayload(e.payload) })); ws.on(close, () frames.push({ dir: received, payload: __CLOSE__ })); }); } export async function mockWebSocket( page: Page, urlPattern: string, handler: (ws: WebSocketRoute) void ) { return page.routeWebSocket(urlPattern, handler); }这样测试文件只需要关心业务断言WebSocket的底层记录和路由逻辑都收敛在公共模块里。团队里其他人接手用例时也不用重新踩一遍帧事件、二进制解析这些坑。WebSocket测试最大的变数不在Playwright的API而在对业务协议和连接生命周期的理解。把这两块搞清楚剩下的就是把这些API组合成稳定的断言。我现在接到实时推送模块的测试需求第一件事永远是先用旁路监听跑一遍真实链路把协议和连接事件整理成文档再设计mock场景。这个习惯帮我省掉了大量调试时间也避免了很多“测试本身写错”的假失败。
返回列表