利用 Visual Studio 2013 进行跨浏览器、编码 UI 测试

利用 Visual Studio 2013 进行跨浏览器、编码 UI 测试

在过去几年中,基于 Web 的解决方案为全世界用户提供便利的访问,因而非常受欢迎。用户喜欢它们的另一个原因是它们的方便性。用户无需安装单独的应用程序;仅凭浏览器就能从任何连接 Internet 的设备连接自己的帐户。但是,从软件开发者和测试者的角度看,用户可以选择任何 Web 浏览器会带来这样一个问题:解决方案必须经过多种浏览器的测试。本文将介绍如何通过简单的方法解决这个问题,即只使用 C# 创建任何新型浏览器都可以执行的编码 UI 测试用例。

新 Visual Studio

几年前,当 Visual Studio 2010 发布时,它最有趣的功能之一是能够测试基于 Web 的解决方案的 UI。但是,当时这种技术的使用有一定限制;例如,唯一支持的 Web 浏览器是 Internet Explorer。再者,UI 测试依赖于记录 Web 网站上的用户操作,然后重现这些操作来模拟实际用户操作,这是许多开发者无法接受的。

Visual Studio 2013 候选发布版本 (RC) 在许多不同方面进行诸多改进,从新 IDE 功能到扩展测试框架(bit.ly/1bBryTZ 提供了 RC 版本的详细变化列表)。从我的角度来看,有两个新功能特别有趣。第一个,现在不仅可以测试 Internet Explorer(包括 Internet Explorer 11)的 UI,还可以测试所有其他新型浏览器,例如 Google Chrome 和 Mozilla Firefox。第二个,从测试开发的角度来看甚至更为重要,就是 Microsoft 所称的“可配置浏览器编码 UI 测试属性”。从根本上看,这个新功能定义了一组 UI 元素的搜索条件。本文后面将详细介绍这些功能。

被测系统

我将使用这两个新功能来创建跨浏览器、完全编码的 UI 测试。对于我的待测试系统 (SUT),我需要一个公开的、大家熟知的、基于 Web 的应用程序,因此我选择了 Facebook。我准备介绍两个基本用户方案。第一个方案是正测试用例,成功登录后将显示个人资料页面。第二个方案是负测试用例,我输入无效的用户凭据来尝试登录。此时,我希望用户响应中显示某种错误消息。

我需要解决几项挑战。首先,需要启动正确的浏览器(根据测试配置),而且它必须能够提供对特定 URL 的访问。其次,在运行期间,必须从 HTML 文档中提取特定的控件元素,以便为模拟的用户提供输入。必要时,必须输入控件元素的值,并单击正确的按钮向服务器提交 HTML 表单。代码还应能够处理服务器的响应,验证响应,并在测试用例完成时最终关闭浏览器(利用测试的清理方法)。

编码前

开始编码前,我需要准备环境,这非常简单。首先,我需要从 bit.ly/137Sg3U 下载 Visual Studio 2013 RC。默认情况下,可通过 Visual Studio 2013 RC 只针对 Internet Explorer 创建编码的 UI 测试,但这不是我感兴趣的;我要针对所有新型浏览器创建测试。当然,只要我在代码中指定用 Internet Explorer 以外的浏览器运行测试,将不会发生编译错误,但是在运行时会引发未处理的异常。之后我还将演示如何更改浏览器。为避免编码过程出现问题,我需要下载并安装一个名为“编码 UI 跨浏览器测试 Selenium 组件”(bit.ly/Sd7Pgw) 的 Visual Studio 扩展,通过它我可以对安装在计算机中的任何浏览器执行测试。

代码探讨

一切就绪后,即可演示如何创建新的编码 UI 项目。打开 Visual Studio 2013,单击“文件”|“新建项目”|“模板” |“Visual C#”|“测试”|“编码的 UI 测试项目”。输入项目名称,按“确定”查看新解决方案,如图 1 所示。

