代码改变世界

完整教程:【获取WebSocket】使用 Playwright 监听 Selenium 自动化测试中的 WebSocket 消息(一)

2026-01-16 09:37  tlnshuju  阅读(0)  评论(0)    收藏  举报

使用 Playwright 监听 Selenium 自动化测试中的 WebSocket 消息(一)

一、前言:为什么 Selenium 自动化里“看不到” WebSocket?

在现代前端应用中,越来越多的核心逻辑通过 WebSocket 完成,例如:

  • 多人协同编辑(SelectionChange、EditRecord 等实时广播)
  • 实时状态同步
  • 前端操作与后端推送解耦

在浏览器中,这些信息可以通过
DevTools → Network → WebSocket → Messages 直观查看。

但在 Selenium 自动化测试中,会遇到一个现实问题:

  • Selenium 3.x 没有 DevTools API
  • Selenium 4.x 以上,如果使用了 DevTools API,需要持续更新WebDirver版本以匹配不断更新的浏览器

这在以下场景中会成为瓶颈:

  • 需要验证某个操作是否真的向后端发送了 Action:"EditRecord"
  • 需要校验某一步操作触发了 几次SelectionChange
  • 仅靠 UI 状态不足以判断功能是否正确

本文将介绍一种借助Playwright辅助Selenium 框架的解决方案:
使用 Playwright 作为“旁路监听器”,专门监听 WebSocket。


二、整体技术方案:Selenium 驱动,Playwright 监听

本方案的核心思想是:

Selenium 继续负责“操作页面”,Playwright 只负责“监听浏览器的 WebSocket”。

两者并不是互相替代,而是各司其职。

2.1 方案结构概览

整体结构可以概括为:

  • Selenium

    • 创建并管理 Chrome WebDriver
    • 执行页面操作(点击、输入、拖拽等)
  • Chrome

    • 启动时开启 --remote-debugging-port
  • Playwright

    • 通过 Chrome DevTools Protocol(CDP)连接到同一个浏览器实例
    • 监听 WebSocket 帧(FrameSent / FrameReceived)
  • 测试代码

    • 对捕获到的 WebSocket 消息进行解析和断言

2.2 为什么选择 Playwright?

Playwright 在这里的价值主要体现在:

  • 原生支持 CDP(Chrome DevTools Protocol)
  • 可以附着到已经存在的浏览器进程
  • 提供 page.WebSocket 事件,直接获取 WS 帧数据
  • 不影响 Selenium 的 UI 操作

这意味着:
我们不需要重写现有 Selenium 用例,只需额外“接一根线”。


三、环境与前提条件说明

在继续之前,需要明确本文适用的技术前提。

3.1 技术栈前提

