这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

指南和建议

Selenium项目的一些测试指南和建议.

关于"最佳实践"的注解:我们有意在本文档中避免使用"最佳实践"的说辞. 没有一种方法可以适用于所有情况. 我们更喜欢"指南和建议"的想法. 我们鼓励您通读这些内容, 并仔细地确定哪种方法适用于您的特定环境.

由于许多原因, 功能测试很难正确完成. 即便应用程序的状态, 复杂性, 依赖还不够让测试变得 足够复杂, 操作浏览器(特别是跨浏览器的兼容性测试)就已经使得写一个好的测试变成一种挑战.

Selenium提供了一些工具使得功能测试用户更简单的操作浏览器, 但是这些工具并不能帮助你来写一个好的 架构的测试套件. 这章我们会针对怎么来做web页面的功能测试的自动化给出一些忠告, 指南和建议.

这章记录了很多历年来成功的使用Selenium的用户的常用的软件设计模式.

1 - PO设计模式

PO(page object)设计模式是在自动化中已经流行起来的一种易于维护和减少代码的设计模式. 在自动化测试中, PO对象作为一个与页面交互的接口. 测试中需要与页面的UI进行交互时, 便调用PO的方法. 这样做的好处是, 如果页面的UI发生了更改,那么测试用例本身不需要更改, 只需更改PO中的代码即可.

PO设计模式具有以下优点:

  • 测试代码与页面的定位代码(如定位器或者其他的映射)相分离.
  • 该页面提供的方法或元素在一个独立的类中, 而不是将这些方法或元素分散在整个测试中.

这允许在一个地方修改由于UI变化所带来的所有修改. 随着这种"测试设计模式"的广泛使用, 可以在众多博客中找到有关此技术的有用信息. 我们鼓励希望了解更多信息的读者在互联网上搜索有关此主题的博客. 许多人已经写过这种设计模式, 并且可以提供超出本用户指南范围的有用提示. 不过, 为了让您入门, 我们将通过一个简单的示例来说明页面对象.

首先, 思考一个不使用PO模式的自动化测试的典型案例:

/***
 * Tests login feature
 */
public class Login {

  public void testLogin() {
    // 在登录页面上填写登录数据
    driver.findElement(By.name("user_name")).sendKeys("userName");
    driver.findElement(By.name("password")).sendKeys("my supersecret password");
    driver.findElement(By.name("sign-in")).click();

    // 登录后验证h1标签是否为Hello userName
    driver.findElement(By.tagName("h1")).isDisplayed();
    assertThat(driver.findElement(By.tagName("h1")).getText(), is("Hello userName"));
  }
}

这种方法有两个问题.

  • 测试方法与定位器 (在此实例中为By.name)耦合过于严重. 如果测试的用户界面更改了其定位器或登录名的输入和处理方式, 则测试本身必须进行更改.
  • 在对登录页面的所有测试中, 同一个定位器会散布在其中.

可以在以下登录页面的示例中应用PO设计模式重写此示例.

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;

/**
 * Page Object encapsulates the Sign-in page.
 */
public class SignInPage {
  protected WebDriver driver;

  // <input name="user_name" type="text" value="">
  private By usernameBy = By.name("user_name");
  // <input name="password" type="password" value="">
  private By passwordBy = By.name("password");
  // <input name="sign_in" type="submit" value="SignIn">
  private By signinBy = By.name("sign_in");

  public SignInPage(WebDriver driver){
    this.driver = driver;
    if (!driver.getTitle().equals("Sign In Page")) {
      throw new IllegalStateException("This is not Sign In Page," +
            " current page is: " + driver.getCurrentUrl());
    }
  }

  /**
    * Login as valid user
    *
    * @param userName
    * @param password
    * @return HomePage object
    */
  public HomePage loginValidUser(String userName, String password) {
    driver.findElement(usernameBy).sendKeys(userName);
    driver.findElement(passwordBy).sendKeys(password);
    driver.findElement(signinBy).click();
    return new HomePage(driver);
  }
}

Home page的PO如下所示.

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;