利用 Visual Studio 2013 进行跨浏览器、编码 UI 测试
图 1 新建一个编码的 UI 测试项目

该解决方案基本上可分为三个紧密相连的组,如图 2 所示。第一组包含一个 Pages 命名空间,其中包括 BasePage 类,该类又派生出 ProfilePage 和 LoginPage。这些类公开在浏览器当前显示页面中使用的属性和逻辑。这种方式可帮助测试用例实现与浏览器特定的操作(如按 Id 搜索控件)分离开。测试用例直接作用于页面类公开的属性和函数。

利用 Visual Studio 2013 进行跨浏览器、编码 UI 测试
图 2 编码的 UI 测试解决方案示意图

我在第二组中放入了所有扩展 (UIControlExtensions)、选择器 (SearchSelector) 和与浏览器有关的公共类(BrowserFactory、Browser)。此对象子集存储 HTML 元素搜索引擎的实现逻辑(稍后介绍)。我还添加了我自己的与浏览器相关的对象,帮助使用正确的 Web 浏览器运行测试用例。最后一组包含测试用例实现的测试文件 (FacebookUITests)。这些测试用例不会直接在浏览器上运行;而是使用面板类运行。

项目的一个重要部分是 HTML 控件搜索引擎,因此,第一步是创建一个名为 UIControl­Extensions 的静态类,该类包含从当前打开的 Web 浏览器页面中查找和提取特定控件的实现逻辑。为便于编码以及以后在项目中重用编码,我不想在每次需要使用该类时都初始化它的实例,因此,我准备将它实现为扩展方法,用来扩展内置的 UITestControl 类型。此外,我实现的任何扩展函数都将是泛型函数。它们必须从 HtmlControl(编码的 UI 测试框架中所有 UI 控件的基类)派生,必须包含一个默认的无参数构造函数。我之所以需要此函数是泛型函数,是因为我准备搜索特定控件类型(请参阅 bit.ly/1aiB5eW 提供的可用 HTML 类型列表)。

我将通过 SearchSelector 类型的 selectorDefinition 参数将搜索条件传递给我的函数。SearchSelector 类是简单类型,但是它仍然非常有用。它公开很多属性(如 ID 或 Class),这些属性可从其他函数设置,之后可利用反射转换为 PropertyExpressionCollection 类 (bit.ly/18lvmnd)。接下来,可使用该属性集合作为筛选器只提取与给定条件匹配的 HTML 控件的小子集。之后,生成的属性集合分配给泛型对象的 SearchProperties 属性 (bit.ly/16C20iS),使其可以调用 Exists 属性和 FindMatchingControls 函数。请注意,默认情况下,编码的 UI 测试框架算法不搜索整个页面中的特定控件,只处理扩展 UITestControl 上的所有子控件及其子控件。这有助于提高测试用例的性能,因为搜索条件只应用于小的 HTML 文档子集上;例如,应用于某个 DIV 容器的所有子控件,如图 3 所示。

图 3:搜索 HTML 控件

          private static ReadOnlyCollection<T>    
  FindAll<T>(this UITestControl control,    
  SearchSelector selectorDefinition) where T : HtmlControl, new()   
{   
  var result = new List<T>();   
    T selectorElement = new T { Container = control };   
      selectorElement.SearchProperties.AddRange(   
        selectorDefinition.ToPropertyCollection());   
    if (!selectorElement.Exists)   
      {   
        Trace.WriteLine(string.Format(   
          "Html {0} element doesn't exist for given selector {1}.",   
             typeof(T).Name, selectorDefinition),"UI CodedTest");   
          return result.AsReadOnly();   
      }   
    return selectorElement   
        .FindMatchingControls()   
        .Select(c => (T)c).ToList().AsReadOnly();   
      }   
}