本文基于以下环境设计:

  • Selenium WebDriver 3.10.x

  • 测试框架:MSTest

  • 浏览器:Chrome

  • Playwright:.NET 官方版本

  • 已存在的自动化框架中:

    • 有统一的 TestBase
    • 有 Browser 封装(支持 SetChromeOptions

3.2 重要约束条件

这是本文方案成立的关键前提:

  1. 不能修改 Selenium Browser 核心实现

    • CreateWebDriver() 是私有方法
    • Browser 类可能来自 DLL
  2. 但可以:

    • 修改 TestBase
    • 在测试类中增加辅助逻辑
  3. 测试用例可能在:

    • 多台机器
    • 多进程
    • 并发执行

因此,本方案必须满足:

  • 对现有框架侵入极小
  • 不引入端口冲突
  • 不影响非 WebSocket 用例

四、第一步:让 Selenium 启动一个“可被监听”的浏览器

Playwright 想要监听 WebSocket,有一个前提条件:

浏览器必须开启 remote-debugging-port。

4.1 为什么需要 remote-debugging-port?

Playwright 并不是“魔法监听”,它的本质是:

  • 通过 CDP(Chrome DevTools Protocol)
  • 连接到浏览器调试端口
  • 订阅 Network / WebSocket 事件

如果 Chrome 启动时没有开启该端口,Playwright 无从连接。

4.2 注入 ChromeOptions 的关键时机

在很多 Selenium 框架中:

  • WebDriver 是懒加载的
  • Browser 在第一次访问 WebDriver 时才真正启动浏览器

幸运的是,大多数成熟框架都会提供一个生命周期钩子,例如:

protected virtual void AfterInitializeTestDriver()

这个方法的特点是:

  • Browser 已创建
  • WebDriver 尚未创建
  • 正是注入 ChromeOptions 的最佳时机

4.3 在 TestBase 中注入 remote-debugging-port

示例代码如下:

protected override void AfterInitializeTestDriver()
{
base.AfterInitializeTestDriver();
if (PlaywrightWsCollectorBridge.DebugPort == 0)
return;
var browser = this.ActiveBrowser;
if (browser.BrowserType == BrowserType.Chrome)
{
var options = new ChromeOptions();
options.AddArgument(
$"--remote-debugging-port={PlaywrightWsCollectorBridge.DebugPort}"
);
browser.SetChromeOptions(options);
}
}

这里做了三件关键的事:

  1. 仅在需要 WebSocket 的测试中生效(通过 DebugPort 判断)
  2. 使用 Chrome 原生支持的 --remote-debugging-port
  3. 不修改 Browser 内部实现,只调用公开扩展点

到这里,Selenium 启动的 Chrome 已经具备了“被 Playwright 监听”的能力。


五、代码实战:实现 PlaywrightWsCollector(专职监听 WebSocket)

这一节的目标很明确:把“DevTools 里 WS → Messages 面板看到的东西”,变成测试代码里可读取、可切片、可断言的数据结构。
同时要满足几个工程要求:

  • Playwright 不接管 UI,只监听;
  • 连接必须通过 CDP 端口,而端口是 Selenium 启动 Chrome 时注入的;
  • 避免常见坑:.NET 没有 IBrowser.ContextCreatedECONNREFUSED 等;
  • 能做到“只取某一步操作产生的 WS 消息”,而不是全程污染。
    在这里插入图片描述

5.1 设计一个可复用的消息模型 WsMessage

我们不仅要拿到文本,还要知道它属于:

  • 上箭头 / 下箭头(Sent / Received)
  • 以及消息发生顺序(用于“操作切片”)
public class WsMessage
{
public long Seq { get; set; }            // 递增序号:用于切片
public string MessageType { get; set; }  // "Sent" / "Received"
public string Text { get; set; }         // WebSocket 帧内容(一般是 JSON)
}

5.2 端口“桥接”对象(让 TestBase 能拿到端口)

在第 4 节里已经在 TestBase.AfterInitializeTestDriver() 注入了:

--remote-debugging-port=xxx

TestBase 自己并不知道端口是什么,因此我们用一个小桥接类存放端口值即可:

public static class PlaywrightWsCollectorBridge
{
public static int DebugPort { get; set; } = 0;
}

约定:

  • DebugPort == 0:表示当前用例不需要 WS 监听;
  • 非 0:TestBase 注入该端口,Playwright 用同一个端口连接。

5.3 PlaywrightWsCollector:核心类骨架

这个类负责:

  1. 生成一个可用端口(供 Selenium 启动 Chrome)
  2. 在浏览器启动后,通过 CDP 连接
  3. hook page 的 WebSocket 事件
  4. 收集消息、切片、分类、清理

关键点:不要在构造函数里 Connect,否则浏览器还没启动就连,会直接 ECONNREFUSED

5.3.1 生成空闲端口(多机/多进程安全)
using System.Net;
using System.Net.Sockets;
public static int GenerateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
int port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}

5.4 完整实现:PlaywrightWsCollector.cs

说明:这里使用 127.0.0.1,避免某些机器 localhost 解析到 IPv6 ::1 导致连接异常。

