デザインパターンと開発戦略

(以前の場所: https://github.com/SeleniumHQ/selenium/wiki/Bot-Style-Tests)

概要

プロジェクトは時間の経過とともに、多数のテストを蓄積する傾向があります。テストの総数が増加するにつれて、コードベースへの変更が難しくなります。単一の「単純な」変更が、アプリケーションがまだ正常に動作しているにもかかわらず、多数のテストを失敗させる可能性があります。場合によっては、これらの問題は避けられませんが、発生した場合は、できるだけ早く稼働状態に戻したいものです。以下のデザインパターンと戦略は、WebDriver でテストをより簡単に記述および保守できるようにするために以前に使用されてきました。これらはあなたにも役立つかもしれません。

DomainDrivenDesign: アプリのエンドユーザーの言語でテストを表現します。 PageObjects: Web アプリの UI のシンプルな抽象化。LoadableComponent: PageObjects をコンポーネントとしてモデル化。BotStyleTests: PageObjects が推奨するオブジェクトベースのアプローチではなく、コマンドベースのアプローチを使用してテストを自動化します。

LoadableComponent

LoadableComponent とは?

LoadableComponent は、PageObjects の記述をより容易にすることを目的とした基底クラスです。これは、ページがロードされていることを保証する標準的な方法を提供し、ページのロード失敗のデバッグを容易にするためのフックを提供することによって実現します。これを使用すると、テスト内のボイラープレートコードの量を減らすことができ、その結果、テストの保守がより容易になります。

現在、Selenium 2 の一部として出荷される Java での実装がありますが、使用されているアプローチは十分にシンプルで、どの言語でも実装できます。

シンプルな使用例

モデル化したい UI の例として、新しい Issue ページを見てみましょう。テスト作成者の視点から見ると、これは新しい Issue をファイルできるサービスを提供します。基本的なページオブジェクトは次のようになります。

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 を表すということです。

この基底クラスを拡張することにより、2 つの新しいメソッドを実装する必要があります。

  @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);
  }
}

あまり変わっていないように見えますよね? 1 つ変わった点は、ページへの移動方法に関する情報をページ自体にカプセル化し、この情報がコードベース全体に散らばらないようにしたことです。また、テストでこれを実行できることも意味します。

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

この呼び出しは、必要に応じてドライバをページに移動させます。

ネストされたコンポーネント

LoadableComponent は、他の LoadableComponent と組み合わせて使用​​すると、より便利になり始めます。例を使用すると、「Issue の編集」ページをプロジェクトの Web サイト内のコンポーネントと見なすことができます (結局のところ、そのサイトのタブからアクセスします)。Issue をファイルするには、ログインする必要もあります。これをネストされたコンポーネントのツリーとしてモデル化できます。

 + 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 はテストの重複を減らすための便利な方法ですが、常にチームが快適に感じるパターンではありません。代替アプローチは、より「コマンドのような」スタイルのテストに従うことです。

「ボット」は、生の 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");
  }
}

これらの抽象化が構築され、テストの重複が特定されたら、ボットの上に PageObjects を重ねることが可能です。