Home 从源码理解 Selenium
Post
Cancel

从源码理解 Selenium

Web测试原理——第一站: Selenium

前言:Selenium 作为 Web 自动化测试中的老大哥,一直以来都有着不可撼动的地位。同时,其作为一个代码清晰,文档完善的开源软件,也值得我们仔细学习其源代码,理解它的工作原理,也提高我们自身的编程水平。

Web 自动测试的经典——Selenium

Selenium 是一个用于 Web 应用程序测试的工具。Selenium 测试直接运行在浏览器中,就像真正的用户在操作一样。支持的浏览器包括 IE,Firefox,Safari,Chrome,Opera,Edge等。这个工具的主要功能包括:测试与浏览器的兼容性,以及测试系统功能——创建回归测试检验软件功能和用户需求。

如果你让你的软件测试朋友推荐一款进行 Web 应用自动化测试的工具,他第一个想到的一定是 Selenium。Selenium 诞生于2004年,在很长一段时间内,Selenium 都是 Web 测试的第一选择。尽管现在已经有了更多的选择,Puppeteer , Playwright, Cypress 等新一代的 Web 测试工具开始展露锋芒,Selenium 仍有其可取之处。

作为这些工具中最经典的一款,Selenium 代码完全开源,你可以在他们的 GitHub 主页 下载源代码,对商业用户也没有任何限制,支持分布式,拥有成熟的社区与学习文档,非常值得 Web 测试的入门者专门花时间去学习,去理解其工作原理。并且,Selenium 支持所有主流的浏览器,并提供了对 Java,JavaScript,Python 等多种语言的支持,对于绝大部分测试场景 Selenium 都能从容应对。

通过编写模仿用户操作的 Selenium 测试脚本,你可以从终端用户的角度来测试 Web 应用。你只需要告诉 Selenium 需要完成哪些动作,例如打开某个网址,输入文本,点击按钮等等,Selenium 便会自动控制浏览器来完成这些相应的动作,并告诉你测试的结果。那么 Selenium 是如何控制我们的浏览器的呢?这篇文章就从逻辑框架出发,一直到源码细节,来慢慢铺开它的工作原理。

为方便查阅,文中所有提到的资源链接都会整理在文末。

这篇文章并不会讲述如何下载和使用 Selenium。国内网站上已经有诸多 Selenium 的使用教程可供参考,你也可以直接查阅 Selenium 用户手册 。本文中就不再赘述。

Selenium 整体架构

Selenium 的核心功能是由一个名为 WebDriver 的组件完成的。Selenium 能够与我们的浏览器进行沟通都要归功于这个 WebDriver,它本质上来说就是一套 API,其定义了一套能与浏览器进行沟通的协议(Protocol)。Selenium 基于 WebDriver 向外提供了多种语言的扩展(Language Bindings),从而你可以使用 Java,JavaScript,Python 等语言驱动 WebDriver。每个浏览器都提供了特定的 Driver (也被称为 Browser Driver)来响应这套协议。例如 Google Chrome 提供了 chromedriver;Mozilla Firefox 则提供了 geckodriver。下面这个图展示了 WebDriver,Driver 和浏览器(Browser)之间的关系。

从大体上来说,WebDriver 通过 Driver 与浏览器进行沟通。沟通的过程是双向的:WebDriver 通过协议发送命令给 Driver,Driver 将命令传递给浏览器执行相应的操作;操作完成后,浏览器会原路发送信息回到 WebDriver。Selenium 的核心工作,就是完成 WebDriver 与 Driver 之间的顺利沟通。Selenium 的使用者只需要使用 Selenium 提供的一套 API,定义他们需要浏览器完成的动作,Selenium 则负责将这些动作翻译为 Driver 能理解的内容,通过协议发送给 Driver,让 Driver 帮我们控制浏览器完成各种动作。

这套 WebDriver 和 Driver 之间的协议限定了我们能要求浏览器做哪些事情,不能做哪些事情。在上面的文字中,我们不停地提到了协议这个词,下面我们就来一起看看它们之间用的究竟是什么协议。

全新的 W3C 协议

在 Selenium 4 之前,Selenium 一直使用的协议名为 JSON Wire Protocol,这是由他们自己起草的协议。在 Selenium 的 GitHub wiki 页面可以找到这个协议的具体内容 JSON Wire Protocol

然而,自从在2019年4月发布的 Selenium 4 版本之后, 原有的 JSON Wire Protocol 将不再被支持,取而代之的是新的 WebDriver W3C Protocol,也被称为 W3C Wire Protocol ,以下简称 W3C 协议。这是由 W3C(World Wide Web Consortium)组织背书的协议,致力于成为 WebDriver 的行业标准。

W3C 协议目前还在修订过程中,你可以在 W3C 编辑草案 页面上追踪协议的最新进展。

