开源应用架构(卷 1)
Selenium WebDriver

Simon Stewart

Selenium 是一种浏览器自动化工具,通常用于编写 Web 应用程序的端到端测试。浏览器自动化工具的功能正如您所期望的那样:自动化浏览器的控制,以便可以自动化重复性任务。这听起来像是一个简单的要解决的问题,但正如我们将看到的,为了使其工作,幕后需要发生很多事情。

在描述 Selenium 的架构之前,了解项目中各个相关部分如何组合在一起会有所帮助。在非常高的层面上,Selenium 是一套包含三个工具的工具集。第一个工具 Selenium IDE 是 Firefox 的一个扩展,允许用户录制和回放测试。录制/回放模式可能存在限制,并且不适合许多用户,因此套件中的第二个工具 Selenium WebDriver 通过各种语言提供 API,以允许进行更多控制以及应用标准的软件开发实践。最后一个工具 Selenium Grid 使得能够使用 Selenium API 来控制分布在一个机器网格上的浏览器实例,从而允许更多测试并行运行。在项目中,它们分别被称为“IDE”、“WebDriver”和“Grid”。本章探讨 Selenium WebDriver 的架构。

本章是在 2010 年底 Selenium 2.0 测试版期间撰写的。如果您在之后阅读本书,那么事情将会向前发展,您将能够看到此处描述的架构选择是如何展开的。如果您在该日期之前阅读:恭喜!您拥有时光机。我能得到一些中奖彩票号码吗?

16.1. 历史

Jason Huggins 在 2004 年开始 Selenium 项目,当时他在 ThoughtWorks 工作,负责他们内部的 Time and Expenses (T&E) 系统,该系统大量使用了 Javascript。尽管 Internet Explorer 当时是占主导地位的浏览器,但 ThoughtWorks 使用了许多替代浏览器(特别是 Mozilla 变体),并在 T&E 应用程序无法在其选择的浏览器上运行时提交错误报告。当时的开源测试工具要么专注于单个浏览器(通常是 IE),要么是对浏览器的模拟(如 HttpUnit)。商业工具的许可证费用会耗尽小型内部项目的有限预算,因此甚至没有被视为可行的测试选择。

在自动化困难的情况下,通常依赖于手动测试。当团队非常小或发布频率极高时,这种方法无法扩展。要求人们逐步执行可以自动化的脚本也是对人类的浪费。更通俗地说,对于枯燥的重复性任务,人类比机器慢且更容易出错。手动测试不是一种选择。

幸运的是,所有正在测试的浏览器都支持 Javascript。这对 Jason 和他所在的团队来说很有意义,他们用这种语言编写了一个测试工具,可以用来验证应用程序的行为。受 FIT1 上正在进行的工作的启发,在原始 Javascript 上放置了一个基于表的语法,这允许编程经验有限的人使用 HTML 文件中基于关键字的方法编写测试。这个工具最初称为“Selenium”,后来被称为“Selenium Core”,于 2004 年在 Apache 2 许可证下发布。

Selenium 的表格格式与 FIT 中的 ActionFixture 类似。表格的每一行都分为三列。第一列给出要执行的命令的名称,第二列通常包含元素标识符,第三列包含可选值。例如,以下是如何将字符串“Selenium WebDriver”输入到用名称“q”标识的元素中

type       name=q       Selenium WebDriver

由于 Selenium 是用纯 Javascript 编写的,因此其初始设计要求开发人员将 Core 和他们的测试托管在与被测应用程序 (AUT) 相同的服务器上,以避免违反浏览器的安全策略和 Javascript 沙箱。这并不总是实用或可能的。更糟糕的是,尽管开发人员的 IDE 使他们能够快速操作代码并浏览大型代码库,但 HTML 没有这样的工具。很快变得很清楚,维护即使是中等规模的测试套件也是一项笨拙且痛苦的提议。2

为了解决这个问题和其他问题,编写了一个 HTTP 代理,以便 Selenium 可以拦截每个 HTTP 请求。使用此代理可以绕过“相同主机来源”策略的许多限制,在该策略中,浏览器不允许 Javascript 调用除当前页面已从中获取服务的服务器以外的任何其他服务器,从而可以缓解第一个弱点。该设计为用多种语言编写 Selenium 绑定打开了可能性:它们只需要能够向特定 URL 发送 HTTP 请求即可。线路格式紧密模仿了 Selenium Core 的基于表的语法,它以及基于表的语法被称为“Selenese”。由于语言绑定是从远处控制浏览器,因此该工具被称为“Selenium Remote Control”或“Selenium RC”。

在开发 Selenium 的同时,ThoughtWorks 正在酝酿另一个浏览器自动化框架:WebDriver。该框架的初始代码于 2007 年初发布。WebDriver 源于希望将其端到端测试与底层测试工具隔离的项目的开发工作。通常,执行此隔离的方式是通过适配器模式。WebDriver 从在众多项目中始终如一地应用此方法而获得的见解中发展而来,最初是 HtmlUnit 的一个包装器。Internet Explorer 和 Firefox 在发布后迅速获得了支持。

当 WebDriver 发布时,它与 Selenium RC 之间存在显着差异,尽管它们位于浏览器自动化 API 的相同软件领域。对用户来说最明显的区别是 Selenium RC 具有基于字典的 API,所有方法都公开在一个类上,而 WebDriver 具有更面向对象的 API。此外,WebDriver 仅支持 Java,而 Selenium RC 支持多种语言。在技术上也存在很大差异:Selenium Core(RC 基于此)本质上是一个 Javascript 应用程序,运行在浏览器的安全沙箱中。WebDriver 试图与浏览器进行本机绑定,以牺牲框架本身开发工作量大幅增加为代价,绕过了浏览器的安全模型。

2009 年 8 月,宣布这两个项目将合并,Selenium WebDriver 是这些合并项目的成果。在我写这篇文章的时候,WebDriver 支持 Java、C#、Python 和 Ruby 的语言绑定。它支持 Chrome、Firefox、Internet Explorer、Opera 以及 Android 和 iPhone 浏览器。有一些姊妹项目,虽然没有保存在同一个源代码存储库中,但与主项目密切合作,提供 Perl 绑定、BlackBerry 浏览器的实现以及“无头”WebKit——对于那些需要在没有适当显示器的持续集成服务器上运行测试的情况很有用。原始的 Selenium RC 机制仍然得到维护,并允许 WebDriver 为其他情况下不支持的浏览器提供支持。

16.2. 关于术语的题外话

不幸的是,Selenium 项目使用了大量术语。回顾我们已经遇到的内容

敏锐的读者会注意到“Selenium”以相当普遍的意义使用。幸运的是,上下文通常可以清楚地表明人们指的是哪个特定的 Selenium。

最后,还有一个我将要使用的短语,并且没有优雅的方式来介绍它:“driver”是给 WebDriver API 的特定实现的名称。例如,有一个 Firefox driver 和一个 Internet Explorer driver。

16.3. 架构主题

在我们开始查看各个部分以了解它们是如何连接在一起之前,了解项目架构和开发的总体主题很有用。简而言之,这些是

16.3.1. 降低成本

在 Y 个平台上支持 X 个浏览器本质上是一个昂贵的提议,无论是在初始开发方面还是在维护方面。如果我们能找到某种方法来保持产品质量高,又不违反太多其他原则,那么这就是我们喜欢的路线。这在我们尽可能采用 Javascript 的过程中最为明显,正如您很快将要了解的那样。

16.3.2. 模拟用户

