設計模式與開發策略

(先前位於:https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests)

總覽

隨著時間推移,專案往往會累積大量的測試。隨著測試總數的增加,對程式碼庫進行變更變得更加困難——即使應用程式仍然正常運作,一個「簡單」的變更也可能導致許多測試失敗。有時這些問題是不可避免的,但當它們確實發生時,您希望盡快恢復運作。以下設計模式和策略先前已與 WebDriver 一起使用,以幫助使測試更易於編寫和維護。它們也可能對您有所幫助。

DomainDrivenDesign:以應用程式最終使用者的語言表達您的測試。 PageObjects:您的 Web 應用程式 UI 的簡單抽象化。 LoadableComponent:將 PageObjects 建模為組件。 BotStyleTests:使用基於命令的方法來自動化測試,而不是 PageObjects 鼓勵的基於物件的方法

可載入組件

那是什麼?

LoadableComponent 是一個基礎類別,旨在使編寫 PageObjects 不那麼痛苦。它透過提供一種標準方法來確保頁面已載入,並提供鉤子來簡化偵錯頁面載入失敗的問題。您可以使用它來幫助減少測試中的樣板程式碼量,這反過來又使維護測試變得不那麼繁瑣。

目前在 Java 中有一個實作作為 Selenium 2 的一部分發布,但使用的方法非常簡單,可以在任何語言中實作。

簡單用法

作為我們想要建模的 UI 範例,請查看 新問題 頁面。從測試作者的角度來看,這提供了能夠提交新問題的服務。一個基本的 Page Object 看起來會像

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
}

這個簽名看起來有點不尋常,但它的意思只是這個類別代表一個載入 EditIssue 頁面的 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 類別執行一系列斷言。您可以根據需要使用任意數量的斷言。透過使用這些斷言,可以為該類別的使用者提供清晰的資訊,這些資訊可用於偵錯測試。

經過一些修改,我們的 PageObject 看起來像

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();

如果需要,此呼叫將導致驅動程式導航到頁面。

巢狀組件

當 LoadableComponents 與其他 LoadableComponents 結合使用時,它們開始變得更有用。以我們的範例為例,我們可以將「編輯問題」頁面視為專案網站中的一個組件(畢竟,我們是透過該網站上的標籤訪問它的)。您還需要登入才能提交問題。我們可以將其建模為巢狀組件樹

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

這在程式碼中會是什麼樣子?首先,每個邏輯組件都會有自己的類別。它們中的每一個的「load」方法都會「get」父組件。除了上面的 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 等函式庫,則可以省略設定 PageObjects 的前言,從而產生良好、清晰、可讀的測試。

Bot 模式

(先前位於:https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests)

雖然 PageObjects 是減少測試中重複程式碼的有用方法,但它並不總是團隊感到舒適遵循的模式。另一種方法是遵循更「類似命令」的測試風格。

「bot」是原始 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");
  }
}

一旦建立了這些抽象化並識別出測試中的重複程式碼,就可以在 bot 之上分層 PageObjects。