使用新的 W3C 协议拥有诸多优势:

  • 首先,不同浏览器之间可以更一致地进行 Selenium 测试,因为它们都遵循了相同的 W3C 协议;
  • 使用新协议可以让 Selenium 更加稳定,减少 flakiness。这也是 Selenium 4 推出的主要原因;
  • 新协议提供了更丰富的命令 API,这意味着你可以使用 Selenium 进行更多的操作,例如放大页面、缩小页面、多指操作等等;
  • 新协议使得源代码更加清晰易读。

下面让我们一起来看看新协议的具体内容。W3C 协议定义了很多东西。首先,它定义了协议通信的两端—— local endremote end

  • Local end:表示协议的用户端(client side),Selenium 在这个协议中扮演的角色就是用户端。
  • Remote end:表示协议的服务器端(server side),browser driver 便处于服务器端的位置。

W3C 协议对 local end 的设计没有太多的限制,其大部分篇幅都在描述 remote end 的行为。Remote end 端需要建立一个 HTTP server,使得 local end 能够通过 HTTP 请求与 remote end 进行通信。Local end 与 remote end 之间的一条通信链路被称为一个 session。WebDriver 会给每个 session 赋予独立的 session ID,用于区分不同的 session。这样一来只需要一个 HTTP server 就能同时响应多个 session。

协议的通讯过程由一条一条的命令(command)组成。打开网页、点击按钮、查找元素这些浏览器操作,都是一条条命令。每一条命令会以 HTTP 请求的方式由 local end 发送给 remote end,等到 remote end 完成命令操作后,再将 HTTP 响应发回给 local end。协议定义了一系列命令对应的 HTTP 方法(method)和 URI 模板(template)。所有支持的命令都列举在 W3C Webdriver Endpoints 表格中,下面截取了表格的一部分。

可以看到,除了 New Session 和 Status 命令,其他命令都需要提供 session ID。

其实这是一种颇具 RESTful 风格的设计。如果你熟悉 RESTful API,就不会对 HTTP method、URI template 这些名词感到陌生

下面我们以 Node.js 下的 Selenium 为例,介绍 Selenium 的源码细节。

Node.js 下的 Selenium 模块

Node.js 是本地的 JavaScript 运行时环境,npm 是 Node.js 下的包管理器。可以在 Node.js 中文网 上下载它们。如果你还不熟悉 Node.js,可以查阅 Node.js 官方指南 学习。

首先,打开控制台,进入项目文件夹,使用 npm 安装 Selenium 模块(module)。

1
2
$> npm init --yes
$> npm i selenium-webdriver

安装完成后,你可以在 node_module 目录下找到 selenium-webdriver 文件夹。Selenium 所有的源代码都在这个文件夹内。笔者下载的版本为 4.0.0-beta.4。Selenium 的 JavaScript 版本只有不到2万行源代码,还是比较好读的。首先,我们来一起看看这个文件夹中主要有那些内容。

文件夹或文件名功能 
index.js面向用户的模块接口 
README.md模块功能说明 
package.json模块依赖配置文件 
chrome.js, firefox.js, edge.js, …控制各类浏览器的 Browser Driver 
example 文件夹提供 Selenium 使用样例 
lib 文件夹定义了 WebDriver 和基于它的一系列上层 API 
remote 文件夹控制 Browser Driver 服务 
http 文件夹控制 HTTP 通信链路 

上表只列举了比较重要的文件和文件夹,简要概括了它们的功能。对于所有文件内容的详细说明,可以查阅官方提供的 Selenium JS API 文档。

如果我们直接挨个文件一行一行地读代码,不异于按照页次顺序读字典,必然感觉味同嚼蜡,生涩难懂。因此,我们还是从一段简单的 Selenium 使用样例—— demo.js 入手,理解其每一句代码的工作原理。

demo.js 这段代码来自于 example 文件夹,为了方便读者,我对其作了些许简化。这段代码完成了一件很普通的事情——用 Firefox 浏览器打开百度。对于我们来说,这种事情点点鼠标就完成了,不要太简单。那如果使用 Selenium ,该怎么完成这个任务呢?

1
2
3
4
5
6
7
8
(demo.js)
const webdriver = require('selenium-webdriver');	// 导入模块

const driver = new webdriver.Builder()
  .forBrowser('firefox')
  .build();		// 初始化 WebDriver

driver.get('http://www.baidu.com');	//打开网页

demo.js 一共有三句代码。第一句使用 require关键字加载 selenium-webdriver 模块。第二句用 forBrowser 方法指定了浏览器类型 Firefox,然后用 build 方法创建了 WebDriver 实例。最后一句使用 WebDriverget 方法跳转到百度网站。总的来说,前两句都是在初始化 WebDriver,第三句开始才开始使用 WebDriver 模拟用户的动作。

