ページオブジェクトモデル

注: このページは、Selenium wiki を含む複数のソースからのコンテンツを統合したものです。

概要

Web アプリの UI 内には、テストがインタラクトする領域があります。ページオブジェクトは、これらをテストコード内のオブジェクトとしてモデル化するだけです。これにより、重複するコードの量が減り、UI が変更された場合、修正は 1 か所のみに適用すれば済むようになります。

ページオブジェクトは、テストのメンテナンスを強化し、コードの重複を減らすためにテスト自動化で普及しているデザインパターンです。ページオブジェクトは、AUT のページのインターフェースとして機能するオブジェクト指向クラスです。テストは、ページの UI と対話する必要があるときはいつでも、このページオブジェクトクラスのメソッドを使用します。利点は、ページの UI が変更されても、テスト自体を変更する必要がなく、ページオブジェクト内のコードのみを変更する必要があることです。その結果、新しい UI をサポートするためのすべての変更は、1 か所に集約されます。

利点

  • テストコードと、ロケータ(または UI マップを使用している場合はその使用)やレイアウトなどのページ固有のコードとの間に明確な分離があります。
  • ページが提供するサービスまたは操作のための単一のリポジトリがあり、これらのサービスがテスト全体に散在することはありません。

どちらの場合も、UI の変更による必要な変更はすべて 1 か所で行うことができます。この手法に関する役立つ情報は、この「テスト設計パターン」が広く使用されるようになっているため、多数のブログで見つけることができます。詳細を知りたい読者には、この主題に関するブログをインターネットで検索することをお勧めします。多くの人がこのデザインパターンについて書いており、このユーザーガイドの範囲を超えて役立つヒントを提供できます。手始めに、簡単な例でページオブジェクトを説明します。

まず、ページオブジェクトを使用しない、テスト自動化の典型的な例を考えてみましょう。

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

  public void testLogin() {
    // fill login data on sign-in page
    driver.findElement(By.name("user_name")).sendKeys("userName");
    driver.findElement(By.name("password")).sendKeys("my supersecret password");
    driver.findElement(By.name("sign-in")).click();

    // verify h1 tag is "Hello userName" after login
    driver.findElement(By.tagName("h1")).isDisplayed();
    assertThat(driver.findElement(By.tagName("h1")).getText(), is("Hello userName"));
  }
}

このアプローチには 2 つの問題があります。

  • テストメソッドと AUT のロケータ(この例では ID)の間に分離がなく、両方が単一のメソッドに絡み合っています。AUT の UI が識別子、レイアウト、またはログインの入力と処理方法を変更した場合、テスト自体も変更する必要があります。
  • ID ロケータは、このログインページを使用する必要があるすべてのテストで、複数のテストに分散されます。

ページオブジェクトの手法を適用すると、この例は、サインインページのページオブジェクトの次の例のように書き換えることができます。

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

そして、ホームページのページオブジェクトは次のようになります。

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);
  }
  /* More methods offering the services represented by Home Page
  of Logged User. These methods in turn might return more Page Objects
  for example click on Compose mail button could return ComposeMail class object */
}

これで、ログインテストは、次の 2 つのページオブジェクトを次のように使用します。

