设计模式和开发策略

(之前位于:https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests

概述

随着时间的推移,项目往往会积累大量的测试。随着测试总数的增加,对代码库进行更改变得更加困难——即使应用程序仍然正常工作,一个“简单”的更改也可能导致许多测试失败。有时这些问题是不可避免的,但是当它们发生时,您希望能够尽快恢复正常运行。以下设计模式和策略之前已与 WebDriver 一起使用,以帮助使测试更易于编写和维护。它们也可能对您有所帮助。

领域驱动设计:用应用程序最终用户的语言表达您的测试。页面对象:您的 Web 应用程序 UI 的简单抽象。LoadableComponent:将页面对象建模为组件。BotStyleTests:使用基于命令的方法来自动化测试,而不是页面对象鼓励的基于对象的方法。

可加载组件

它是什么?

LoadableComponent 是一个基类,旨在减少编写页面对象的痛苦。它通过提供一种确保页面加载的标准方法以及提供用于更容易调试页面加载失败的钩子来实现此目的。您可以使用它来帮助减少测试中的样板代码量,这反过来又使维护测试变得不那么麻烦。

目前在 Java 中有一个实现,作为 Selenium 2 的一部分发布,但是使用的方法足够简单,可以用任何语言实现。

简单用法

作为我们想要建模的 UI 示例,请查看新建问题页面。从测试编写者的角度来看,这提供了提交新问题的服务。一个基本的页面对象将如下所示

package com.example.webdriver;

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

public class EditIssue {

  private final WebDriver driver;

  public EditIssue(WebDriver driver) {
    this.driver = driver;
  }

  public void setTitle(String title) {
    WebElement field = driver.findElement(By.id("issue_title")));
    clearAndType(field, title);
  }

  public void setBody(String body) {
    WebElement field = driver.findElement(By.id("issue_body"));
    clearAndType(field, body);
  }

  public void setHowToReproduce(String howToReproduce) {
    WebElement field = driver.findElement(By.id("issue_form_repro-command"));
    clearAndType(field, howToReproduce);
  }

  public void setLogOutput(String logOutput) {
    WebElement field = driver.findElement(By.id("issue_form_logs"));
    clearAndType(field, logOutput);
  }

  public void setOperatingSystem(String operatingSystem) {
    WebElement field = driver.findElement(By.id("issue_form_operating-system"));
    clearAndType(field, operatingSystem);
  }

  public void setSeleniumVersion(String seleniumVersion) {
    WebElement field = driver.findElement(By.id("issue_form_selenium-version"));
    clearAndType(field, logOutput);
  }

  public void setBrowserVersion(String browserVersion) {
    WebElement field = driver.findElement(By.id("issue_form_browser-versions"));
    clearAndType(field, browserVersion);
  }

  public void setDriverVersion(String driverVersion) {
    WebElement field = driver.findElement(By.id("issue_form_browser-driver-versions"));
    clearAndType(field, driverVersion);
  }

  public void setUsingGrid(String usingGrid) {
    WebElement field = driver.findElement(By.id("issue_form_selenium-grid-version"));
    clearAndType(field, usingGrid);
  }

  public IssueList submit() {
    driver.findElement(By.cssSelector("button[type='submit']")).click();
    return new IssueList(driver);
  }

  private void clearAndType(WebElement field, String text) {
    field.clear();
    field.sendKeys(text);
  }
}

为了将其转换为 LoadableComponent,我们所需要做的就是将其设置为基本类型

public class EditIssue extends LoadableComponent<EditIssue> {
  // rest of class ignored for now
}

这个签名看起来有点不寻常,但它所表示的只是此类代表一个加载编辑问题页面的 LoadableComponent。

通过扩展这个基类,我们需要实现两个新方法

  @Override
  protected void load() {
    driver.get("https://github.com/SeleniumHQ/selenium/issues/new?assignees=&labels=I-defect%2Cneeds-triaging&projects=&template=bug-report.yml&title=%5B%F0%9F%90%9B+Bug%5D%3A+");
  }

  @Override
  protected void isLoaded() throws Error {
    String url = driver.getCurrentUrl();
    assertTrue("Not on the issue entry page: " + url, url.endsWith("/new"));
  }