WebDriver 旨在准确模拟用户与 Web 应用程序交互的方式。模拟用户输入的一种常见方法是利用 Javascript 来合成和触发应用程序在真实用户执行相同交互时会看到的事件序列。这种“合成事件”方法充满了困难,因为每个浏览器,有时甚至是同一浏览器的不同版本,会触发略微不同的事件,并且具有略微不同的值。更复杂的是,出于安全原因,大多数浏览器不允许用户以这种方式与表单元素(如文件输入元素)交互。

在可能的情况下,WebDriver 使用在操作系统级别触发事件的替代方法。由于这些“本机事件”不是由浏览器生成的,因此此方法规避了对合成事件施加的安全限制,并且由于它们是特定于操作系统的,因此一旦它们在一个平台上的一个浏览器中工作,在另一个浏览器中重用代码就相对容易了。遗憾的是,这种方法仅在 WebDriver 可以与浏览器紧密绑定并且开发团队已确定如何最好地发送本机事件而无需浏览器窗口获得焦点的情况下才有可能(因为 Selenium 测试需要很长时间才能运行,并且能够在运行时将机器用于其他任务很有用)。在撰写本文时,这意味着可以在 Linux 和 Windows 上使用本机事件,但不能在 Mac OS X 上使用。

无论 WebDriver 如何模拟用户输入,我们都努力尽可能地模仿用户行为。这与 RC 形成对比,后者提供的 API 在比用户工作级别低得多的级别上运行。

16.3.3. 证明驱动程序有效

这可能听起来有点理想化,像是“母爱与苹果派”一样,但我相信,如果代码无法工作,那么编写它就没有意义。我们在 Selenium 项目中验证驱动程序工作的方式是拥有大量自动化测试用例。这些通常是“集成测试”,需要编译代码并利用与 Web 服务器交互的浏览器,但在可能的情况下,我们会编写“单元测试”。与集成测试不同,单元测试无需完整重新编译即可运行。在撰写本文时,大约有 500 个集成测试和 250 个单元测试可以在每个浏览器上运行。随着我们修复问题和编写新代码,我们会添加更多测试,并且我们的重点正在转向编写更多单元测试。

并非每个测试都会在每个浏览器上运行。某些测试针对某些浏览器不支持或在不同浏览器上的处理方式不同的特定功能。例如,针对并非所有浏览器都支持的新 HTML5 功能的测试。尽管如此,每个主要的桌面浏览器都会运行针对其自身的相当一部分测试。可以理解,找到一种方法在多个平台上为每个浏览器运行 500 多个测试是一项重大挑战,这也是该项目持续努力解决的问题。

16.3.4. 你不需要了解所有内容的工作原理

很少有开发人员精通并舒适地使用我们使用的每种语言和技术。因此,我们的架构需要允许开发人员专注于他们能够发挥最大作用的天赋,而无需让他们处理他们不熟悉的代码部分。

16.3.5. 降低“巴士因素”

软件开发中有一个(并非完全严肃的)概念,称为“巴士因素”。它指的是需要遭遇某些不幸事件——可能是被巴士撞到——才能导致项目无法继续进行的关键开发人员的数量。像浏览器自动化这样复杂的事情尤其容易受到这种情况的影响,因此,我们的许多架构决策都是为了尽可能提高这个数字。

16.3.6. 对 JavaScript 实现表示同情

如果无法通过其他方式控制浏览器,WebDriver 会退回到使用纯 JavaScript 来驱动浏览器。这意味着我们添加的任何 API 都应该“同情”JavaScript 实现。举个具体的例子,HTML5 引入了 LocalStorage,这是一个用于在客户端存储结构化数据的 API。这通常使用 SQLite 在浏览器中实现。一种自然的实现方法是使用类似 JDBC 的东西,为底层数据存储提供数据库连接。最终,我们选择了一个与底层 JavaScript 实现紧密匹配的 API,因为模拟典型数据库访问 API 的东西并不“同情”JavaScript 实现。

16.3.7. 每个调用都是一个 RPC 调用

WebDriver 控制在其他进程中运行的浏览器。虽然很容易忽略这一点,但这意味着通过其 API 进行的每个调用都是一个 RPC 调用,因此框架的性能受网络延迟的影响。在正常操作中,这可能并不明显——大多数操作系统都优化了到本地主机的路由——但随着浏览器和测试代码之间的网络延迟增加,曾经看起来有效的操作对 API 设计人员和 API 用户来说都变得不那么有效了。

这在 API 的设计中引入了一些张力。一个更大的 API,使用更粗粒度的函数,将有助于通过合并多个调用来减少延迟,但这必须与保持 API 表达性和易用性相平衡。例如,需要进行一些检查以确定元素是否对最终用户可见。我们不仅需要考虑各种 CSS 属性(可能需要通过查看父元素来推断),而且还应该检查元素的尺寸。一个极简的 API 需要单独进行每个检查。WebDriver 将所有这些检查合并到一个 isDisplayed 方法中。

16.3.8. 最后的想法:这是开源的

虽然这不是严格的架构要点,但 Selenium 是一个开源项目。将以上所有要点联系在一起的主题是,我们希望使新开发人员尽可能轻松地做出贡献。通过尽可能降低所需的知识深度,使用尽可能少的语言,并依靠自动化测试来验证没有任何内容被破坏,我们希望能够实现这种易于贡献性。

最初,该项目被拆分为一系列模块,每个模块代表一个特定的浏览器,并有额外的模块用于通用代码以及支持和实用程序代码。每个绑定的源代码树都存储在这些模块下。这种方法对于 Java 和 C# 等语言来说很有意义,但对于 Ruby 和 Python 开发者来说却很难使用。这几乎直接转化为相对的贡献者数量,只有少数人能够并且有兴趣处理 Python 和 Ruby 绑定。为了解决这个问题,在 2010 年 10 月和 11 月,源代码进行了重组,Ruby 和 Python 代码存储在每个语言的单个顶级目录下。这更符合这些语言的开源开发者的期望,社区贡献的影响几乎立即显而易见。

16.4. 处理复杂性

软件是一个凹凸不平的结构。这些凸起是复杂性,作为 API 的设计者,我们可以在哪里推动这种复杂性方面做出选择。在一个极端情况下,我们可以尽可能均匀地分布复杂性,这意味着每个 API 的使用者都需要参与其中。另一个极端建议尽可能多地承担复杂性并在一个地方隔离它。如果他们不得不冒险进入那个地方,那个地方将成为许多人恐惧和恐怖的地方,但权衡是,不需要深入实现的 API 用户,他们已经预先支付了复杂性的成本。

WebDriver 开发人员更倾向于在少数几个地方找到并隔离复杂性,而不是将其分散开来。这样做的原因之一是我们的用户。他们非常擅长发现问题和错误,从我们的错误列表中可以看出这一点,但由于他们中的许多人不是开发人员,因此复杂的 API 不会很好用。我们力求提供一个引导人们走向正确方向的 API。例如,考虑以下来自原始 Selenium API 的方法,每个方法都可以用于设置输入元素的值

以下是 WebDriver API 中的等效方法

如前所述,这突出了 RC 和 WebDriver 之间的主要哲学差异之一,即 WebDriver 努力模拟用户,而 RC 提供的 API 则处理用户难以或无法访问的更低级别。typeKeystypeKeysNative 之间的区别在于,前者始终使用合成事件,而后者则尝试使用 AWT Robot 来键入按键。令人失望的是,AWT Robot 将按键发送到任何具有焦点的窗口,这可能不一定就是浏览器。相比之下,WebDriver 的本机事件直接发送到窗口句柄,避免了浏览器窗口必须具有焦点的要求。

16.4.1. WebDriver 设计