我已经实现了搜索控制引擎的核心部分,但是函数 FindAll<T> 需要很多代码和知识才能正常运行,您需要指定搜索参数,检查某个项是否存在等。为此我决定不将其公开,而是公开另外两个函数:

          public static T FindById<T>(this UITestControl control,    
  string controlId) where T : HtmlControl, new()   
public static T FindFirstByCssClass<T>(this UITestControl control,    
  string className, bool contains = true) where T : HtmlControl, new()

这些泛型方法“目的单一”,减少了对简单类型预期输入的维度变化,因此用处更大。在内部,两个函数都调用 FindAll<T> 函数并根据其结果运行,但是实现隐藏在它们内部。

使用任何浏览器

我在查找和检索控件上已经做了些工作,但是要测试我的函数实现是否正确,需要使 Web 浏览器正常运行,这意味着需要启动浏览器。启动特定浏览器与任何其他的浏览器相关操作一样容易。如前所述,我需要将所有与浏览器相关的操作放在与页面相关的类中。但是,启动浏览器不是测试的一部分,而是前提条件。受到软件开发最佳实践的启发,我决定创建一个 BasePage 类(如图 4 所示),来存储所有派生页类的公共操作(包括启动浏览器),且不存在任何冗余。

图 4 BasePage 类

          public abstract class BasePage : UITestControl   
{   
  protected const string BaseURL = "https://www.facebook.com/";   
  /// <summary>   
  /// Gets URL address of the current page.   
          /// </summary>   
  public Uri PageUrl{get; protected set;}   
  /// <summary>   
  /// Store the root control for the page.   
          /// </summary>   
  protected UITestControl Body;   
  /// <summary>   
  /// Gets current browser window.   
          /// </summary>   
  protected BrowserWindow BrowserWindow { get; set; }   
  /// <summary>   
  /// Default constructor.   
          /// </summary>   
  public BasePage()   
  {   
    this.ConstructUrl();   
  }   
  /// <summary>   
  /// Builds derived page URL based on the BaseURL and specific page URL.   
          /// </summary>   
  /// <returns>A specific URL for the page.</returns>   
  protected abstract Uri ConstructUrl();   
  /// <summary>   
  /// Verifies derived page is displayed correctly.   
          /// </summary>   
  /// <returns>True if validation conditions passed.</returns>   
  public abstract bool IsValidPageDisplayed();   
}

静态、泛型 Launch<T> 函数也是 Base­Page 类的一部分。在函数体内,根据无参数默认构造函数对特定页面类型(从 BasePage 派生)的新实例进行初始化。然后在代码中,根据浏览器参数的值(“ie”表示 Internet Explorer,“chrome”表示 Google Chrome 等)设置目标 Web 浏览器。此赋值指定当前测试执行时使用的浏览器。下一步,在选择的浏览器中导航到某个 URL。这是由 BrowserWindow.Launch(page.ConstructUrl()) 处理的,其中 ConstructUrl 函数是每个派生页面的特定函数。启动浏览器窗口并导航到特定 URL 后,我将 HTML 体存储在 BasePage 属性内,并使浏览器窗口最大化(可选)(这是因为页面控件可能重叠,导致 UI 自动执行操作失败)。然后,我清除了 Cookie,因为每次测试应该是独立进行的。最后,在图 5 所示的 Launch 函数中,检查当前显示的页面是否正确,于是我调用 IsValidPageDisplayed,它将在泛型页面的上下文中执行。此函数查找所有需要的 HTML 控件(登录、密码、提交按钮),确认它们存在于页面上。