/**
 * Page Object encapsulates the Home Page
 */
public class HomePage {
  protected WebDriver driver;

  // <h1>Hello userName</h1>
  private By messageBy = By.tagName("h1");

  public HomePage(WebDriver driver){
    this.driver = driver;
    if (!driver.getTitle().equals("Home Page of logged in user")) {
      throw new IllegalStateException("This is not Home Page of logged in user," +
            " current page is: " + driver.getCurrentUrl());
    }
  }

  /**
    * Get message (h1 tag)
    *
    * @return String message text
    */
  public String getMessageText() {
    return driver.findElement(messageBy).getText();
  }

  public HomePage manageProfile() {
    // Page encapsulation to manage profile functionality
    return new HomePage(driver);
  }
  /* 提供登录用户主页所代表的服务的更多方法. 这些方法可能会返回更多页面对象. 
  例如, 单击"撰写邮件"按钮可以返回ComposeMail类对象 */
}

那么, 接下来的登录测试用例将使用这两个页面对象.

/***
 * Tests login feature
 */
public class TestLogin {

  @Test
  public void testLogin() {
    SignInPage signInPage = new SignInPage(driver);
    HomePage homePage = signInPage.loginValidUser("userName", "password");
    assertThat(homePage.getMessageText(), is("Hello userName"));
  }

}

PO的设计方式具有很大的灵活性, 但是有一些基本规则可以使测试代码具有理想的可维护性.

PO本身绝不应进行判断或断言. 判断和断言是测试的一部分, 应始终在测试的代码内, 而不是在PO中. PO用来包含页面的表示形式, 以及页面通过方法提供的服务, 但是与PO无关的测试代码不应包含在其中.

实例化PO时, 应进行一次验证, 即验证页面以及页面上可能的关键元素是否已正确加载. 在上面的示例中, SignInPage和HomePage的构造函数均检查预期的页面是否可用并准备接受测试请求.

PO不一定需要代表整个页面. PO设计模式可用于表示页面上的组件. 如果自动化测试中的页面包含多个组件, 则每个组件都有单独的页面对象, 则可以提高可维护性.

还有其他设计模式也可以在测试中使用. 一些使用页面工厂实例化其页面对象. 讨论所有这些都不在本用户指南的范围之内. 在这里, 我们只想介绍一些概念, 以使读者了解可以完成的一些事情. 如前所述, 许多人都在此主题上写博客, 我们鼓励读者搜索有关这些主题的博客.

2 - 领域特定语言

领域特定语言 (DSL) 是一种为用户提供解决问题的表达方式的系统. 它使用户可以按照自己的术语与系统进行交互, 而不仅仅是通过程序员的语言.

您的用户通常并不关心您网站的外观. 他们不在乎装饰, 动画或图形. 他们希望借助于您的系统, 以最小的难度使新员工融入整个流程; 他们想预订去阿拉斯加的旅行; 他们想以折扣价配置和购买独角兽. 您作为测试人员的工作应尽可能接近"捕捉”这种思维定势. 考虑到这一点, 我们开始着手"建模”您正在工作的应用程序, 以使测试脚本 (发布前用户仅有的代理) “说话”并代表用户.

在Selenium中, DSL通常由方法表示, 其编写方式使API简单易读-它们使开发人员和干系人 (用户, 产品负责人, 商业智能专家等) 之间能够产生汇报.

好处

  • 可读: 业务关系人可以理解.
  • 可写: 易于编写, 避免不必要的重复.
  • 可扩展: 可以 (合理地) 添加功能而无需打破约定以及现有功能.
  • 可维护: 通过将实现细节排除在测试用例之外, 您可以很好地隔离 AUT* 的修改.

Java

以下是Java中合理的DSL方法的示例. 为简便起见, 假定 driver 对象是预定义的并且可用于该方法.

/**
 * Takes a username and password, fills out the fields, and clicks "login".
 * @return An instance of the AccountPage
 */