团队将 WebDriver 的 API 称为“基于对象”的。接口定义明确,并尝试坚持只具有单一角色或责任,但我们并没有将每个可能的 HTML 标签都建模成自己的类,而只有一个 WebElement 接口。通过遵循这种方法,使用支持自动完成功能的 IDE 的开发人员可以被引导到下一步。结果是编码会话可能如下所示(在 Java 中)

WebDriver driver = new FirefoxDriver();
driver.<user hits space>

此时,会出现一个相对较短的包含 13 个方法的列表供选择。用户选择其中一个

driver.findElement(<user hits space>)

大多数 IDE 现在将提示预期参数的类型,在本例中为“By”。在 By 本身声明为静态方法的一些预配置的“By”对象工厂方法。我们的用户很快就会得到一行看起来像这样的代码

driver.findElement(By.id("some_id"));

基于角色的接口

考虑一个简化的 Shop 类。每天,它都需要补充库存,并且它与 Stockist 合作来交付这些新库存。每月,它都需要支付员工工资和税款。为了便于说明,让我们假设它使用 Accountant 来执行此操作。一种建模方式如下所示

public interface Shop {
    void addStock(StockItem item, int quantity);
    Money getSalesTotal(Date startDate, Date endDate);
}

在定义 Shop、Accountant 和 Stockist 之间的接口时,我们有两个选择来确定边界。我们可以画一条理论线,如 图 16.1 所示。

这意味着 AccountantStockist 都将接受 Shop 作为其各自方法的参数。但是,这里的缺点是,Accountant 真的不太可能想整理货架,并且 Stockist 也可能不太应该意识到 Shop 加上的巨大价格差。因此,一个更好的边界绘制位置如 图 16.2 所示。

我们需要 Shop 需要实现的两个接口,但这些接口清楚地定义了 Shop 为 Accountant 和 Stockist 履行的角色。它们是基于角色的接口

public interface HasBalance {
    Money getSalesTotal(Date startDate, Date endDate);
}

public interface Stockable {
    void addStock(StockItem item, int quantity);
}

public interface Shop extends HasBalance, Stockable {
}

我发现 UnsupportedOperationExceptions 及其同类非常令人不快,但需要一些东西允许为可能需要它的用户子集公开功能,而不会因为大多数用户的 API 而变得杂乱。为此,WebDriver 广泛使用了基于角色的接口。例如,有一个 JavascriptExecutor 接口,它提供在当前页面上下文中执行任意 JavaScript 代码块的能力。将 WebDriver 实例成功转换为该接口表示您可以预期其上的方法可以工作。

[Accountant and Stockist Depend on Shop]

图 16.1:Accountant 和 Stockist 依赖于 Shop

[Shop Implements HasBalance and Stockable]

图 16.2:Shop 实现 HasBalance 和 Stockable

16.4.2. 处理组合爆炸

从对 WebDriver 支持的广泛浏览器和语言进行片刻思考后,最先显而易见的事情之一是,除非小心处理,否则它将很快面临不断升级的维护成本。对于 X 个浏览器和 Y 种语言,很容易陷入维护 X×Y 个实现的陷阱。

减少 WebDriver 支持的语言数量将是降低此成本的一种方法,但我们不想走这条路,原因有两个。首先,从一种语言切换到另一种语言需要付出认知成本,因此,对于框架的用户来说,能够使用他们进行大部分开发工作的相同语言编写测试是有利的。其次,在一个项目上混合使用多种语言是团队可能不习惯的事情,并且公司编码标准和要求通常似乎要求技术单一文化(尽管,令人欣慰的是,我认为第二点随着时间的推移正变得不那么正确),因此减少支持的语言数量不是一个可行的选择。

减少支持的浏览器数量也不是一种选择——当我们在 WebDriver 中逐步停止对 Firefox 2 的支持时,曾出现过激烈的争论,尽管我们在做出这个选择时,它在浏览器市场中的份额还不到 1%。

我们剩下的唯一选择是尝试让所有浏览器在语言绑定方面看起来都一样:它们应该提供一个统一的接口,以便能够轻松地在各种语言中进行访问。更重要的是,我们希望语言绑定本身尽可能地易于编写,这意味着我们希望它们尽可能地精简。为了支持这一点,我们将尽可能多的逻辑推入底层驱动程序:我们未能推入驱动程序的每个功能都需要在我们支持的每种语言中实现,这可能需要大量的额外工作。

例如,IE 驱动程序已成功地将查找和启动 IE 的责任推给了主驱动程序逻辑。尽管这导致驱动程序中出现了令人惊讶数量的代码行,但创建新实例的语言绑定却简化为对该驱动程序进行一次方法调用。相比之下,Firefox 驱动程序未能进行此更改。仅在 Java 世界中,这意味着我们有三个主要类负责配置和启动 Firefox,这些类的代码量约为 1300 行。这些类在每个希望支持 FirefoxDriver 而又不依赖于启动 Java 服务器的语言绑定中都进行了复制。这需要维护大量额外的代码。

16.4.3. WebDriver 设计中的缺陷

以这种方式公开功能的决定的缺点是,在有人知道某个特定接口存在之前,他们可能没有意识到 WebDriver 支持那种类型的功能;API 的可探索性有所降低。当然,当 WebDriver 还是新生事物时,我们似乎花费了大量时间只是将人们引导到特定的接口。我们现在在文档方面投入了更多精力,并且随着 API 被更广泛地使用,用户更容易找到他们需要的信息。

我认为我们的 API 在一个地方特别糟糕。我们有一个名为 RenderedWebElement 的接口,它包含各种用于查询元素渲染状态(isDisplayedgetSizegetLocation)、对其执行操作(hover 和拖放方法)以及获取特定 CSS 属性值(一个方便的方法)的方法。它的创建是因为 HtmlUnit 驱动程序没有公开所需的信息,但 Firefox 和 IE 驱动程序公开了。它最初只包含第一组方法,但我们在认真思考如何让 API 演变之前添加了其他方法。该接口现在广为人知,艰难的选择是:鉴于它被广泛使用,我们是否保留这个 API 中不美观的部分,或者是否尝试删除它。我更倾向于不留下“破窗”,因此在发布 Selenium 2.0 之前修复这一点非常重要。因此,当您阅读本章时,RenderedWebElement 可能已经消失了。

从实现者的角度来看,紧密绑定到浏览器也是一个设计缺陷,尽管这是一个不可避免的缺陷。支持新浏览器需要付出巨大的努力,而且通常需要进行多次尝试才能使其正常工作。举一个具体的例子,Chrome 驱动程序已经经历了四次完整的重写,IE 驱动程序也经历了三次主要的重写。紧密绑定到浏览器的优势在于它提供了更多控制权。

16.5. 层和 Javascript

浏览器自动化工具本质上由三个移动部件组成

本节重点介绍第一部分:提供一种查询 DOM 的机制。浏览器的通用语言是 Javascript,这似乎是在查询 DOM 时使用的理想语言。虽然这个选择看起来很明显,但它带来了一些有趣的挑战和相互竞争的需求,在考虑 Javascript 时需要权衡这些需求。

像大多数大型项目一样,Selenium 使用了一套分层的库。最底层是 Google 的 Closure 库,它提供了基本元素和模块化机制,允许源文件保持专注并尽可能小。在此之上,有一个实用程序库,提供从简单的任务(如获取属性值)到确定元素是否对最终用户可见,再到更复杂的操作(如使用合成事件模拟点击)等各种功能。在项目中,这些被视为提供最小的浏览器自动化单元,因此被称为浏览器自动化原子或原子。最后,有一些适配器层,将原子组合起来以满足 WebDriver 和 Core 的 API 合约。

[Layers of Selenium Javascript Library]

图 16.3:Selenium Javascript 库的层