using Microsoft.Playwright;
using System.Collections.Concurrent;
using System.Threading;
using System.Linq;
public class PlaywrightWsCollector : IDisposable
{
private IPlaywright _playwright;
private IBrowser _browser;
private readonly ConcurrentBag<WsMessage> _messages = new ConcurrentBag<WsMessage>();
  private long _seq = 0;
  private bool _connected = false;
  private bool _pageHooked = false;
  public int DebugPort { get; }
  public PlaywrightWsCollector(int debugPort)
  {
  DebugPort = debugPort; // 仅保存端口,不做连接
  }
  /// <summary>
    /// 在 Selenium 浏览器启动(带 remote-debugging-port)后调用。
  /// </summary>
  public void EnsureConnected()
  {
  if (_connected) return;
  _playwright = Playwright.CreateAsync().GetAwaiter().GetResult();
  var url = $"http://127.0.0.1:{DebugPort}";
  _browser = _playwright.Chromium.ConnectOverCDPAsync(url).GetAwaiter().GetResult();
  HookExistingContexts(_browser);
  _connected = true;
  }
  private void HookExistingContexts(IBrowser browser)
  {
  // .NET 里没有 IBrowser.ContextCreated,所以只能:
  // 1) 先 hook 现有 contexts
  foreach (var ctx in browser.Contexts)
  {
  HookContext(ctx);
  }
  }
  private void HookContext(IBrowserContext ctx)
  {
  // hook 已有 page
  foreach (var page in ctx.Pages)
  {
  HookPage(page);
  }
  // hook 新打开的 page(例如 Selenium 新开 tab/window)
  ctx.Page += (_, page) => HookPage(page);
  }
  private void HookPage(IPage page)
  {
  // 避免重复 hook:同一个测试通常只关心当前页面的 WS
  if (_pageHooked) return;
  page.WebSocket += (sender, ws) =>
  {
  ws.FrameSent += (s2, frame) =>
  {
  AddWs("Sent", frame.Text);
  };
  ws.FrameReceived += (s3, frame) =>
  {
  AddWs("Received", frame.Text);
  };
  };
  _pageHooked = true;
  }
  private void AddWs(string type, string text)
  {
  var id = Interlocked.Increment(ref _seq);
  _messages.Add(new WsMessage
  {
  Seq = id,
  MessageType = type,
  Text = text
  });
  }
  public void Clear()
  {
  while (_messages.TryTake(out _)) { }
  Interlocked.Exchange(ref _seq, 0);
  }
  // 核心切片:只取某一步操作发生后的 WS
  private List<WsMessage> CaptureCore(Action uiAction, int waitMs)
    {
    var start = Interlocked.Read(ref _seq);
    uiAction();
    Thread.Sleep(waitMs);
    return _messages
    .Where(m => m.Seq > start)
    .OrderBy(m => m.Seq)
    .ToList();
    }
    public List<string> CaptureTexts(Action uiAction, int waitMs = 1000)
      => CaptureCore(uiAction, waitMs).Select(m => m.Text).ToList();
      public List<WsMessage> CaptureRaw(Action uiAction, int waitMs = 1000)
        => CaptureCore(uiAction, waitMs);
        // 方向过滤(可选:带是否包含空消息参数)
        public IEnumerable<string> OnlyReceived(IEnumerable<WsMessage> list, bool containEmpty = false)
          {
          var q = list.Where(m => m.MessageType == "Received");
          if (!containEmpty)
          {
          q = q.Where(m => !string.IsNullOrWhiteSpace(m.Text) && m.Text != "{}");
          }
          return q.Select(m => m.Text);
          }
          public IEnumerable<string> OnlySent(IEnumerable<WsMessage> list, bool containEmpty = false)
            {
            var q = list.Where(m => m.MessageType == "Sent");
            if (!containEmpty)
            {
            q = q.Where(m => !string.IsNullOrWhiteSpace(m.Text) && m.Text != "{}");
            }
            return q.Select(m => m.Text);
            }
            public void Dispose()
            {
            _browser?.CloseAsync().GetAwaiter().GetResult();
            _playwright?.Dispose();
            }
            }

5.5 如何在测试类里“正确初始化” Collector(时序写法)

这一段非常重要:端口必须先确定,TestBase 必须用这个端口启动 Chrome,Chrome 启动后 Collector 才能连接。

5.5.1 ClassInitialize:生成端口 + 初始化 Collector,但不连接
static PlaywrightWsCollector _collector;
[ClassInitialize]
public static void ClassInit(TestContext ctx)
{
int port = PlaywrightWsCollector.GenerateFreePort();
PlaywrightWsCollectorBridge.DebugPort = port; // 让 TestBase 注入该端口
_collector = new PlaywrightWsCollector(port);
}
5.5.2 TestInitialize:先 base,再 EnsureConnected
[TestInitialize]
public override void TestInitialize()
{
base.TestInitialize();        // 触发 Selenium 启动浏览器(带 remote-debugging-port)
_collector.EnsureConnected(); // 浏览器已启动,此时才连
_collector.Clear();           // 每个 case 清空,避免污染
}

这段顺序颠倒就会出现错误:

  • ECONNREFUSED:浏览器还没启动就连接
  • 或端口不一致:Selenium 用 A 端口,Playwright 连 B 端口

5.6 本节小结

到这一节为止,框架已经具备了完整的监听能力:

  • Selenium 按原方式执行 UI 操作;
  • Playwright 通过 CDP 附着到同一个浏览器;
  • CaptureTexts(() => 某一步操作) 只截取当前操作的 WS;
  • OnlySent/OnlyReceived 可进一步对应 DevTools 的上下箭头;

到这里,我们已经完成了本篇的核心目标:

  • 不改动既有 Selenium 框架 的前提下

  • 通过 remote-debugging-port + Playwright CDP 连接

  • 成功监听到了 Selenium 驱动页面产生的 WebSocket 消息

  • 并实现了:

    • 消息方向区分(Sent / Received)
    • 按“单一步操作”切片捕获
    • 为后续断言打下基础

这一步,解决了**“拿到 WebSocket”**的问题。


下一篇预告

《使用 Playwright 监听 Selenium 自动化测试中的 WebSocket(二)》

将重点介绍:

  • WebSocket 消息中 Action 的解析与统计
  • SelectionChange / EditRecord 次数断言的实现方式
  • PlaywrightWsCollector 在多用例场景下的复用设计
  • 常见坑点总结与工程化建议