public AccountPage loginAsUser(String username, String password) {
  WebElement loginField = driver.findElement(By.id("loginField"));
  loginField.clear();
  loginField.sendKeys(username);

  // Fill out the password field. The locator we're using is "By.id", and we should
  // have it defined elsewhere in the class.
  WebElement passwordField = driver.findElement(By.id("password"));
  passwordField.clear();
  passwordField.sendKeys(password);

  // Click the login button, which happens to have the id "submit".
  driver.findElement(By.id("submit")).click();

  // Create and return a new instance of the AccountPage (via the built-in Selenium
  // PageFactory).
  return PageFactory.newInstance(AccountPage.class);
}

此方法完全从测试代码中抽象出输入字段, 按钮, 单击甚至页面的概念. 使用这种方法, 测试人员要做的就是调用此方法. 这给您带来了维护方面的优势: 如果登录字段曾经更改过, 则只需更改此方法-而非您的测试.

public void loginTest() {
    loginAsUser("cbrown", "cl0wn3");

    // Now that we're logged in, do some other stuff--since we used a DSL to support
    // our testers, it's as easy as choosing from available methods.
    do.something();
    do.somethingElse();
    Assert.assertTrue("Something should have been done!", something.wasDone());

    // Note that we still haven't referred to a button or web control anywhere in this
    // script...
}

郑重强调: 您的主要目标之一应该是编写一个API, 该API允许您的测试解决 当前的问题, 而不是UI的问题. 用户界面是用户的次要问题–用户并不关心用户界面, 他们只是想完成工作. 您的测试脚本应该像用户希望做的事情以及他们想知道的事情的完整清单那样易于阅读. 测试不应该考虑UI如何要求您去做.

*AUT: 待测系统

3 - 生成应用程序状态

Selenium不应用于准备测试用例. 测试用例中所有重复性动作和准备工作, 都应通过其他方法来完成.
例如, 大多数Web UI都具有身份验证 (诸如一个登录表单) . 在每次测试之前通过Web浏览器进行登录的消除, 将提高测试的速度和稳定性. 应该创建一种方法来获取对 AUT* 的访问权限 (例如, 使用API登录并设置Cookie) . 此外, 不应使用Selenium创建预加载数据来进行测试的方法.
如前所述, 应利用现有的API为 AUT* 创建数据. *AUT: 待测系统

4 - 模拟外部服务

消除对外部服务的依赖性将大大提高测试的速度和稳定性.

5 - 改善报告

Selenium并非旨在报告测试用例的运行状态. 利用单元测试框架的内置报告功能是一个好的开始. 大多数单元测试框架都有可以生成xUnit或HTML格式的报告. xUnit报表很受欢迎, 可以将其结果导入到持续集成(CI)服务器, 例如Jenkins、Travis、Bamboo等. 以下是一些链接, 可获取关于几种语言报表输出的更多信息.

NUnit 3 Console Runner

NUnit 3 Console Command Line

xUnit getting test results in TeamCity

xUnit getting test results in CruiseControl.NET

xUnit getting test results in Azure DevOps

6 - 避免共享状态

尽管在多个地方都提到过, 但这点仍值得被再次提及. 确保测试相互隔离.

  • 不要共享测试数据. 想象一下有几个测试, 每个测试都会在选择操作执行之前查询数据库中的有效订单. 如果两个测试采用相同的顺序, 则很可能会出现意外行为.

  • 清理应用程序中过时的数据, 这些数据可能会被其他测试. 例如无效的订单记录.

  • 每次测试都创建一个新的WebDriver实例. 这在确保测试隔离的同时可以保障并行化更为简单.

7 - 使用定位器的提示

何时使用哪些定位器以及如何在代码中最好地管理它们.

这里有一些 支持的定位策略 的例子 .

一般来说,如果 HTML 的 id 是可用的、唯一的且是可预测的,那么它就是在页面上定位元素的首选方法。它们的工作速度非常快,可以避免复杂的 DOM 遍历带来的大量处理。

如果没有唯一的 id,那么最好使用写得好的 CSS 选择器来查找元素。XPath 和 CSS 选择器一样好用,但是它语法很复杂,并且经常很难调试。尽管 XPath 选择器非常灵活,但是他们通常未经过浏览器厂商的性能测试,并且运行速度很慢。