选择 Closure 库的原因有很多。主要原因是 Closure 编译器理解库使用的模块化技术。Closure 编译器是一个以 Javascript 作为输出语言的编译器。“编译”可以像按依赖顺序排列输入文件、连接和美化它们一样简单,也可以像进行高级缩小和死代码删除一样复杂。另一个不可否认的优势是,参与 Javascript 代码工作的团队成员中有几位非常熟悉 Closure 库。

当需要查询 DOM 时,这个“原子”代码库在整个项目中被广泛使用。对于 RC 和那些主要由 Javascript 组成的驱动程序,该库被直接使用,通常编译成一个整体脚本。对于用 Java 编写的驱动程序,来自 WebDriver 适配器层的各个函数都使用启用的完全优化进行编译,生成的 Javascript 作为 JAR 中的资源包含在内。对于用 C 变体(如 iPhone 和 IE 驱动程序)编写的驱动程序,不仅各个函数使用完全优化进行编译,而且生成的输出被转换为在头文件中定义的常量,并根据需要通过驱动程序的正常 Javascript 执行机制执行。虽然这看起来像是件奇怪的事情,但它允许 Javascript 被推入底层驱动程序,而无需在多个地方公开原始源代码。

由于原子被广泛使用,因此可以确保不同浏览器之间的一致行为,并且由于库是用 Javascript 编写的,并且不需要提升的权限来执行开发周期,因此简单快捷。Closure 库可以动态加载依赖项,因此 Selenium 开发人员只需编写一个测试并在浏览器中加载它,根据需要修改代码并点击刷新按钮即可。一旦测试在一个浏览器中通过,就可以很容易地在另一个浏览器中加载它并确认它在那里也通过。由于 Closure 库在抽象浏览器之间的差异方面做得很好,这通常就足够了,尽管令人欣慰的是,有一些持续构建会针对每个受支持的浏览器运行测试套件。

最初,Core 和 WebDriver 在许多方面存在重叠的代码——这些代码以略微不同的方式执行相同的功能。当我们开始研究原子时,对这些代码进行了梳理,以尝试找到“最佳”功能。毕竟,这两个项目都被广泛使用,它们的代码非常健壮,因此丢弃所有内容并从头开始不仅浪费,而且愚蠢。提取每个原子后,就会识别出它将被使用的站点并切换到使用该原子。例如,Firefox 驱动程序的 getAttribute 方法从大约 50 行代码缩减到 6 行,包括空行

FirefoxDriver.prototype.getElementAttribute =
  function(respond, parameters) {
  var element = Utils.getElementAt(parameters.id,
                                   respond.session.getDocument());
  var attributeName = parameters.name;

  respond.value = webdriver.element.getAttribute(element, attributeName);
  respond.send();
};

倒数第二行,其中 respond.value 被赋值,使用了原子 WebDriver 库。

原子是项目中几个架构主题的实际演示。当然,它们强制执行 API 的实现需要与 Javascript 实现保持一致的要求。更棒的是,相同的库在整个代码库中共享;以前,错误必须在多个实现中进行验证和修复,现在只需在一个地方修复错误即可,这降低了更改成本,同时提高了稳定性和有效性。原子还使项目的巴士因素更有利。由于可以使用普通的 Javascript 单元测试来检查修复程序是否有效,因此加入开源项目的障碍比以前低得多,以前需要了解每个驱动程序是如何实现的。

使用原子还有另一个好处。一个模拟现有 RC 实现但由 WebDriver 支持的层是团队希望以受控方式迁移到较新的 WebDriver API 的重要工具。随着 Selenium Core 被原子化,可以单独编译其中的每个函数,从而使编写此模拟层变得更容易实现且更准确。

不用说,这种方法也存在缺点。最重要的是,将 Javascript 编译成 C const 是一件非常奇怪的事情,它总是让想要处理 C 代码的新贡献者感到困惑。而且,很少有开发人员拥有每个浏览器的每个版本,并且足够专注于在所有这些浏览器中运行每个测试——有人可能无意中在意外的地方导致回归,并且可能需要一些时间才能识别出问题,特别是如果持续构建不稳定的话。

由于原子规范化了浏览器之间的返回值,因此也可能出现意外的返回值。例如,考虑以下 HTML

<input name="example" checked>

checked 属性的值将取决于使用的浏览器。原子将此属性和其他在 HTML5 规范中定义的布尔属性规范化为“true”或“false”。当这个原子被引入代码库时,我们发现很多地方都在根据浏览器做出关于返回值应该是什么的依赖性假设。虽然值现在是一致的,但有一段时间我们向社区解释了发生了什么以及原因。

16.6. 远程驱动程序,特别是 Firefox 驱动程序

远程 WebDriver 最初是一种经过美化的 RPC 机制。它后来发展成为我们用来降低维护 WebDriver 成本的关键机制之一,因为它提供了一个统一的接口,语言绑定可以针对它进行编码。即使我们已经尽可能地将逻辑从语言绑定中推送到驱动程序中,如果每个驱动程序都需要通过唯一的协议进行通信,我们仍然需要在所有语言绑定中重复大量的代码。

无论何时需要与在进程外运行的浏览器实例通信,都会使用远程 WebDriver 协议。设计此协议意味着需要考虑许多问题。其中大部分是技术性的,但由于它是开源的,因此还需要考虑社会方面。

任何 RPC 机制都分为两部分:传输和编码。我们知道,无论我们如何实现远程 WebDriver 协议,我们都需要在我们要用作客户端的语言中支持这两部分。设计的第一个迭代是在 Firefox 驱动程序中开发的。

Mozilla 及其 Firefox 一直被其开发者视为一个多平台应用程序。为了促进开发,Mozilla 创建了一个受 Microsoft 的 COM 启发的框架,允许构建和组合组件,称为 XPCOM(跨平台 COM)。XPCOM 接口使用 IDL 声明,并且有 C 和 Javascript 以及其他语言的语言绑定。由于 XPCOM 用于构建 Firefox,并且 XPCOM 具有 Javascript 绑定,因此可以在 Firefox 扩展中使用 XPCOM 对象。

普通的 Win32 COM 允许远程访问接口。也计划将相同的功能添加到 XPCOM 中,Darin Fisher 添加了一个 XPCOM ServerSocket 实现来促进这一点。尽管 D-XPCOM 的计划从未实现,但就像阑尾一样,残余的基础设施仍然存在。我们利用这一点在一个包含控制 Firefox 所有逻辑的自定义 Firefox 扩展中创建了一个非常基本的服务器。最初使用的协议是基于文本和面向行的,将所有字符串编码为 UTF-2。每个请求或响应都以一个数字开头,指示在确定请求或回复已发送之前需要计算多少个换行符。至关重要的是,这种方案易于在 Javascript 中实现,因为 SeaMonkey(当时 Firefox 的 Javascript 引擎)在内部将 Javascript 字符串存储为 16 位无符号整数。

虽然在原始套接字上使用自定义编码协议是一种消磨时间的好方法,但它有几个缺点。自定义协议没有广泛可用的库,因此需要为我们想要支持的每种语言从头开始实现。这种实现更多代码的要求将降低慷慨的开源贡献者参与新语言绑定开发的可能性。此外,虽然当我们只在周围发送基于文本的数据时,面向行的协议是可以的,但当我们想要发送图像(例如屏幕截图)时,它带来了问题。

很快就变得非常明显,这种最初的 RPC 机制不切实际。幸运的是,有一种广为人知的传输方式在几乎所有语言中都得到了广泛的采用和支持,它可以让我们做到我们想要做的事情:HTTP。