load 方法用于导航到页面,而 isLoaded 方法用于确定我们是否在正确的页面上。尽管该方法看起来应该返回一个布尔值,但它使用 JUnit 的 Assert 类执行一系列断言。可以有任意数量的断言。通过使用这些断言,可以为类的用户提供清晰的信息,这些信息可用于调试测试。

经过一些修改,我们的页面对象如下所示

package com.example.webdriver;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;

import static junit.framework.Assert.assertTrue;

public class EditIssue extends LoadableComponent<EditIssue> {

  private final WebDriver driver;
  
  // By default the PageFactory will locate elements with the same name or id
  // as the field. Since the issue_title element has an id attribute of "issue_title"
  // we don't need any additional annotations.
  private WebElement issue_title;
  
  // But we'd prefer a different name in our code than "issue_body", so we use the
  // FindBy annotation to tell the PageFactory how to locate the element.
  @FindBy(id = "issue_body") private WebElement body;
  
  public EditIssue(WebDriver driver) {
    this.driver = driver;
    
    // This call sets the WebElement fields.
    PageFactory.initElements(driver, this);
  }

  @Override
  protected void load() {
    driver.get("https://github.com/SeleniumHQ/selenium/issues/new?assignees=&labels=I-defect%2Cneeds-triaging&projects=&template=bug-report.yml&title=%5B%F0%9F%90%9B+Bug%5D%3A+");
  }

  @Override
  protected void isLoaded() throws Error {
    String url = driver.getCurrentUrl();
    assertTrue("Not on the issue entry page: " + url, url.endsWith("/new"));
  }

  public void setHowToReproduce(String howToReproduce) {
    WebElement field = driver.findElement(By.id("issue_form_repro-command"));
    clearAndType(field, howToReproduce);
  }

  public void setLogOutput(String logOutput) {
    WebElement field = driver.findElement(By.id("issue_form_logs"));
    clearAndType(field, logOutput);
  }

  public void setOperatingSystem(String operatingSystem) {
    WebElement field = driver.findElement(By.id("issue_form_operating-system"));
    clearAndType(field, operatingSystem);
  }

  public void setSeleniumVersion(String seleniumVersion) {
    WebElement field = driver.findElement(By.id("issue_form_selenium-version"));
    clearAndType(field, logOutput);
  }

  public void setBrowserVersion(String browserVersion) {
    WebElement field = driver.findElement(By.id("issue_form_browser-versions"));
    clearAndType(field, browserVersion);
  }

  public void setDriverVersion(String driverVersion) {
    WebElement field = driver.findElement(By.id("issue_form_browser-driver-versions"));
    clearAndType(field, driverVersion);
  }

  public void setUsingGrid(String usingGrid) {
    WebElement field = driver.findElement(By.id("issue_form_selenium-grid-version"));
    clearAndType(field, usingGrid);
  }

  public IssueList submit() {
    driver.findElement(By.cssSelector("button[type='submit']")).click();
    return new IssueList(driver);
  }

  private void clearAndType(WebElement field, String text) {
    field.clear();
    field.sendKeys(text);
  }
}

这似乎没有给我们带来太多好处,对吧?它所做的一件事是将有关如何导航到页面的信息封装到页面本身中,这意味着此信息不会分散在代码库中。这也意味着我们可以在测试中执行以下操作

EditIssue page = new EditIssue(driver).get();

如果必要,此调用将使驱动程序导航到页面。

嵌套组件

当 LoadableComponent 与其他 LoadableComponent 一起使用时,它们开始变得更有用。使用我们的示例,我们可以将“编辑问题”页面视为项目网站中的一个组件(毕竟,我们通过该网站上的一个选项卡访问它)。您还需要登录才能提交问题。我们可以将其建模为嵌套组件树

 + ProjectPage
 +---+ SecuredPage
     +---+ EditIssue

这在代码中会是什么样子?首先,每个逻辑组件都有其自己的类。它们中的每一个的“load”方法都会“获取”父级。除了上面的 EditIssue 类之外,最终结果是

ProjectPage.java

package com.example.webdriver;

import org.openqa.selenium.WebDriver;

import static org.junit.Assert.assertTrue;

public class ProjectPage extends LoadableComponent<ProjectPage> {

  private final WebDriver driver;
  private final String projectName;