还是很简单的嘛!使用 Selenium 操纵浏览器并没有想象中那样复杂。Selenium 为我们提供了丰富的 API,我们只需要会使用这些 API 就能轻松地实现我们想要的功能。不过这并不是一篇讲解 Selenium 如何使用的文章,因此我们不能停留于 API 的使用,我们还得往下继续探究这三句看似简单的代码,究竟在背后做了哪些工作。

初始化 WebDriver

为了方便阅读和理解,以下出现的源代码都经过精简和少量修改。

首先来看看初始化 WebDriver 时完成了哪些工作。从第一句开始。

1
2
(demo.js)
const webdriver = require('selenium-webdriver');

第一句使用 require 关键字加载 selenium-webdriver 模块,赋值给变量 webdriver。使用 require 时,Node.js 会在 node_modules 文件夹中寻找名为“selenium-webdriver”的文件夹,然后将该目录下的 index.js 文件中 export 的内容作为 Object 类型导入。因此,我们可以将模块目录下的 index.js 文件视为模块的入口。

在 index.js 文件末尾,你可以看到其可供调用的所有 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(index.js)
// PUBLIC API
exports.Browser = capabilities.Browser
exports.Builder = Builder
exports.Button = input.Button
exports.By = by.By
exports.withTagName = by.withTagName
exports.Capabilities = capabilities.Capabilities
exports.Capability = capabilities.Capability
exports.Condition = webdriver.Condition
exports.FileDetector = input.FileDetector
exports.Key = input.Key
exports.Origin = input.Origin
exports.Session = session.Session
exports.ThenableWebDriver = ThenableWebDriver
exports.WebDriver = webdriver.WebDriver
exports.WebElement = webdriver.WebElement
exports.WebElementCondition = webdriver.WebElementCondition
exports.WebElementPromise = webdriver.WebElementPromise
exports.error = error
exports.logging = logging
exports.promise = promise
exports.until = until

第二句才是真正意义上的 WebDriver 初始化。

1
2
(demo.js)
const driver = new webdriver.Builder().forBrowser('firefox').build();

这一句实际上做了三件事情——1. 实例化一个 Builder 类;2. 确定浏览器种类;3. 创建一个 WebDriver 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(index.js)
class Builder {
  forBrowser(name, opt_version, opt_platform) {
    this.capabilities_.setBrowserName(name)
    ...
    return this
  }
  
  build() {
    const capabilities = new Capabilities(this.capabilities_)
    let browser = capabilities.get(Capability.BROWSER_NAME)
	
    // Check for a native browser.
    switch (browser) {
      ...
      case Browser.FIREFOX: {
        return firefox.Driver.createSession(capabilities)
      }
      ...
    }
  }
}

Builder 类专门负责创建 WebDriver 实例,定义在 index.js 文件中。 WebDriver 类是 Selenium 的核心,定义在 lib\webdriver.js 文件中。 WebDriver 类中提供了各种与操纵浏览器的接口,例如模拟用户动作的接口 this.actions(options),寻找网页元素的接口this.findElement(locator),返回当前网页地址的接口 this.getCurrentUrl() 等等。模拟对浏览器的所有操作,全部都得通过WebDriver 类中提供的接口。因此,我们需要在程序一开始就实例化 WebDriver 类。

Builder 类实例化完成后,然后调用了 Builder.forBrowser 方法指定浏览器种类。设置浏览器时,使用到了 lib\capabilities.js 在文件中的 Capabilities 类。这个类描述了 WebDriver session 具有的能力(capability)。我们可以在该文件中找到所有可用的浏览器种类。

1
2
3
4
5
6
7
8
9
(lib\capabilities.js)
const Browser = {
  CHROME: 'chrome',
  EDGE: 'MicrosoftEdge',
  FIREFOX: 'firefox',
  INTERNET_EXPLORER: 'internet explorer',
  SAFARI: 'safari',
  OPERA: 'opera',
}

最后调用 Builder 类的 build 方法创建 WebDriver 实例。build 方法会根据浏览器类型,调用相应的方法创建 session。以 Firefox 为例,会调用 firefox.js 文件中的Driver.createSession(capabilities) 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
(firefox.js)
class Driver extends webdriver.WebDriver {
  static createSession(opt_config, opt_executor) {
    let caps = opt_config
    let exe_path = findGeckoDriver()
    let service = new remote.DriverService.Builder(exe_path).build()
    let executor = createExecutor(service.start())
    let onQuit = () => service.kill()
    }

    return (super.createSession(executor, caps, onQuit))
  } 
}