一旦我们决定使用 HTTP 作为传输机制,接下来需要做出的选择是使用单个端点(类似于 SOAP)还是多个端点(类似于 REST)。最初的 Selenese 协议使用单个端点,并在查询字符串中编码命令和参数。虽然这种方法效果很好,但感觉“不对劲”:我们设想能够连接到浏览器中的远程 WebDriver 实例以查看服务器的状态。我们最终选择了一种我们称之为“类 REST”的方法:使用 HTTP 动词帮助提供含义的多个端点 URL,但打破了真正 RESTful 系统所需的许多约束,特别是在状态和可缓存性位置方面,主要是因为应用程序状态只有一个有意义存在的位置。

尽管 HTTP 使基于内容类型协商轻松支持多种数据编码方式,但我们决定我们需要所有远程 WebDriver 协议实现都可以使用的规范形式。有一些显而易见的选择:HTML、XML 或 JSON。我们很快排除了 XML:虽然它是一种非常合理的数据格式,并且几乎每种语言都支持它的库,但我对它在开源社区中的受欢迎程度的看法是人们不喜欢使用它。此外,完全有可能,尽管返回的数据将共享一个共同的“形状”,但很容易添加其他字段3。虽然可以使用 XML 命名空间对这些扩展进行建模,但这将开始在客户端代码中引入更多复杂性:这是我渴望避免的。XML 被舍弃作为一种选择。HTML 并不是一个很好的选择,因为我们需要能够定义我们自己的数据格式,尽管可以设计和使用嵌入式微格式,但这似乎是用锤子敲鸡蛋。

最后考虑的可能性是 Javascript 对象表示法 (JSON)。浏览器可以使用直接调用 eval 或在更新的浏览器上使用旨在安全地将 Javascript 对象转换为字符串并从字符串转换回 Javascript 对象的基元来将字符串转换为对象。从实用角度来看,JSON 是一种流行的数据格式,几乎每种语言都提供用于处理它的库,并且所有酷孩子都喜欢它。一个简单的选择。

因此,远程 WebDriver 协议的第二个迭代使用 HTTP 作为传输机制,并使用 UTF-8 编码的 JSON 作为默认编码方案。选择 UTF-8 作为默认编码,以便可以轻松地用 Unicode 支持有限的语言编写客户端,因为 UTF-8 与 ASCII 向后兼容。发送到服务器的命令使用 URL 来确定正在发送哪个命令,并在数组中编码命令的参数。

例如,调用 WebDriver.get("http://www.example.com") 映射到对编码会话 ID 并以“/url”结尾的 URL 的 POST 请求,参数数组如下所示:{[}'http://www.example.com'{]}。返回的结果结构更清晰,并为返回值和错误代码提供了占位符。不久之后,远程协议的第三次迭代,它用命名参数的字典替换了请求的参数数组。这样做的好处是使调试请求变得更加容易,并消除了客户端错误地错误排序参数的可能性,使整个系统更加健壮。当然,决定使用正常的 HTTP 错误代码来指示某些返回值和响应,因为它们是最合适的方式;例如,如果用户尝试调用没有映射到它的 URL,或者当我们想要指示“空响应”时。

远程 WebDriver 协议有两个级别的错误处理,一个用于无效请求,一个用于失败的命令。无效请求的一个示例是服务器上不存在的资源,或者可能是资源不理解的动词(例如,向用于处理当前页面 URL 的资源发送 DELETE 命令)。在这些情况下,会发送正常的 HTTP 4xx 响应。对于失败的命令,响应错误代码设置为 500(“内部服务器错误”),并且返回的数据包含对错误原因的更详细说明。

当从服务器发送包含数据的响应时,它采用 JSON 对象的形式

描述
sessionId 服务器用来确定将特定于会话的命令路由到何处的不明确句柄。
状态 总结命令结果的数字状态代码。非零值表示命令失败。
响应 JSON 值。

一个示例响应将是

{
  sessionId: 'BD204170-1A52-49C2-A6F8-872D127E7AE8',
  status: 7,
  value: 'Unable to locate element with id: foo'
}

可以看出,我们在响应中编码状态代码,非零值表示某些事情发生了严重错误。IE 驱动程序是第一个使用状态代码的驱动程序,线协议中使用的值反映了这些值。因为所有驱动程序之间的所有错误代码都是一致的,所以可以在用特定语言编写的所有驱动程序之间共享错误处理代码,从而使客户端实现者的工作更容易。

远程 WebDriver 服务器只是一个充当多路复用器的 Java servlet,它将接收到的任何命令路由到相应的 WebDriver 实例。这是二年级研究生可以编写的那种东西。Firefox 驱动程序也实现了远程 WebDriver 协议,其架构更加有趣,所以让我们跟踪从语言绑定中的调用到后端,直到它返回到用户。

假设我们使用的是 Java,并且“element”是 WebElement 的一个实例,它从这里开始

element.getAttribute("row");

在内部,元素有一个服务器端用来识别我们正在谈论哪个元素的不透明“id”。为了讨论起见,我们将假设它的值为“some_opaque_id”。这被编码到一个 Java Command 对象中,该对象使用一个 Map 保存(现在已命名)的参数 id 用于元素 ID,name 用于要查询的属性的名称。

在表中快速查找表明正确的 URL 是

/session/:sessionId/element/:id/attribute/:name

URL 中以冒号开头的任何部分都被假定为需要替换的变量。我们已经获得了 idname 参数,sessionId 是另一个不明确句柄,当服务器可以一次处理多个会话时(Firefox 驱动程序不能),它用于路由。因此,此 URL 通常扩展为类似以下内容:

http://localhost:7055/hub/session/XXX/element/some_opaque_id/attribute/row

顺便说一句,WebDriver 的远程线协议最初是在 URL 模板作为 RFC 草案提出时开发的。我们指定 URL 的方案和 URL 模板都允许在 URL 内扩展(因此派生)变量。遗憾的是,尽管 URL 模板是在同一时间提出的,但我们直到相对较晚才意识到它们,因此它们不用于描述线协议。

因为我们正在执行的方法是幂等的4,所以要使用的正确 HTTP 方法是 GET。我们委托给可以处理 HTTP 的 Java 库(Apache HTTP Client)来调用服务器。

[Overview of the Firefox Driver Architecture]

图 16.4:Firefox 驱动程序架构概述

Firefox 驱动程序作为 Firefox 扩展实现,其基本设计如图 16.4所示。有点不寻常的是,它有一个嵌入式 HTTP 服务器。虽然最初我们使用自己构建的服务器,但在 XPCOM 中编写 HTTP 服务器并不是我们的核心竞争力,因此当机会出现时,我们用 Mozilla 自己编写的基本 HTTPD 替换了它。HTTPD 接收请求,并几乎立即传递给 dispatcher 对象。

调度程序获取请求并迭代已知支持的 URL 列表,尝试找到与请求匹配的 URL。此匹配是在了解客户端进行的变量插值的情况下完成的。一旦找到完全匹配项,包括正在使用的动词,就会构造一个表示要执行的命令的 JSON 对象。在我们的例子中,它看起来像

{
  'name': 'getElementAttribute',
  'sessionId': { 'value': 'XXX' },
  'parameters': {
    'id': 'some_opaque_key',
    'name': 'rows'
  }
}

然后将其作为 JSON 字符串传递给我们编写的名为 CommandProcessor 的自定义 XPCOM 组件。这是代码

var jsonResponseString = JSON.stringify(json);
var callback = function(jsonResponseString) {
  var jsonResponse = JSON.parse(jsonResponseString);

  if (jsonResponse.status != ErrorCode.SUCCESS) {
    response.setStatus(Response.INTERNAL_ERROR);
  }

  response.setContentType('application/json');
  response.setBody(jsonResponseString);
  response.commit();
};