图 5 Launch 函数

          public static T Launch<T>(   
  Browser browser = Browser.IE,   
  bool clearCookies = true,   
  bool maximized = true)   
  where T : BasePage, new()   
{   
  T page = new T();   
  var url = page.PageUrl;   
  if (url == null)   
  {   
    throw new InvalidOperationException("Unable to find URL for requested page.");   
  }   
  var pathToBrowserExe = FacebookCodedUITestProject   
        .BrowserFactory.GetBrowserExePath(browser);   
  // Setting the currect browser for the test.   
          BrowserWindow.CurrentBrowser = GetBrowser(browser);   
  var window = BrowserWindow.Launch(page.ConstructUrl());   
  page.BrowserWindow = window;   
  if (window == null)   
  {   
    var errorMessage = string.Format(   
        "Unable to run browser under the path: {0}", pathToBrowserExe);   
          throw new InvalidOperationException(errorMessage);   
            }   
            page.Body = (window.CurrentDocumentWindow.GetChildren()[0] as
                          UITestControl) as HtmlControl;   
  if (clearCookies)   
  {   
  BrowserWindow.ClearCookies();   
  }   
  window.Maximized = maximized;   
  if (!page.IsValidPageDisplayed())   
  {   
    throw new InvalidOperationException("Invalid page is displayed.");   
  }   
  return page;   
}   
 }

Web 浏览器是不断演变的,发生演变之时,您可能并不会注意到。有时,这意味着新浏览器版本中没有某些功能,导致某些测试失败,即便以前已经通过这些测试。为此,请务必禁用自动浏览器更新,等待编码的 UI 跨浏览器测试 Selenium 组件支持新版本。否则,在运行时可能发生意外的异常,如图 6 所示。

利用 Visual Studio 2013 进行跨浏览器、编码 UI 测试
图 6 Web 浏览器更新后发生的异常

测试、测试、再测试

最后,我为测试编写一些逻辑。如前所述,我需要测试两个基本用户方案。第一个是正登录进程(项目源代码提供了第二个负测试用例,可在 archive.msdn.microsoft.com/mag201312Testing 找到)。要使该测试能够运行,我必须创建一个特定页面类,该类从 BasePage 派生,如图 7 所示。在新类中,我在私有字段中放入了所有常量值(控件、Id 和 CSS 类名称),创建了利用这些常量从当前页面提取特定 UI 元素的专用方法。我还创建了一个名为 TypeCredentialAndClickLogin(字符串登录、字符串密码)的函数,用来完全封装登录操作。在运行时,它查找所有所需控件,模拟传递为参数的值的键入,然后通过单击鼠标左键按“登录”按钮。

图 7 登录页面

        public class LoginPage : BasePage   
{   
  private const string LoginButtonId = "u_0_1";   
  private const string LoginTextBoxId = "email";   
  private const string PasswordTextBoxId = "pass";   
  private const string LoginFormId = "loginform";   
  private const string ErrorMessageDivClass = "login_error_box";   
  private const string Page = "login.php";   
  /// <summary>   
  /// Builds URL for the page.   
        /// </summary>   
  /// <returns>Uri of the specific page.</returns>   
  protected override Uri ConstructUrl()   
  {   
    this.PageUrl = new Uri(string.Format("{0}/{1}", BasePage.BaseURL,   
       LoginPage.Page));   
    return this.PageUrl;   
  }   
  /// <summary>   
  /// Validate that the correct page is displayed.   
        /// </summary>   
  public override bool IsValidPageDisplayed()   
  {   
    return this.Body.FindById<HtmlDiv>(LoginTextBoxId) != null;   
  }   
  /// <summary>   
  /// Gets the login button from the page.   
        /// </summary>   
  public HtmlInputButton LoginButton   
  {   
    get
    {   
      return this.Body.FindById<HtmlInputButton>(LoginButtonId);   
    }   
  }   
  /// <summary>   
  /// Gets the login textbox from the page.   
        /// </summary>   
  public HtmlEdit LoginTextBox   
  {   
    get
    {   
      return this.Body.FindById<HtmlEdit>(LoginTextBoxId);   
    }   
  }   
  /// <summary>   
  /// Gets the password textbox from the page.   
        /// </summary>   
  public HtmlEdit PasswordTextBox   
  {   
    get
    {   
      return this.Body.FindById<HtmlEdit>(PasswordTextBoxId);   
    }   
  }   
  /// <summary>   
  /// Gets the error dialog window - when login failed.   
        /// </summary>   
  public HtmlControl ErrorDialog   
  {   
    get
    {   
      return this.Body.FindFirstByCssClass<HtmlControl>("*login_error_box ");   
    }   
  }   
  /// <summary>   
  /// Types login and password into input fields and clicks the Login button.   
        /// </summary>   
  public void TypeCredentialAndClickLogin(string login, string password)   
  {   
    var loginButton = this.LoginButton;   
    var emailInput = this.LoginTextBox;   
    var passwordInput = this.PasswordTextBox;   
    emailInput.TypeText(login);   
    passwordInput.TypeText(password);   
    Mouse.Click(loginButton, System.Windows.Forms.MouseButtons.Left);   
  }   
}