/***
 * 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"));
  }

}

ページオブジェクトの設計方法には多くの柔軟性がありますが、テストコードの望ましい保守性を得るためのいくつかの基本的なルールがあります。

ページオブジェクトにおけるアサーション

ページオブジェクト自体は、検証やアサーションを絶対に行うべきではありません。これはテストの一部であり、常にテストのコード内にあるべきであり、ページオブジェクト内にあるべきではありません。ページオブジェクトには、ページの表現と、ページがメソッドを通じて提供するサービスが含まれますが、テストされている内容に関連するコードはページオブジェクト内にあるべきではありません。

ページオブジェクト内に記述でき、記述すべき検証が 1 つあり、それはページと、場合によってはページ上の重要な要素が正しくロードされたことを検証することです。この検証は、ページオブジェクトをインスタンス化するときに行う必要があります。上記の例では、SignInPage と HomePage の両方のコンストラクタが、期待されるページが利用可能であり、テストからのリクエストを受け入れる準備ができていることを確認しています。

ページコンポーネントオブジェクト

ページオブジェクトは、必ずしもページ自体のすべての部分を表す必要はありません。これは、初期の頃に Martin Fowler によって指摘され、「パネルオブジェクト」という用語を最初に作り出したときのことです。

ページオブジェクトに使用されるのと同じ原則を、「ページコンポーネントオブジェクト」を作成するために使用できます。これは後で呼ばれるようになり、ページの個別のチャンクを表し、ページオブジェクトに含めることができます。これらのコンポーネントオブジェクトは、これらの個別のチャンク内の要素への参照と、それらによって提供される機能または動作を活用するためのメソッドを提供できます。

たとえば、製品ページには複数の製品があります。

<!-- Products Page -->
<div class="header_container">
    <span class="title">Products</span>
</div>

<div class="inventory_list">
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
    <div class="inventory_item">
    </div>
</div>

各製品は、製品ページのコンポーネントです。

<!-- Inventory Item -->
<div class="inventory_item">
    <div class="inventory_item_name">Backpack</div>
    <div class="pricebar">
        <div class="inventory_item_price">$29.99</div>
        <button id="add-to-cart-backpack">Add to cart</button>
    </div>
</div>

製品ページには、製品のリストがあります。このオブジェクト関係はコンポジションと呼ばれます。簡単に言えば、何かは別のもので構成されています

public abstract class BasePage {
    protected WebDriver driver;

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

// Page Object
public class ProductsPage extends BasePage {
    public ProductsPage(WebDriver driver) {
        super(driver);
        // No assertions, throws an exception if the element is not loaded
        new WebDriverWait(driver, Duration.ofSeconds(3))
            .until(d -> d.findElement(By.className("header_container")));
    }

    // Returning a list of products is a service of the page
    public List<Product> getProducts() {
        return driver.findElements(By.className("inventory_item"))
            .stream()
            .map(e -> new Product(e)) // Map WebElement to a product component
            .toList();
    }

    // Return a specific product using a boolean-valued function (predicate)
    // This is the behavioral Strategy Pattern from GoF
    public Product getProduct(Predicate<Product> condition) {
        return getProducts()
            .stream()
            .filter(condition) // Filter by product name or price
            .findFirst()
            .orElseThrow();
    }
}

製品コンポーネントオブジェクトは、製品ページオブジェクト内で使用されます。

public abstract class BaseComponent {
    protected WebElement root;

    public BaseComponent(WebElement root) {
        this.root = root;
    }
}

// Page Component Object
public class Product extends BaseComponent {
    // The root element contains the entire component
    public Product(WebElement root) {
        super(root); // inventory_item
    }

    public String getName() {
        // Locating an element begins at the root of the component
        return root.findElement(By.className("inventory_item_name")).getText();
    }

    public BigDecimal getPrice() {
        return new BigDecimal(
                root.findElement(By.className("inventory_item_price"))
                    .getText()
                    .replace("$", "")
            ).setScale(2, RoundingMode.UNNECESSARY); // Sanitation and formatting
    }

    public void addToCart() {
        root.findElement(By.id("add-to-cart-backpack")).click();
    }
}

これで、製品テストは、ページオブジェクトとページコンポーネントオブジェクトを次のように使用します。

public class ProductsTest {
    @Test
    public void testProductInventory() {
        var productsPage = new ProductsPage(driver); // page object
        var products = productsPage.getProducts();
        assertEquals(6, products.size()); // expected, actual
    }
    
    @Test
    public void testProductPrices() {
        var productsPage = new ProductsPage(driver);

        // Pass a lambda expression (predicate) to filter the list of products
        // The predicate or "strategy" is the behavior passed as parameter
        var backpack = productsPage.getProduct(p -> p.getName().equals("Backpack")); // page component object
        var bikeLight = productsPage.getProduct(p -> p.getName().equals("Bike Light"));

        assertEquals(new BigDecimal("29.99"), backpack.getPrice());
        assertEquals(new BigDecimal("9.99"), bikeLight.getPrice());
    }
}

ページとコンポーネントは、独自のオブジェクトによって表されます。両方のオブジェクトは、オブジェクト指向プログラミングの現実世界のアプリケーションに一致する、提供するサービスのメソッドのみを持っています。

さらに複雑なページのために、コンポーネントオブジェクトを他のコンポーネントオブジェクト内にネストすることもできます。AUT のページに複数のコンポーネントがある場合、またはサイト全体で使用される共通コンポーネント(ナビゲーションバーなど)がある場合は、保守性が向上し、コードの重複が減る可能性があります。

テストで使用されるその他の設計パターン

テストで使用できる他の設計パターンもあります。これらすべてを議論することは、このユーザーガイドの範囲を超えています。ここでは、読者に実行できることのいくつかを知ってもらうために、概念を紹介したいだけです。前述のように、多くの人がこのトピックについてブログで書いており、読者にはこれらのトピックに関するブログを検索することをお勧めします。

実装に関する注意

ページオブジェクトは、同時に 2 つの方向を向いていると考えることができます。テストの開発者に向かって、特定のページによって提供されるサービスを表します。開発者から離れて、ページの HTML の構造(またはページの一部)に関する深い知識を持っている唯一のものである必要があります。ページオブジェクトのメソッドは、ページの細部やメカニズムを公開するのではなく、ページが提供する「サービス」を提供すると考えるのが最も簡単です。例として、Web ベースのメールシステムの受信トレイを考えてみましょう。提供するサービスの中には、新しいメールを作成する機能、単一のメールを読むことを選択する機能、および受信トレイ内のメールの件名行をリストする機能があります。これらがどのように実装されているかは、テストには関係ありません。

テストの開発者に、実装ではなく、対話しているサービスについて考えようとすることを推奨しているため、ページオブジェクトは、基盤となる WebDriver インスタンスをほとんど公開するべきではありません。これを容易にするために、ページオブジェクトのメソッドは、他のページオブジェクトを返す必要があります。これは、アプリケーションを通るユーザーの移動を効果的にモデル化できることを意味します。また、ページ間の関係が変更された場合(以前はそうではなかったサービスに最初にログインするときに、ログインページがユーザーにパスワードの変更を要求する場合など)、適切なメソッドの署名を変更するだけで、テストのコンパイルが失敗することを意味します。言い換えれば、ページ間の関係を変更し、それをページオブジェクトに反映させるときに、実行しなくてもどのテストが失敗するかを伝えることができます。

このアプローチの 1 つの結果は、(たとえば)ログインの成功と失敗の両方をモデル化する必要がある場合があることです。または、クリックはアプリの状態に応じて異なる結果になる可能性があります。このような場合、ページオブジェクトに複数のメソッドを持つのが一般的です。

public class LoginPage {
    public HomePage loginAs(String username, String password) {
        // ... clever magic happens here
    }
    
    public LoginPage loginAsExpectingError(String username, String password) {
        //  ... failed login here, maybe because one or both of the username and password are wrong
    }
    
    public String getErrorMessage() {
        // So we can verify that the correct error is shown
    }
}

上記のコードは、重要な点を示しています。ページオブジェクトではなく、テストがページのステートに関するアサーションを行う責任を負う必要があります。たとえば

public void testMessagesAreReadOrUnread() {
    Inbox inbox = new Inbox(driver);
    inbox.assertMessageWithSubjectIsUnread("I like cheese");
    inbox.assertMessageWithSubjectIsNotUnread("I'm not fond of tofu");
}

は、次のように書き換えることができます

public void testMessagesAreReadOrUnread() {
    Inbox inbox = new Inbox(driver);
    assertTrue(inbox.isMessageWithSubjectIsUnread("I like cheese"));
    assertFalse(inbox.isMessageWithSubjectIsUnread("I'm not fond of tofu"));
}

もちろん、すべてのガイドラインと同様に、例外があり、ページオブジェクトで一般的に見られる例外の 1 つは、ページオブジェクトをインスタンス化するときに WebDriver が正しいページにあることを確認することです。これは、以下の例で行われています。

最後に、ページオブジェクトは、ページ全体を表す必要はありません。サイトナビゲーションなど、サイトまたはページ内で頻繁に表示されるセクションを表す場合があります。重要な原則は、特定の(ページの一部)ページの HTML の構造に関する知識を持つ場所がテストスイートに 1 か所しかないことです。

まとめ

  • パブリックメソッドは、ページが提供するサービスを表します
  • ページの内部構造を公開しないようにしてください
  • 一般的にアサーションを行わないでください
  • メソッドは他のページオブジェクトを返します
  • ページ全体を表す必要はありません
  • 同じアクションに対する異なる結果は、異なるメソッドとしてモデル化されます

public class LoginPage {
    private final WebDriver driver;

    public LoginPage(WebDriver driver) {
        this.driver = driver;

        // Check that we're on the right page.
        if (!"Login".equals(driver.getTitle())) {
            // Alternatively, we could navigate to the login page, perhaps logging out first
            throw new IllegalStateException("This is not the login page");
        }
    }

    // The login page contains several HTML elements that will be represented as WebElements.
    // The locators for these elements should only be defined once.
        By usernameLocator = By.id("username");
        By passwordLocator = By.id("passwd");
        By loginButtonLocator = By.id("login");

    // The login page allows the user to type their username into the username field
    public LoginPage typeUsername(String username) {
        // This is the only place that "knows" how to enter a username
        driver.findElement(usernameLocator).sendKeys(username);

        // Return the current page object as this action doesn't navigate to a page represented by another PageObject
        return this;	
    }

    // The login page allows the user to type their password into the password field
    public LoginPage typePassword(String password) {
        // This is the only place that "knows" how to enter a password
        driver.findElement(passwordLocator).sendKeys(password);

        // Return the current page object as this action doesn't navigate to a page represented by another PageObject
        return this;	
    }

    // The login page allows the user to submit the login form
    public HomePage submitLogin() {
        // This is the only place that submits the login form and expects the destination to be the home page.
        // A seperate method should be created for the instance of clicking login whilst expecting a login failure. 
        driver.findElement(loginButtonLocator).submit();

        // Return a new page object representing the destination. Should the login page ever
        // go somewhere else (for example, a legal disclaimer) then changing the method signature
        // for this method will mean that all tests that rely on this behaviour won't compile.
        return new HomePage(driver);	
    }

    // The login page allows the user to submit the login form knowing that an invalid username and / or password were entered
    public LoginPage submitLoginExpectingFailure() {
        // This is the only place that submits the login form and expects the destination to be the login page due to login failure.
        driver.findElement(loginButtonLocator).submit();

        // Return a new page object representing the destination. Should the user ever be navigated to the home page after submiting a login with credentials 
        // expected to fail login, the script will fail when it attempts to instantiate the LoginPage PageObject.
        return new LoginPage(driver);	
    }

    // Conceptually, the login page offers the user the service of being able to "log into"
    // the application using a user name and password. 
    public HomePage loginAs(String username, String password) {
        // The PageObject methods that enter username, password & submit login have already defined and should not be repeated here.
        typeUsername(username);
        typePassword(password);
        return submitLogin();
    }
}