// Dispatch the command.
Components.classes['@googlecode.com/webdriver/command-processor;1'].
    getService(Components.interfaces.nsICommandProcessor).
    execute(jsonString, callback);

这里有很多代码,但有两个关键点。首先,我们将上面的对象转换为 JSON 字符串。其次,我们将回调传递给 execute 方法,该方法导致发送 HTTP 响应。

命令处理器的 execute 方法查找“name”以确定要调用哪个函数,然后执行该函数。给此实现函数传递的第一个参数是“respond”对象(之所以这样称呼是因为它最初只是用于将响应发送回用户的函数),它不仅封装了可能发送的可能值,而且还具有允许将响应调度回用户并查找有关 DOM 信息的机制的方法。第二个参数是上面看到的 parameters 对象的值(在本例中为 idname)。这种方案的优点是每个函数都具有统一的接口,该接口反映了客户端使用的结构。这意味着用于思考双方代码的心理模型是相似的。以下是 getAttribute 的底层实现,您之前在第 16.5 节中见过

FirefoxDriver.prototype.getElementAttribute = function(respond, parameters) {
  var element = Utils.getElementAt(parameters.id,
                                  respond.session.getDocument());
  var attributeName = parameters.name;

  respond.value = webdriver.element.getAttribute(element, attributeName);
  respond.send();
};

为了使元素引用保持一致,第一行只是在缓存中查找由不透明 ID 引用的元素。在 Firefox 驱动程序中,该不透明 ID 是一个 UUID,而“缓存”只是一个映射。getElementAt 方法还会检查引用的元素是否已知并附加到 DOM。如果任一检查失败,则(如果必要)从缓存中删除 ID,并抛出异常并将其返回给用户。

倒数第二行使用了前面讨论过的浏览器自动化原子,这次将其编译成一个单片脚本并作为扩展的一部分加载。

在最后一行,调用了 send 方法。这会进行简单的检查以确保我们只发送一次响应,然后再调用传递给 execute 方法的回调。响应以 JSON 字符串的形式发送回用户,然后将其分解成一个类似于以下的对象(假设 getAttribute 返回“7”,表示未找到元素)

{
  'value': '7',
  'status': 0,
  'sessionId': 'XXX'
}

然后,Java 客户端检查 status 字段的值。如果该值为非零,则将数字状态代码转换为正确类型的异常并抛出该异常,使用“value”字段帮助设置发送给用户的消息。如果状态为零,则将“value”字段的值返回给用户。

大部分内容都有一定的道理,但有一个地方敏锐的读者会提出疑问:为什么调度程序在调用 execute 方法之前将其拥有的对象转换为字符串?

这样做的原因是 Firefox 驱动程序还支持运行用纯 Javascript 编写的测试。通常,这将是一件非常难以支持的事情:测试在浏览器的 Javascript 安全沙箱的上下文中运行,因此可能无法执行测试中一些有用的操作,例如跨域或上传文件。但是,WebDriver Firefox 扩展提供了一种从沙箱中逃脱的方法。它通过向文档元素添加 webdriver 属性来宣布其存在。WebDriver Javascript API 使用此作为指示符,表明它可以将 JSON 序列化命令对象作为文档元素上 command 属性的值,触发自定义 webdriverCommand 事件,然后侦听同一元素上的 webdriverResponse 事件以获知 response 属性已设置。

这表明在安装了 WebDriver 扩展的 Firefox 副本中浏览网页是一个非常糟糕的主意,因为它使某人远程控制浏览器变得非常容易。

在后台,有一个 DOM 信使,等待 webdriverCommand,它读取序列化的 JSON 对象并在命令处理器上调用 execute 方法。这次,回调是一个简单地设置文档元素上的 response 属性,然后触发预期的 webdriverResponse 事件的回调。

16.7. IE 驱动程序

Internet Explorer 是一个有趣的浏览器。它由许多协同工作的 COM 接口构成。这扩展到 Javascript 引擎,其中熟悉的 Javascript 变量实际上引用底层的 COM 实例。该 Javascript window 是一个 IHTMLWindowdocument 是 COM 接口 IHTMLDocument 的一个实例。微软在增强浏览器时,在维护现有行为方面做得很出色。这意味着,如果某个应用程序使用 IE6 公开的 COM 类,它仍然可以继续与 IE9 一起使用。

Internet Explorer 驱动程序的架构随着时间的推移而发展。其设计的一个主要驱动力是需要避免安装程序。这是一个有点不寻常的要求,因此可能需要一些解释。不需要安装程序的第一个原因是,它使 WebDriver 更难通过“5 分钟测试”,其中开发人员下载一个包并在短时间内试用它。更重要的是,WebDriver 用户通常无法在自己的机器上安装软件。这也意味着当项目想要开始使用 IE 进行测试时,没有人需要记住登录到持续集成服务器来运行安装程序。最后,运行安装程序根本不属于某些语言的文化。常见的 Java 习惯用法是将 JAR 文件简单地放到 CLASSPATH 上,而且,根据我的经验,那些需要安装程序的库往往不太受欢迎或使用。

所以,没有安装程序。这种选择会带来一些后果。

在 Windows 上进行编程的自然语言应该是 .Net 上运行的某种语言,可能是 C#。IE 驱动程序通过利用每个 Windows 版本都附带的 IE COM 自动化接口与 IE 紧密集成。特别是,我们使用来自本地 MSHTML 和 ShDocVw DLL 的 COM 接口,这些接口构成 IE 的一部分。在 C# 4 之前,CLR/COM 互操作性是通过使用单独的主互操作程序集 (PIA) 来实现的。PIA 本质上是在 CLR 的托管世界和 COM 世界之间生成的桥梁。

遗憾的是,使用 C# 4 将意味着使用非常现代版本的 .Net 运行时,许多公司避免处于领先地位,而是偏爱旧版本的稳定性和已知问题。通过使用 C# 4,我们将自动排除相当一部分用户群。使用 PIA 还有其他缺点。考虑许可限制。在与 Microsoft 协商后,很明显 Selenium 项目无权分发 MSHTML 或 ShDocVw 库的 PIA。即使获得了这些权利,Windows 和 IE 的每次安装都具有这些库的独特组合,这意味着我们需要交付大量此类内容。根据需要在客户端机器上构建 PIA 也不是一个可行的方案,因为它们需要开发人员工具,而这些工具可能不存在于普通用户的机器上。

因此,尽管 C# 对于完成大部分编码工作来说是一种有吸引力的语言,但它并不是一种选择。我们需要使用某种本地语言,至少用于与 IE 通信。接下来自然的选择是 C++,这就是我们最终选择的语言。使用 C++ 的优点是我们不需要使用 PIA,但它确实意味着我们需要重新分发 Visual Studio C++ 运行时 DLL,除非我们静态链接到它们。由于我们需要运行安装程序才能使该 DLL 可用,因此我们静态链接我们用于与 IE 通信的库。

对于不需要使用安装程序的要求来说,这是一个相当高的代价。但是,回到复杂性应该存在于何处的主题,这项投资是值得的,因为它使我们用户的日常生活变得更加轻松。这是一个我们持续重新评估的决定,因为对用户的益处与能够为高级 C++ 开源项目做出贡献的人员池明显小于能够为等效的 C# 项目做出贡献的人员池的事实之间存在权衡。

IE 驱动程序的初始设计如图 16.5 所示。

[Original IE Driver]

图 16.5:原始 IE 驱动程序

从该堆栈的底部开始,您可以看到我们正在使用 IE 的 COM 自动化接口。为了使这些接口在概念层面上更容易处理,我们将这些原始接口包装在一组与主要 WebDriver API 紧密匹配的 C++ 类中。为了使 Java 类与 C++ 通信,我们利用了 JNI,其中 JNI 方法的实现使用了 COM 接口的 C++ 抽象。

