最近帮团队补测一个实时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,再丢给解析函数。我在实际项目里习惯封装一个decodePayload:
function 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路径和参数(
?token=xxx这种动态参数怎么处理) - 连接建立后第一条消息是什么(订阅、鉴权、心跳)
- 心跳消息格式和频率(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场景。这个习惯帮我省掉了大量调试时间,也避免了很多“测试本身写错”的假失败。