做Web端自动化测试这几年,我遇到最头疼的一类问题,不是按钮点不到,也不是断言超时,而是那些走WebSocket推送的功能。实时行情、在线客服、协同编辑、监控大屏——你用Playwright把页面操作写到行云流水,结果要验证的数据根本不是从HTTP响应里来的,而是从一条长连接里源源不断推下来。想在测试里断言、录制、模拟服务端异常,光靠click和expect完全抓瞎。
这篇文章我会把Playwright自动化框架里处理WebSocket的整套思路完整梳理一遍:从最简单的监听消息帧,到在浏览器和服务端之间做消息拦截,再给一个可以直接抄作业的录制与断言脚本,最后附上几个我在真实项目里踩过的高频报错和排查记录。适合已经能写基础Playwright用例、但对WebSocket交互不知道从哪里下手的测试开发同学参考。
1. 先搞清楚WebSocket测试难在哪,才知道怎么下手
1.1 WebSocket和普通HTTP请求的本质差异
要解决WebSocket的测试问题,得先理解它和普通HTTP请求的差别。普通HTTP是请求-响应模型,客户端发一个包,服务端回一个包,连接通常是短连接。Playwright里处理这种交互非常成熟,想改请求就page.route,想看响应就page.on("response"),一切都有明确的“开始”和“结束”。
WebSocket不一样。它先在HTTP协议上完成一次握手,然后升级成一条全双工的长连接。也就是说,一次连接建立之后,浏览器和服务端可以随时往对端推数据,没有“谁先请求谁后响应”的约束。实际项目里的典型场景是:页面刚打开,服务端就把历史消息全量推下来;之后某个状态变化,服务端又主动推送增量数据。这种模式下,你很难靠“等待某个URL响应”来断言数据,因为根本没有新的URL请求发生,数据是顺着一个已经建立的连接流过来的。
测试人员对WebSocket的另一个直观感受是:浏览器Network面板里能看到这些帧,但普通抓包工具和脚本接口拿不到。之前做一个实时告警项目,线上反馈“页面偶尔不弹告警”,我们第一反应是去抓HTTP接口,抓了半天一无所获,最后打开DevTools切换到WS标签页,才发现告警数据全是从WebSocket推下来的。所以WebSocket测试的第一步,是先解决“看得见消息”的问题,而不是急着做断言。
1.2 处理WebSocket的两条路线:监听与拦截
Playwright给了两条处理路线,能覆盖绝大多数WebSocket测试场景。
第一条是“只读监听”,适合验证前端是否正确收到了服务端推送。思路是通过page.on("websocket")拿到WebSocket实例,再监听它的消息帧事件,把每一帧发出去的数据和收到的数据都记录下来,作为断言依据。这种方式的优点是非常轻量,基本不改变被测页面行为,适合回归测试和巡检类场景。
第二条是“中间层拦截”,适合需要模拟服务端异常、伪造推送消息、或者把真实WebSocket替换成测试替身的场景。Playwright从1.26版本开始提供page.route_web_socket(),允许你在浏览器和目标服务端之间插入一段自定义逻辑。你可以完全不连真实服务端,直接往浏览器里塞消息;也可以先连上真实服务端,在中间做转发的同时记录、丢弃或者篡改某些帧。
两条路线的适用场景差别很大,我列一个表方便对照:
| 测试需求 | 推荐路线 | 说明 |
|---|---|---|
| 验证页面是否收到服务端推送 | 只读监听 | page.on("websocket")最轻量 |
| 录制WebSocket消息用于定位问题 | 只读监听 | 记录全部帧,测试结束后导出 |
| 测试服务端异常(断连、无响应) | 中间层拦截 | 用route模拟异常行为 |
| 后端没就绪,先跑前端测试 | 中间层拦截 | 直接route.send()推送假数据 |
| 在消息链路中篡改某个字段 | 中间层拦截 | 先connect_to_server再做转发 |
这个表可以在方案设计阶段帮你快速定方向。接下来我会把这两条路线的具体API和实战写法讲透。
2. 直接盘API:监听消息帧与中间层拦截
2.1 page.on("websocket"):先看见消息
page.on("websocket")注册一个回调,只要页面里有WebSocket连接建立,无论顶层页面还是iframe里发起的,都会触发这个回调。回调参数是一个WebSocket实例,上面挂着framesent、framereceived、close、socketerror几个事件。
下面是最小可用的监听代码,我用Python的同步API写:
from playwright.sync_api import sync_playwright def on_websocket(ws): print("WebSocket连接建立:", ws.url) ws.on("framesent", lambda payload: print("浏览器发出:", payload)) ws.on("framereceived", lambda payload: print("浏览器收到:", payload)) ws.on("close", lambda: print("WebSocket连接关闭")) ws.on("socketerror", lambda error: print("WebSocket错误:", error)) with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.on("websocket", on_websocket) page.goto("https://example.com/realtime") page.wait_for_timeout(15000) browser.close()这段代码看着简单,其实已经把WebSocket测试最核心的“看得见”问题解决了。framesent代表浏览器主动发给服务端的消息,framereceived代表服务端推给浏览器的消息,payload通常是字符串,如果消息是二进制帧,payload会是bytes。你可以在控制台把消息打出来,也可以收集到一个列表里,后面用于断言。
有一个细节要注意:page.on("websocket")里拿到的WebSocket实例,只能通过事件回调注册监听。如果注册晚了,比如在连接建立之后才去监听framesent,已经发过的帧是补不回来的。所以注册page.on("websocket")的动作,一定要发生在触发WebSocket连接的页面操作之前,最稳妥的位置是page.goto()之前。
另外,同步脚本里事件回调内部的异常会被Playwright捕获并重新抛出,这可能让整个测试直接崩掉。真在回调里处理数据时,建议自己try/except包一层,避免一条脏数据把测试带崩。
2.2 page.route_web_socket():在浏览器与服务端之间加一道闸门
如果只做只读断言,2.1的方法已经够用。但很多测试场景不满足于“看”,还要“操控”。这时候需要page.route_web_socket()上场。
先看一个最简单的透传例子:
def handle_ws(route): # 连接真实服务端 server = route.connect_to_server() # 服务端消息转发给浏览器 server.on_message(lambda message: route.send(message)) # 浏览器消息转发给服务端 route.on_message(lambda message: server.send(message)) # 任意一方断开,另一方也跟着断开 route.on_close(lambda: server.close()) server.on_close(lambda: route.close()) with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.route_web_socket("**/ws", handle_ws) page.goto("https://example.com/realtime") # 后续测试逻辑...route_web_socket的第一个参数是URL匹配规则,用的同样是glob风格,支持**匹配多级路径。这个规则匹配的是WebSocket的握手URL,也就是ws://或者wss://开头的地址。如果页面里同时存在WebSocket和普通HTTP请求,这个规则只对WebSocket握手生效。
调用connect_to_server()之后,返回的不是原来的WebSocket连接,而是一个WebSocketConnection对象,它和浏览器侧的消息互不干扰。你需要自己在两个方向上搭桥。如果只想监听不想转发,把server.on_message和route.on_message里的转发逻辑去掉,就天然变成了一个“偷听”节点。
这里有一个很实际的坑:route_web_socket一旦注册,这个URL模式下的所有WebSocket连接都会进入handler。如果页面里有多条WebSocket连接,比如一条用来收推送、一条用来上报埋点,你的handler就要做连接级别的区分,否则可能出现消息串场。建议在handler开头打印一下URL,或者从消息内容里带连接标识再过滤。
如果不想连真实服务端,还可以直接用route.send()向浏览器塞消息。对于“后端还没开发完,前端联调测试”的场景,这几乎是神器。页面该触发的地方触发一下,你直接伪造推送内容,照样能验证前端的渲染逻辑和异常兜底。
2.3 消息断言与录制策略
监听和拦截的代码能跑通之后,下一件事就是怎么把它变成测试断言。WebSocket消息的断言不像HTTP响应那样有个统一的“状态码”,核心思路是:把消息帧变成测试里可以查询的数据,再去做等待和断言。
我在实际项目里一般会封装一个WebSocketCollector:
import time class WebSocketCollector: def __init__(self): self.sent = [] self.received = [] self.connections = [] def attach(self, page): page.on("websocket", self._on_ws) def _on_ws(self, ws): self.connections.append({"url": ws.url, "time": time.time()}) ws.on("framesent", lambda payload, conn=ws.url: self.sent.append({"conn": conn, "data": payload})) ws.on("framereceived", lambda payload, conn=ws.url: self.received.append({"conn": conn, "data": payload}))有了这个Collector,断言就变得很直观:
def test_leave_message_pushed(): collector = WebSocketCollector() collector.attach(page) page.goto("https://example.com/realtime") page.fill("#message-input", "hello") page.click("#send-btn") # 等待前端渲染 expect(page.locator("#chat-list")).to_contain_text("hello") # 再验证消息链路本身 assert any("hello" in item["data"] for item in collector.sent), "浏览器应发出hello消息" assert any("ack" in item["data"] for item in collector.received), "服务端应回ack"为什么等待DOM的同时还要断言WebSocket消息?因为DOM断言验证的是前端渲染结果,WebSocket消息验证的是数据链路本身。如果页面有时候渲染慢,但消息其实已经收到了,只看DOM容易误判;反过来,如果消息压根没到,DOM等多久都不会变。两条断言一起上,定位问题会快很多。
3. 搞一个能落地的WebSocket录制与断言脚本
3.1 先定场景:一个实时公告页面
我拿一个很贴近真实工作的场景举例:被测页面是一个运营后台的“实时公告”模块。页面打开后,通过WebSocket订阅公告流;服务端每隔几秒推送一条公告;前端收到后会在页面顶部滚动展示,同时播放提示音。
这个测试要验证什么?我归纳成三条需求:
- 页面打开后,必须建立WebSocket连接;
- 打开期间,服务端推送的公告,前端必须逐条收到并展示;
- 点击页面的“停止接收”按钮后,WebSocket连接应该被前端主动关闭,不再接收新公告。
这其实就是实时类系统“能连、能收、能断”三个最基本的验收点。用Playwright做这三条验证,光靠页面交互是不够的,必须把WebSocket消息层的数据也拉进来。
3.2 完整代码实现
完整脚本我贴出来,代码里的注释尽量写清楚每一步在干什么:
import json import time from playwright.sync_api import sync_playwright class WebSocketCollector: def __init__(self): self.sent = [] self.received = [] self.connections = [] self.closed = [] def attach(self, page): page.on("websocket", self._on_ws) def _on_ws(self, ws): ws_url = ws.url self.connections.append({"url": ws_url, "time": time.time()}) ws.on("framesent", lambda payload: self.sent.append({"url": ws_url, "data": payload})) ws.on("framereceived", lambda payload: self.received.append({"url": ws_url, "data": payload})) ws.on("close", lambda: self.closed.append({"url": ws_url, "time": time.time()})) def run(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() collector = WebSocketCollector() collector.attach(page) page.goto("https://your-project.example.com/announcement") page.wait_for_load_state("networkidle") # 需求1:必须有WebSocket连接建立 assert len(collector.connections) > 0, "未检测到WebSocket连接" print("WebSocket连接已建立:", collector.connections[0]["url"]) # 需求2:等待服务端推送,记录所有收到的公告 page.wait_for_selector(".announcement-item", timeout=10000) announcements = [] for item in collector.received: data = item["data"] if isinstance(data, str) and "announcement" in data: announcements.append(json.loads(data)) assert announcements, "没有收到任何公告推送" print("收到公告数:", len(announcements)) # 需求3:点击停止接收,期望连接关闭 page.click("#stop-receive") page.wait_for_timeout(1000) assert len(collector.closed) > 0, "点击停止后连接未关闭" print("WebSocket连接已按预期关闭") browser.close() if __name__ == "__main__": run()这个脚本有几个细节值得展开。
第一个是received数据的提取方式。我用“消息内容里包含announcement字段”来判断,真实项目里WebSocket消息往往是JSON格式,字段名不固定。建议在封装Collector时,把每一条帧同时记录原始字符串和解析后的JSON,断言层按需取用。二进制帧的提取稍微麻烦一些,可能会出现半包和粘包,我个人的经验是优先推动后端把关键消息改成文本帧,测试稳定性会高很多。
第二个是断言的时机。这个脚本把断言放在浏览器关闭之前、事件都稳定落袋之后,避免在事件回调尚未处理完时就断言。如果希望在测试过程中早失败,也可以把断言提前到具体操作之后,看你团队的风格。
第三个是数据竞争问题。pytest跑多线程时,如果多个用例共用一个WebSocketCollector,消息列表会被跨用例的消息交叉污染。我的做法是每个用例单独实例化一个Collector,测试结束后立即导出消息副本,不做跨用例共享。
3.3 工作流整合:Codegen、Robot Framework、Playwright MCP
很多团队不会只用裸的Playwright,这里把和常见工作流的整合方式说一下。
先用playwright codegen录制页面交互。codegen能帮你快速得到点击、输入这些“页面操作骨架”,比如定位停止接收按钮。但它不会自动生成WebSocket监听逻辑,因为WebSocket消息本身不是页面操作,没法通过录制获得。所以建议是:codegen负责操作骨架,WebSocket的监听与断言代码手工补进Collector相关部分,两类代码合到同一个脚本里。
Robot Framework的用户也别急着劝退。playwright生态里有一个robotframework-browser库,它把Playwright的很多能力封装成了RF关键字,但没有单独把WebSocket事件监听做成一等公民。遇到要断言WebSocket消息的项目,更稳的方案是写一个Python扩展库,内部用Playwright的API做监听,再对外暴露成Robot Framework关键字。RF只管调度和报告,底层还是Playwright自己那套事件体系,这样既保留了RF的语法,又拿到了WebSocket的原生能力。
再聊聊最近比较受关注的Playwright MCP。这个东西的主要价值是让大模型通过工具调用去驱动浏览器、查看页面状态,方便AI辅助测试。但至少在当前版本里,MCP侧对WebSocket消息的实时处理还是有限的,它更多是让模型“看到”页面现状,而不是让模型“感知”到某条二进制帧。如果团队在做AI测试方向探索,对WebSocket消息的断言还是建议落在Python或Node的测试脚本里,不要让模型直接处理原始帧,成本高且不稳定。
顺便提一句和其他框架的对比。Cypress本身对WebSocket没有原生支持,想监听消息帧一般得依赖cy.websocket这类第三方插件;Playwright的原生WebSocket监听API确实更直接。这也是我在实时消息类项目里选Playwright的一个重要原因。
4. 高发问题排查与自动化框架整合经验
4.1 target closed报错的真实原因
有段时间我经常在测试结束阶段看到这个报错:
playwright._impl._errors.TargetClosedError: Target closed报错信息通常还会带一句“target page, context or browser has been closed”,意思是代码尝试访问一个已经被关闭的页面、上下文或浏览器对象。最常见的原因有三种。
第一种,测试里过早调用了browser.close()。比如等待某个断言超时,脚本进入except分支直接执行了browser.close(),但另一个还在后台运行的异步回调或wait_for方法仍然持有page引用,于是访问page时抛出target closed。解决方法是管理好资源释放顺序,在finally里统一关闭,别在业务逻辑中间随意close。
第二种,页面自身发生了跳转或刷新。WebSocket长连接往往绑定旧页面,页面一刷新,旧连接被浏览器关闭,Playwright这边如果还监听着旧的WebSocket事件,在某些版本下会抛出target closed。这种情况不要硬接,建议在跳转发生后重新注册监听,或者把Collector的查询放在页面稳定之后。
第三种,多页面之间串对象。page对象和页面不是同一个东西,一个browser context下可以有多个page。如果你后台任务误用了某个已经关闭的page,也会触发。排查时先确认自己操作的是不是当前活跃的page,再用page.is_closed()做一个前置判断,能避免大部分误报。
再分享一个真实踩过的场景:用playwright加pytest跑一套10个用例的回归,第7个用例开始随机报target closed,但单独跑每一个都通过。后来发现是fixture里把browser对象设计成session级,用例之间没有正确清理WebSocketCollector,消息列表还在被上一个用例的回调追加,而那个用例的page早就关闭了。把Collector的作用域改成function级,问题立刻消失。
4.2 stream disconnected before completion:服务端先动手断了连接
另一个比较新的报错,常见于较新版本的Playwright:
stream disconnected before completion: websocket closed by server before response这个报错的核心是:WebSocket连接在预期完成之前,被服务端主动关闭了。它不一定代表测试代码写错,更多时候是被测服务端主动断连。
我遇到的情况分两类。一类是服务端有超时机制,比如连接建立后60秒内没收到任何心跳,服务端就把连接掐了。测试脚本如果长时间不操作页面,自然就被踢下线。这类问题的排查很简单,看服务端的WebSocket日志或Network面板里WS连接的时间线,确认是不是断在了服务端的超时策略上。如果要跑长时间用例,可以在脚本里做心跳模拟,定期往连接里发ping或业务心跳消息。
另一类是页面在连接还没完全建立时,就发生了跳转或关闭,导致服务端发现客户端“不辞而别”,主动关闭连接。这种情况的错误信息里经常能看到“before response”字样。排查思路是检查测试流程里是否存在连续goto,或者页面加载失败后没有等待load事件完成。
处理这类报错时,我习惯先把Playwright的DEBUG日志打开:设置环境变量DEBUG=pw:protocol,跑一遍复现,看是客户端发出的close帧,还是服务端先关闭的。这个定位思路对绝大多数连接类问题都有效。
4.3 被测站点识别出自动化控制时怎么办
很多测开同学会在某个阶段突然发现:在本机跑得好好的Playwright脚本,换到某台机器或者某条测试环境上,页面行为就完全不一样了。比如要求弹验证码、页面白屏、接口正常但页面不渲染,或者WebSocket连接刚建立就被服务端掐断。
先解释一下为什么会被识别。现代浏览器自动化工具和真实浏览器的差异,主要体现在几个层面:一是浏览器启动参数里默认带上了自动化控制标志;二是window.navigator.webdriver属性默认是true;三是浏览器指纹层面的自动化特征。如果被测系统本身风控策略比较严格,这些特征就会被检测到,然后采取对应拦截动作。
先说一个原则:如果你测试的是自己负责的系统,发现自动化特征影响测试,应该从测试环境配置上找解决方案,而不是想办法对付线上风控。如果你是给第三方站点做自动化采集,那涉及合规问题,这篇内容不讨论也不建议。
合法的排查方向有几个。第一,确认是不是只有无头模式才出问题,把headless改成False,很多特征其实只在无头模式下比较明显。第二,优先使用launch_persistent_context加载一个真实的用户数据目录,让浏览器复用日常使用的cookie、localStorage和浏览器配置,很多跟“冷启动”相关的识别会因此消失。第三,把自动化控制标志关掉或显式设置一个偏移量很小的user agent,让测试环境更接近真实用户环境。简单示例:
context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", locale="zh-CN", viewport={"width": 1920, "height": 1080}, ) page = context.new_page()注意,这些操作的目标是让测试环境更接近真实用户环境,而不是教大家去逃避风控审核。如果你的被测系统安全性要求高,建议把自动化测试跑在专门的白名单测试环境里,而不是动线上生产环境。
4.4 容易被忽略的环境坑:Linux、离线安装、沙箱、Electron与iframe
最后聊几个环境问题,都是实际反馈里高频出现的。
Linux上安装Playwright。很多服务器是纯净环境,直接pip install playwright,然后playwright install chromium,启动时还是报缺一堆系统依赖。正确的做法是安装后执行playwright install --with-deps chromium,它会用系统的包管理器补齐依赖。如果是内网环境,可以在能连外网的机器上把driver和浏览器下载好,整个缓存目录拷贝到内网,离线部署也能跑起来。
root沙箱问题。Linux容器里以root跑Playwright,一定要在launch时加上args=["--no-sandbox"],否则浏览器起不来。这一步要写进代码里,别等到CI里报sandbox错误才手工加。
Electron应用里的WebSocket测试。有的客户端用Electron封装,内核虽然是Chromium,但会自己管理浏览器窗口。直接用playwright.launch()是连不上的,需要先通过electron.launch()或者connect_over_cdp去连Electron的调试端口。WebSocket的监听逻辑本身不变,依然可以在对应context的page上注册page.on("websocket")。
动态iframe里的WebSocket。这个问题被问过很多次。实际上page.on("websocket")监听的是整个page上下文范围,iframe内部发起的WebSocket连接同样会触发事件。如果发现没触发,大概率是事件注册太晚,或者iframe里的连接在页面跳转时被销毁了。建议先用page.wait_for_selector定位到iframe入口,再执行触发操作,确保监听注册在连接建立之前。
定位元素和消息等待的配合。WebSocket消息到达后,页面DOM会有变化,但真实渲染有延迟。如果直接断言某个span文本,容易踩时序问题。我的做法是先等待消息帧进入Collector,再等待DOM文本出现,两个条件都满足才通过。定位span本身用page.locator("span:has-text('公告标题')")就够,关键是别把消息等待和DOM等待一刀切。
最后分享一个我实际养成的小习惯:每个用Playwright做WebSocket测试的项目,我都会在测试报告之外单独导出一份WebSocket消息记录,哪怕是全绿的用例也照导不误。原因是WebSocket消息是调试实时类系统最直接的第一手资料。之前有个线上告警不弹的问题,我们就是靠测试用例里导出的那批framereceived记录反查服务端推送链路,才发现是消息里某个字段缺失导致前端过滤掉了。这份记录在排查问题时的价值,往往比测试报告本身的“通过/失败”大得多。如果时间允许,建议在用例结束后把消息列表输出成JSON文件存档留痕,后续复盘的时候会感谢自己这个操作。