这种方法在 Java 是唯一客户端语言时运行得相当好,但如果我们支持的每种语言都需要我们更改底层库,那么它将成为痛苦和复杂性的来源。因此,尽管 JNI 有效,但它没有提供正确的抽象级别。

正确的抽象级别是什么?我们想要支持的每种语言都有一个机制可以调用到纯 C 代码。在 C# 中,这采用 PInvoke 的形式。在 Ruby 中有 FFI,Python 有 ctypes。在 Java 世界中,有一个名为 JNA(Java Native Architecture)的优秀库。我们需要使用这个最低公分母公开我们的 API。这是通过获取我们的对象模型并将其扁平化来完成的,使用简单的前缀(两个或三个字母)来指示方法的“主接口”:“wd”表示“WebDriver”,“wde”表示 WebDriver 元素。因此,WebDriver.get 变成了 wdGetWebElement.getText 变成了 wdeGetText。每个方法都返回一个表示状态代码的整数,“out”参数用于允许函数返回更有意义的数据。因此,我们最终得到的方法签名如下

int wdeGetAttribute(WebDriver*, WebElement*, const wchar_t*, StringWrapper**)

对于调用代码,WebDriverWebElementStringWrapper 是不透明类型:我们在 API 中表达了差异,以明确应使用什么值作为该参数,尽管也可以简单地使用“void *”。您还可以看到我们正在使用宽字符表示文本,因为我们希望正确处理国际化文本。

在 Java 端,我们通过一个接口公开了这个函数库,然后我们对其进行调整,使其看起来像 WebDriver 提供的普通面向对象接口。例如,getAttribute 方法的 Java 定义如下

public String getAttribute(String name) {
  PointerByReference wrapper = new PointerByReference();
  int result = lib.wdeGetAttribute(
      parent.getDriverPointer(), element, new WString(name), wrapper);

  errors.verifyErrorCode(result, "get attribute of");

  return wrapper.getValue() == null ? null : new StringWrapper(lib, wrapper).toString();
}

这导致了图 16.6 所示的设计。

[Modified IE Driver]

图 16.6:修改后的 IE 驱动程序

当所有测试都在本地机器上运行时,这运行良好,但当我们开始在远程 WebDriver 中使用 IE 驱动程序时,我们开始遇到随机的锁定问题。我们将此问题追溯到 IE COM 自动化接口的一个限制。它们设计为在“单线程单元”模型中使用。从本质上讲,这归结为一个要求,即我们每次都从同一线程调用接口。在本地运行时,这默认情况下会发生。但是,Java 应用程序服务器会启动多个线程来处理预期的负载。最终结果?我们无法确定在所有情况下是否都会使用相同的线程来访问 IE 驱动程序。

解决此问题的一种方法是在单线程执行程序中运行 IE 驱动程序,并在应用程序服务器中通过 Futures 序列化所有访问,并且有一段时间我们选择了这种设计。但是,将这种复杂性推到调用代码似乎不公平,而且很容易想象在某些情况下人们意外地从多个线程使用 IE 驱动程序。我们决定将复杂性下沉到驱动程序本身。我们通过在单独的线程中保存 IE 实例并使用 PostThreadMessage Win32 API 在线程边界之间进行通信来做到这一点。因此,在撰写本文时,IE 驱动程序的设计如图 16.7 所示。

[IE Driver as of Selenium 2.0 alpha 7]

图 16.7:截至 Selenium 2.0 alpha 7 的 IE 驱动程序

这不是我自愿选择的设计类型,但它具有工作并经受我们用户可能选择施加给它的可怕考验的优势。

这种设计的一个缺点是,很难确定 IE 实例是否已完全锁定。如果我们在与 DOM 交互时弹出了模式对话框,或者如果线程边界另一侧发生灾难性故障,都可能发生这种情况。因此,我们为发布的每个线程消息都设置了一个超时时间,并将其设置为我们认为相对宽松的 2 分钟。从邮件列表中用户反馈来看,虽然这个假设通常是正确的,但并不总是正确,并且更高版本的 IE 驱动程序可能会使超时时间可配置。

另一个缺点是调试内部机制可能非常麻烦,需要结合速度(毕竟,您只有两分钟的时间来尽可能地跟踪代码)、明智地使用断点以及了解跨线程边界将遵循的预期代码路径。不用说,在一个拥有许多其他有趣问题需要解决的开源项目中,人们对这种繁琐的工作几乎没有兴趣。这大大降低了系统的巴士因子,作为一个项目维护者,这让我感到担忧。

为了解决这个问题,越来越多的 IE 驱动程序被迁移到与 Firefox 驱动程序和 Selenium Core 使用相同的自动化原子。我们通过编译计划使用的每个原子并将其准备为 C++ 头文件来实现这一点,并将每个函数公开为常量。在运行时,我们根据这些常量准备要执行的 Javascript。这种方法意味着我们可以开发和测试 IE 驱动程序的相当一部分代码,而无需使用 C 编译器,从而允许更多的人参与查找和解决 bug。最终,目标是只将交互 API 保留在原生代码中,并尽可能地依赖原子。

我们正在探索的另一种方法是重写 IE 驱动程序以使用轻量级 HTTP 服务器,从而允许我们将其视为远程 WebDriver。如果这样做,我们可以消除线程边界引入的许多复杂性,减少所需的代码总量,并使控制流程更容易跟踪。

16.8. Selenium RC

并非总是能够紧密绑定到特定的浏览器。在这些情况下,WebDriver 会回退到 Selenium 使用的原始机制。这意味着使用 Selenium Core,一个纯 Javascript 框架,由于它坚定地在 Javascript 沙箱的上下文中执行,因此引入了一些缺点。对于 WebDriver API 的用户来说,这意味着受支持浏览器的列表分为多个层级,有些浏览器与之紧密集成并提供卓越的控制,而其他浏览器则通过 Javascript 驱动,并提供与原始 Selenium RC 相同级别的控制。

从概念上讲,使用的设计非常简单,如图 16.8所示。