创建所需组件后,就可以生成测试用例了。此测试函数将验证登录操作是否成功完成。在测试用例开始时,我使用 Launch<T> 静态函数启动“登录”页面。我在登录和密码输入字段中输入所有需要的值,然后单击“登录”按钮。当操作结束时,验证新显示的面板是不是个人资料页面。

          [TestMethod]   
public void FacebookValidLogin()   
{   
  var loginPage = BasePage.Launch<LoginPage>();   
  loginPage.TypeCredentialAndClickLogin(fbLogin, fbPassword);   
  var profilePage = loginPage.InitializePage<ProfilePage>();   
  Assert.IsTrue(profilePage.IsValidPageDisplayed(),   
     "Profile page is not displayed.");   
}

在搜索特定 CSS 类的控件时,我注意到编码的 UI 测试框架中可能出现了问题。在 HTML 中,控件在类属性中可能有多个类名称,这当然会影响我要使用的框架。例如,如果当前网站包含特性类为“A B C”的 DIV 元素,当我使用 SearchSelector.Class 属性查找所有 CSS 类为“B”的控件时,可能不会得到任何结果,因为“A B C”不等于“B”。为解决此问题,我采用星号“*”表示法,将类预期从“相等”更改为“包含”。因此,要使该示例有效,我需要将类“B”更改为类“*B”。

问题解答…

有时测试会失败,您必须寻找原因。许多情况下,只需要查看测试日志就可以回答这个问题,但也不总是这样。在编码的 UI 测试框架中,有一个新功能可以按需提供额外信息。

假定测试失败的原因是显示的页面与预期的不同。从日志中可以看到,未找到某个所需的控件。这信息不错,但没有提供全部答案。但是,通过这个新功能,我可以捕获当前显示的屏幕。要使用该功能,只需添加捕获以及将其保存在测试清理方法中的方式,如图 8 所示。现在,可以获得有关任何失败的测试的信息了。

图 8 测试清理方法

          [TestCleanup]   
public void TestCleanup()   
{   
  if (this.TestContext.CurrentTestOutcome != null &&   
     this.TestContext.CurrentTestOutcome.ToString() == "Failed")   
{   
    try
    {   
      var img = BrowserWindow.Desktop.CaptureImage();   
      var pathToSave = System.IO.Path.Combine(   
        this.TestContext.TestResultsDirectory,   
        string.Format("{0}.jpg", this.TestContext.TestName));   
      var bitmap = new Bitmap(img);   
      bitmap.Save(pathToSave);   
    }   
    catch
    {   
      this.TestContext.WriteLine("Unable to capture or save screen.");   
    }   
  }   
}

总结

本文介绍了在 Visual Studio 2013 RC 中开始使用编码的 UI 测试框架是何等的方便快捷。当然,我介绍的只是这种技术的基本使用方法,包括管理不同的浏览器和支持各种查找、检索和操作 HTML 控件的操作。值得探索的精彩功能不止于此,还有很多。