  public ProjectPage(WebDriver driver, String projectName) {
    this.driver = driver;
    this.projectName = projectName;
  }

  @Override
  protected void load() {
    driver.get("http://" + projectName + ".googlecode.com/");
  }

  @Override
  protected void isLoaded() throws Error {
    String url = driver.getCurrentUrl();

    assertTrue(url.contains(projectName));
  }
}

和 SecuredPage.java

package com.example.webdriver;

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

import static org.junit.Assert.fail;

public class SecuredPage extends LoadableComponent<SecuredPage> {

  private final WebDriver driver;
  private final LoadableComponent<?> parent;
  private final String username;
  private final String password;

  public SecuredPage(WebDriver driver, LoadableComponent<?> parent, String username, String password) {
    this.driver = driver;
    this.parent = parent;
    this.username = username;
    this.password = password;
  }

  @Override
  protected void load() {
    parent.get();

    String originalUrl = driver.getCurrentUrl();

    // Sign in
    driver.get("https://www.google.com/accounts/ServiceLogin?service=code");
    driver.findElement(By.name("Email")).sendKeys(username);
    WebElement passwordField = driver.findElement(By.name("Passwd"));
    passwordField.sendKeys(password);
    passwordField.submit();

    // Now return to the original URL
    driver.get(originalUrl);
  }

  @Override
  protected void isLoaded() throws Error {
    // If you're signed in, you have the option of picking a different login.
    // Let's check for the presence of that.

    try {
      WebElement div = driver.findElement(By.id("multilogin-dropdown"));
    } catch (NoSuchElementException e) {
      fail("Cannot locate user name link");
    }
  }
}

EditIssue 中的“load”方法现在看起来像

  @Override
  protected void load() {
    securedPage.get();

    driver.get("https://github.com/SeleniumHQ/selenium/issues/new?assignees=&labels=I-defect%2Cneeds-triaging&projects=&template=bug-report.yml&title=%5B%F0%9F%90%9B+Bug%5D%3A+");
  }

这表明组件都是彼此“嵌套”的。对 EditIssue 中的 get() 的调用也将导致加载其所有依赖项。示例用法

public class FooTest {
  private EditIssue editIssue;

  @Before
  public void prepareComponents() {
    WebDriver driver = new FirefoxDriver();

    ProjectPage project = new ProjectPage(driver, "selenium");
    SecuredPage securedPage = new SecuredPage(driver, project, "example", "top secret");
    editIssue = new EditIssue(driver, securedPage);
  }

  @Test
  public void demonstrateNestedLoadableComponents() {
    editIssue.get();

    editIssue.title.sendKeys('Title');
    editIssue.body.sendKeys('What Happened');
    editIssue.setHowToReproduce('How to Reproduce');
    editIssue.setLogOutput('Log Output');
    editIssue.setOperatingSystem('Operating System');
    editIssue.setSeleniumVersion('Selenium Version');
    editIssue.setBrowserVersion('Browser Version');
    editIssue.setDriverVersion('Driver Version');
    editIssue.setUsingGrid('I Am Using Grid');
  }

}

如果您在测试中使用像 Guiceberry 这样的库,则可以省略设置页面对象的前导部分,从而实现清晰、易读的测试。

机器人模式

(之前位于:https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests

尽管页面对象是减少测试中重复内容的一种有用方法,但并非总是团队都愿意遵循的模式。另一种方法是遵循更“类似命令”的测试样式。

“机器人”是原始 Selenium API 的面向操作的抽象。这意味着如果您发现命令没有为您的应用程序做正确的事情,则可以轻松地更改它们。例如

public class ActionBot {
  private final WebDriver driver;

  public ActionBot(WebDriver driver) {
    this.driver = driver;
  }

  public void click(By locator) {
    driver.findElement(locator).click();
  }

  public void submit(By locator) {
    driver.findElement(locator).submit();
  }

  /** 
   * Type something into an input field. WebDriver doesn't normally clear these
   * before typing, so this method does that first. It also sends a return key
   * to move the focus out of the element.
   */
  public void type(By locator, String text) { 
    WebElement element = driver.findElement(locator);
    element.clear();
    element.sendKeys(text + "\n");
  }
}

一旦构建了这些抽象并确定了测试中的重复内容,就可以在机器人之上分层页面对象。