基于链接文本和部分链接文本的选择策略有其缺点,即只能对链接元素起作用。此外,它们在 WebDriver 内部调用 querySelectorAll 选择器。

标签名可能是一种危险的定位元素的方法。页面上经常出现同一标签的多个元素。这在调用 findElements(By) 方法返回元素集合的时候非常有用。

建议您尽可能保持定位器的紧凑性和可读性。使用 WebDriver 遍历 DOM 结构是一项性能花销很大的操作,搜索范围越小越好。

8 - 测试的独立性

将每个测试编写为独立的单元. 以不依赖于其他测试完成的方式编写测试:

例如有一个内容管理系统, 您可以借助其创建一些自定义内容, 这些内容在发布后作为模块显示在您的网站上, 并且CMS和应用程序之间的同步可能需要一些时间.

测试模块的一种错误方法是在测试中创建并发布内容, 然后在另一测试中检查该模块. 这是不可取的, 因为发布后内容可能无法立即用于其他测试.

与之相反的事, 您可以创建在受影响的测试中打开和关闭的打桩内容, 并将其用于验证模块. 而且, 对于内容的创建, 您仍然可以进行单独的测试.

9 - 考虑使用Fluent API

Martin Fowler创造了术语 “Fluent API”. Selenium已经在其 FluentWait 类中实现了类似的东西, 这是对标准 Wait 类的替代. 您可以在页面对象中启用Fluent API设计模式, 然后使用如下代码段查询Google搜索页面:

driver.get( "http://www.google.com/webhp?hl=en&amp;tab=ww" );
GoogleSearchPage gsp = new GoogleSearchPage();
gsp.withFluent().setSearchString().clickSearchButton();

Google页面对象类具有这种流畅行为后可能看起来像这样:

public class GoogleSearchPage extends LoadableComponent<GoogleSearchPage> {
  private final WebDriver driver;
  private GSPFluentInterface gspfi;

  public class GSPFluentInterface {
    private GoogleSearchPage gsp;

    public GSPFluentInterface(GoogleSearchPage googleSearchPage) {
        gsp = googleSearchPage;
    }

    public GSPFluentInterface clickSearchButton() {
        gsp.searchButton.click();
        return this;
    }

    public GSPFluentInterface setSearchString( String sstr ) {
        clearAndType( gsp.searchField, sstr );
        return this;
    }
  }

  @FindBy(id = "gbqfq") private WebElement searchField;
  @FindBy(id = "gbqfb") private WebElement searchButton;
  public GoogleSearchPage(WebDriver driver) {
    gspfi = new GSPFluentInterface( this );
    this.get(); // If load() fails, calls isLoaded() until page is finished loading
    PageFactory.initElements(driver, this); // Initialize WebElements on page
  }

  public GSPFluentInterface withFluent() {
    return gspfi;
  }

  public void clickSearchButton() {
    searchButton.click();
  }

  public void setSearchString( String sstr ) {
    clearAndType( searchField, sstr );
  }

  @Override
  protected void isLoaded() throws Error {
    Assert.assertTrue("Google search page is not yet loaded.", isSearchFieldVisible() );
  }

  @Override
  protected void load() {
    if ( isSFieldPresent ) {
      Wait<WebDriver> wait = new WebDriverWait( driver, Duration.ofSeconds(3) );
      wait.until( visibilityOfElementLocated( By.id("gbqfq") ) ).click();
    }
  }
}

10 - 每次测试都刷新浏览器

每次测试都从一个干净的已知状态开始. 理想情况下, 为每次测试打开一个新的虚拟机. 如果打开新虚拟机不切实际, 则至少应为每次测试启动一个新的WebDriver. 对于Firefox, 请使用您已知的配置文件去启动WebDriver. 大多数浏览器驱动器,像GeckoDriver和ChromeDriver那样,默认都会以干净的已知状态和一个新的用户配置文件开始。

WebDriver driver = new FirefoxDriver();