
做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(headlessFalse) 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(headlessFalse) 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响应那样有个统一的“状态码”核心思路是把消息帧变成测试里可以查询的数据再去做等待和断言。我在实际项目里一般会封装一个WebSocketCollectorimport 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, connws.url: self.sent.append({conn: conn, data: payload})) ws.on(framereceived, lambda payload, connws.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(headlessTrue) 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, timeout10000) 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服务端先动手断了连接另一个比较新的报错常见于较新版本的Playwrightstream disconnected before completion: websocket closed by server before response这个报错的核心是WebSocket连接在预期完成之前被服务端主动关闭了。它不一定代表测试代码写错更多时候是被测服务端主动断连。我遇到的情况分两类。一类是服务端有超时机制比如连接建立后60秒内没收到任何心跳服务端就把连接掐了。测试脚本如果长时间不操作页面自然就被踢下线。这类问题的排查很简单看服务端的WebSocket日志或Network面板里WS连接的时间线确认是不是断在了服务端的超时策略上。如果要跑长时间用例可以在脚本里做心跳模拟定期往连接里发ping或业务心跳消息。另一类是页面在连接还没完全建立时就发生了跳转或关闭导致服务端发现客户端“不辞而别”主动关闭连接。这种情况的错误信息里经常能看到“before response”字样。排查思路是检查测试流程里是否存在连续goto或者页面加载失败后没有等待load事件完成。处理这类报错时我习惯先把Playwright的DEBUG日志打开设置环境变量DEBUGpw:protocol跑一遍复现看是客户端发出的close帧还是服务端先关闭的。这个定位思路对绝大多数连接类问题都有效。4.3 被测站点识别出自动化控制时怎么办很多测开同学会在某个阶段突然发现在本机跑得好好的Playwright脚本换到某台机器或者某条测试环境上页面行为就完全不一样了。比如要求弹验证码、页面白屏、接口正常但页面不渲染或者WebSocket连接刚建立就被服务端掐断。先解释一下为什么会被识别。现代浏览器自动化工具和真实浏览器的差异主要体现在几个层面一是浏览器启动参数里默认带上了自动化控制标志二是window.navigator.webdriver属性默认是true三是浏览器指纹层面的自动化特征。如果被测系统本身风控策略比较严格这些特征就会被检测到然后采取对应拦截动作。先说一个原则如果你测试的是自己负责的系统发现自动化特征影响测试应该从测试环境配置上找解决方案而不是想办法对付线上风控。如果你是给第三方站点做自动化采集那涉及合规问题这篇内容不讨论也不建议。合法的排查方向有几个。第一确认是不是只有无头模式才出问题把headless改成False很多特征其实只在无头模式下比较明显。第二优先使用launch_persistent_context加载一个真实的用户数据目录让浏览器复用日常使用的cookie、localStorage和浏览器配置很多跟“冷启动”相关的识别会因此消失。第三把自动化控制标志关掉或显式设置一个偏移量很小的user agent让测试环境更接近真实用户环境。简单示例context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, localezh-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文件存档留痕后续复盘的时候会感谢自己这个操作。