可以看到 Driver 类继承自 webdriver.WebDriver。Selenium 为每个浏览器都定义了各自的 Driver 类,它们都继承自 lib\webdriver.js 中的 WebDriver 类。Driver.createSession 方法负责建立 WebDriver 和 Browser Driver 之间的通信链路,即 session。建立 session 就需要先建立通信的两端——local end 和 remote end。

代码第5行,调用了 findGeckoDriver 方法在环境变量的路径中找到 GeckoDriver.exe(Firefox 的 Driver)。因此你需要将下载的 GeckoDriver 的路径提前加入到环境变量中。

代码第6行,使用 remote\index.js 中的 DriverService.Builder 类创建了 DriverService 类的实例 serviceDriverService 类负责管理 Browser Driver 的生命周期。DriverService.Builder.build 方法中会找到一个空闲的端口,之后就在这个端口上启动 HTTP 服务器。

代码中第7行的 service.start() 会开启 Browser Driver 服务(Service),该服务会一并启动一个 HTTP 服务器,作为 remote end 接收 local end 发出的 HTTP 请求。start 方法会返回 HTTP 服务器的地址,然后createExecutor 方法以服务器地址作为参数,启动 Command Executor,作为 local end,负责将命令(command)翻译为 HTTP 请求,发送给 remote end。

HTTP 服务器地址形如 http://127.0.0.1:58717/ 。其中 http://127.0.0.1 是回环地址(Loopback Address),58717 是端口号(Port)。

代码第11行调用父类,即 WebDriver 类的 createSession 方法,这个方法中 executor 给 service 发送了第一个 HTTP 请求,握手建立连接,并从 HTTP 响应(Response)中获取 session ID,保存在 WebDriver 类成员变量 WebDriver.session_中。

下面我们再来看看 createExecutor 方法的内容。

1
2
3
4
5
6
7
(firefox.js)
function createExecutor(serverUrl) {
  let client = serverUrl.then((url) => new http.HttpClient(url))
  let executor = new http.Executor(client)
  configureExecutor(executor)
  return executor
}

createExecutor 方法中,首先创建了 HTTP 客户端 client,然后实例化了 ExecutorExecutor 类负责将命令(command)翻译为 HTTP 请求,发送给 HTTP 服务器。

至此,WebDriver 的初始化过程就全部结束了。其实,最核心的部分就是 firefox.js 文件中的 Driver.createSession 方法。这个方法启动了 WebDriver 和 Browser Driver,并启动了它们之间的通信链路,确保我们之后可以通过 WebDriver 与浏览器进行正常通信。

打开网页过程

下面来看看跳转到网页的过程。跳转网页使用的是 WebDriver.get(url) 方法。

1
2
(demo.js)
driver.get('http://www.baidu.com');	

get 方法最终会调用 Navigation.to 方法,在此方法中,会通过实例化 Command 类生成一个 command。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
(lib\webdriver.js)
class WebDriver {
  get(url) {
    return this.navigate().to(url)
  }
  
  navigate() {
    return new Navigation(this)
  }
  
  async execute(command) {
    command.setParameter('sessionId', this.session_)
    let parameters = await toWireValue(command.getParameters())
    command.setParameters(parameters)
    let value = await this.executor_.execute(command)
    return fromWireValue(this, value)
  }
}

class Navigation {
  constructor(driver) {
    this.driver_ = driver
  }
  
  to(url) {
    return this.driver_.execute(
      new command.Command(command.Name.GET).setParameter('url', url)
    )
  }
}

Command 类是 WebDriver 命令的抽象,定义在 lib\command.js 文件中。Command 类仅有两个成员变量——nameparameters_name 指定请求的 HTTP 方法(method),例如 GET,POST,DELETE。parameters_ 存放其他参数信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
(lib\commmand.js)
class Command {
  /** @param {string} name The name of this command. */
  constructor(name) {
    this.name_ = name	    // string
    this.parameters_ = {}
  }
  
  setParameter(name, value) {
    this.parameters_[name] = value
    return this
  }
}

生成的 command 最后会传递给 Executor.execute 方法。Executor 类定义在 lib\http.js 文件中,是命令的执行者。Executor.execute 方法会将 command 包装在 HTTP 请求中,发送给 remote end 的 HTTP 服务器。Browser Driver 通过HTTP 请求收到命令后,就会开始打开网页的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
(lib\http.js)
class Executor {
  async execute(command) {
    let request = buildRequest(this.customCommands_, this.w3c, command)

    ...

    let httpResponse = await client.send(request)

    let { isW3C, value } = parseHttpResponse(command, httpResponse)
    return typeof value === 'undefined' ? null : value
  }
}

附资源链接

Selenium:

Browser Driver:

Wire Protocol:

Node.js:

This post is licensed under CC BY 4.0 by the author.

VScode 常用快捷键

Selenium Source Code Analysis