[Outline of Selenium RC's Architecture]

图 16.8:Selenium RC 架构概述

如您所见,这里有三个活动部件:客户端代码、中间服务器以及在浏览器中运行的 Selenium Core 的 Javascript 代码。客户端只是一个 HTTP 客户端,它将命令序列化到服务器端部分。与远程 WebDriver 不同,这里只有一个端点,并且使用的 HTTP 动词在很大程度上无关紧要。这部分是因为 Selenium RC 协议源自 Selenium Core 提供的基于表格的 API,这意味着整个 API 可以使用三个 URL 查询参数来描述。

当客户端启动一个新会话时,Selenium 服务器会查找请求的“浏览器字符串”以识别匹配的浏览器启动器。启动器负责配置和启动请求的浏览器实例。对于 Firefox 来说,这就像扩展一个预构建的配置文件,其中预安装了一些扩展程序(一个用于处理“退出”命令,另一个用于模拟“document.readyState”,它在我们仍然支持的旧版 Firefox 版本中不存在)。完成的关键配置是服务器将自己配置为浏览器的代理,这意味着至少某些请求(针对“/selenium-server”的请求)会通过它路由。Selenium RC 可以以三种模式之一运行:控制单个窗口中的一个框架(“singlewindow”模式)、在单独的窗口中控制第二个窗口中的 AUT(“multiwindow”模式)或通过代理注入自身到页面中(“proxyinjection”模式)。根据操作模式,所有请求都可能被代理。

配置浏览器后,它将启动,初始 URL 指向 Selenium 服务器上托管的一个页面——RemoteRunner.html。此页面负责通过加载 Selenium Core 的所有必需 Javascript 文件来引导进程。完成后,将调用“runSeleniumTest”函数。它使用 Selenium 对象的反射来初始化在启动主命令处理循环之前可用的可用命令列表。

在浏览器中执行的 Javascript 向等待服务器上的 URL(/selenium-server/driver)打开一个 XMLHttpRequest,依赖于服务器代理所有请求的事实,以确保请求实际上到达某个有效位置。在发出请求之前,它首先发送先前执行的命令的响应,或者在浏览器刚刚启动的情况下发送“OK”。然后,服务器保持请求打开,直到从用户的测试通过客户端接收到新命令,然后将其作为对等待的 Javascript 的响应发送。这种机制最初被称为“响应/请求”,但现在更有可能被称为“带有 AJAX 长轮询的 Comet”。

为什么 RC 以这种方式工作?服务器需要配置为代理,以便它可以拦截对它发出的任何请求,而不会导致调用的 Javascript 违反“单主机来源”策略,该策略规定只能通过 Javascript 请求来自与脚本提供相同服务器的资源。这是作为一项安全措施而实施的,但从浏览器自动化框架开发人员的角度来看,这非常令人沮丧,并且需要这样的技巧。

进行 XmlHttpRequest 调用到服务器的原因有两个。首先,也是最重要的是,在 HTML5 的一部分 WebSockets 在大多数浏览器中可用之前,没有办法在浏览器内可靠地启动服务器进程。这意味着服务器必须存在于其他地方。其次,XMLHttpRequest 异步调用响应回调,这意味着当我们等待下一个命令时,浏览器的正常执行不受影响。其他两种等待下一个命令的方法是定期轮询服务器以查看是否有另一个命令要执行,这会给用户的测试引入延迟,或者将 Javascript 放入繁忙循环中,这会将 CPU 使用率推到顶峰,并会阻止其他 Javascript 在浏览器中执行(因为在单个窗口的上下文中只有一个 Javascript 线程在执行)。

在 Selenium Core 中,有两个主要的活动部件。一个是主要的 selenium 对象,它充当所有可用命令的主机,并镜像提供给用户的 API。第二个部件是 browserbot。Selenium 对象使用它来抽象每个浏览器中存在的差异,并呈现常用浏览器功能的理想化视图。这意味着 selenium 中的函数更清晰、更容易维护,而 browserbot 则高度集中。

Core 越来越多的被转换为使用自动化原子。seleniumbrowserbot 可能都需要保留,因为有大量的代码依赖于使用它公开的 API,但预计它们最终将成为 shell 类,尽可能快地委托给原子。

16.9. 回顾

构建浏览器自动化框架就像粉刷房间一样;乍一看,它看起来像是非常容易做的事情。只需要几层油漆,工作就完成了。问题是,你越接近,就会出现越多的任务和细节,任务也会变得越长。对于一个房间来说,就是处理灯具、散热器和踢脚线等事情开始占用时间。对于浏览器自动化框架来说,是浏览器之间各种各样的怪癖和不同的功能使情况变得更加复杂。Daniel Wagner-Hall 在我旁边工作于 Chrome 驱动程序时表达了这种情况的极端情况;他用手敲着桌子,沮丧地嘟囔着:“都是边缘情况!”如果能够回到过去,告诉自己这一点,并告诉自己这个项目将比我预期的花费更长的时间,那该多好啊。

我禁不住想知道,如果我们比现在更早地识别并采取行动满足对像自动化原子这样的层的需求,该项目会是什么样子。它肯定会让项目面临的一些内部和外部、技术和社会方面的挑战更容易应对。Core 和 RC 是用一组集中的语言实现的——基本上只是 Javascript 和 Java。Jason Huggins 过去常常将此称为为 Selenium 提供一定程度的“可破解性”,这使得人们很容易参与到项目中。只有通过原子,这种可破解性才在 WebDriver 中得到了广泛的应用。与此相对应的是,原子之所以能够得到如此广泛的应用,是因为 Closure 编译器,我们几乎在它作为开源发布后就采用了它。

反思我们做对的事情也很有趣。从用户的角度编写框架的决定是我仍然认为是正确的决定。最初,这获得了回报,因为早期采用者指出了需要改进的方面,从而使该工具的实用性迅速提高。后来,随着 WebDriver 被要求做更多更难的事情,以及使用它的开发人员数量的增加,这意味着新 API 的添加都非常谨慎和细致,使项目的重点保持紧密。鉴于我们试图做的事情的范围,这种重点至关重要。

紧密绑定到浏览器是正确和错误的。它是正确的,因为它使我们能够以极高的保真度模拟用户,并非常出色地控制浏览器。它是错误的,因为这种方法在技术上极具挑战性,尤其是在找到进入浏览器的必要挂钩点时。IE 驱动程序的持续发展就是这种情况的体现,尽管这里没有涉及,但 Chrome 驱动程序也是如此,它有着悠久而传奇的历史。在某个时刻,我们需要找到一种方法来处理这种复杂性。

16.10. 展望未来

WebDriver 始终无法紧密集成到某些浏览器中,因此始终需要 Selenium Core。将其从当前的传统设计迁移到基于与原子使用相同 Closure 库的更模块化设计的工作正在进行中。我们还期望将原子更深入地嵌入到现有的 WebDriver 实现中。

WebDriver 的最初目标之一是充当其他 API 和工具的构建块。当然,Selenium 并不存在于真空中:还有很多其他开源浏览器自动化工具。其中之一是 Watir(Web Application Testing In Ruby),并且 Selenium 和 Watir 开发人员已经开始共同努力,将 Watir API 放在 WebDriver 核心之上。我们也渴望与其他项目合作,因为成功地驱动所有现有的浏览器是一项艰巨的工作。最好有一个坚实的内核,其他人可以以此为基础进行构建。我们希望这个内核是 WebDriver。

Opera Software 为我们提供了一瞥未来的景象,他们独立实现了 WebDriver API,并使用 WebDriver 测试套件来验证其代码的行为,并且他们将发布他们自己的 OperaDriver。Selenium 团队的成员也正在与 Chromium 团队的成员合作,为该浏览器添加更好的挂钩和对 WebDriver 的支持,并由此扩展到 Chrome。我们与 Mozilla 拥有良好的关系,他们为 FirefoxDriver 贡献了代码,也与流行的 HtmlUnit Java 浏览器模拟器的开发者保持合作。

对未来的另一种看法是,这种趋势将持续下去,自动化挂钩将在许多不同的浏览器中以统一的方式公开。对于热衷于编写 Web 应用程序测试的人来说,优势是显而易见的,浏览器制造商的优势也同样明显。例如,鉴于手动测试的相对成本,许多大型项目都严重依赖自动化测试。如果无法使用特定浏览器进行测试,或者即使“仅仅”是极其费力的,那么测试就不会针对该浏览器运行,从而对复杂应用程序与该浏览器配合使用的效果产生连锁影响。这些自动化挂钩是否将基于 WebDriver 还是一个开放性问题,但我们可以抱有希望!

未来几年将会非常有趣。由于我们是一个开源项目,欢迎您加入我们,共同踏上这段旅程,访问 http://selenium.googlecode.com/

脚注

  1. http://fit.c2.com
  2. 这与 FIT 非常相似,该项目协调员之一 James Shore 在 http://jamesshore.com/Blog/The-Problems-With-Acceptance-Testing.html 中解释了一些缺点。
  3. 例如,远程服务器会将每个异常的屏幕截图以 base64 编码的形式返回作为调试辅助,但 Firefox 驱动程序不会。
  4. 即,始终返回相同的结果。