頁面物件模型

注意:此頁面已合併來自多個來源的內容,包括 Selenium wiki

概觀

在您的 Web 應用程式 UI 中,有些區域是您的測試會互動的。頁面物件僅將這些區域建模為測試程式碼中的物件。這減少了重複程式碼的數量,並意味著如果 UI 變更,則只需在一個地方應用修復程式即可。

頁面物件是一種設計模式,在測試自動化中已變得流行,用於增強測試維護並減少程式碼重複。頁面物件是一個物件導向的類別,作為您的 AUT 頁面的介面。然後,測試會在需要與該頁面的 UI 互動時,使用此頁面物件類別的方法。好處是,如果頁面的 UI 變更,則測試本身不需要變更,只需要變更頁面物件內的程式碼。隨後,所有支援新 UI 的變更都位於同一個地方。

優點

  • 測試程式碼和頁面特定程式碼之間有明確的分隔,例如定位器(或如果您使用 UI Map 的話,則為其用法)和版面配置。
  • 頁面提供的服務或操作有一個單一的儲存庫,而不是將這些服務分散在整個測試中。

在這兩種情況下,這都允許將 UI 變更所需的所有修改都集中在一個地方進行。關於此技術的實用資訊可以在眾多部落格中找到,因為這種「測試設計模式」正變得越來越普及。我們鼓勵希望了解更多資訊的讀者在網路上搜尋關於此主題的部落格。許多人已經撰寫了關於此設計模式的文章,並且可以提供超出本使用者指南範圍的實用技巧。為了幫助您入門,我們將用一個簡單的範例來說明頁面物件。

範例

首先,考慮一個不使用頁面物件的範例,這是測試自動化中常見的。

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

這種方法有兩個問題。

  • 測試方法和 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 */
}

因此,現在登入測試將會像這樣使用這兩個頁面物件。

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

}

頁面物件的設計方式有很多彈性,但是有一些基本規則可以獲得您測試程式碼所需的維護性。

頁面物件中的斷言

頁面物件本身永遠不應進行驗證或斷言。這是您測試的一部分,並且應始終位於測試的程式碼中,而不是在頁面物件中。頁面物件將包含頁面的表示形式,以及頁面透過方法提供的服務,但是與正在測試的內容相關的程式碼不應位於頁面物件中。

有一個單一的驗證可以而且應該位於頁面物件中,那就是驗證頁面以及頁面上可能存在的關鍵元素是否已正確載入。此驗證應在實例化頁面物件時完成。在上面的範例中,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>

「產品」頁面 HAS-A 產品列表。這種物件關係稱為組合 (Composition)。簡而言之,某事物是由另一事物組成的。

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 中的頁面有多個組件,或整個網站中使用的通用組件(例如,導覽列),則它可以提高可維護性並減少程式碼重複。

測試中使用的其他設計模式

還有其他設計模式也可能用於測試中。討論所有這些都超出了本使用者指南的範圍。在這裡,我們僅想介紹這些概念,以使讀者了解可以完成的一些事情。正如先前所提及的,許多人已經在部落格上撰寫了關於此主題的文章,我們鼓勵讀者搜尋關於這些主題的部落格。

實作注意事項

可以將頁面物件視為同時朝兩個方向。面向測試開發人員時,它們代表特定頁面提供的服務。背對開發人員時,它們應該是唯一深入了解頁面(或頁面的一部分)HTML 結構的東西。最簡單的想法是將頁面物件上的方法視為提供頁面提供的「服務」,而不是公開頁面的詳細資訊和機制。舉例來說,想想任何基於網路的電子郵件系統的收件匣。它提供的服務包括撰寫新電子郵件、選擇閱讀單一電子郵件以及列出收件匣中電子郵件的主旨行。這些服務如何實作不應影響測試。

因為我們鼓勵測試開發人員嘗試思考他們互動的服務,而不是實作,所以頁面物件應盡量不要公開底層的 WebDriver 實例。為了方便起見,頁面物件上的方法應傳回其他頁面物件。這表示我們可以有效地建模使用者在應用程式中的歷程。這也表示,如果頁面彼此關聯的方式發生變更(例如,當登入頁面要求使用者在首次登入服務時變更密碼,而以前沒有這樣做時),只需變更適當的方法簽名就會導致測試編譯失敗。換句話說;當我們變更頁面之間的關係並在頁面物件中反映這一點時,我們可以知道哪些測試會失敗,而無需執行它們。

這種方法的一個後果是,可能需要建模(例如)成功和不成功的登入;或者點擊可能會根據應用程式的狀態產生不同的結果。當這種情況發生時,通常在頁面物件上有多個方法。

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

當然,與每個指南一樣,也有例外情況,並且在頁面物件中常見的一種情況是在實例化頁面物件時檢查 WebDriver 是否在正確的頁面上。這在下面的範例中完成。

最後,頁面物件不一定需要表示整個頁面。它可以表示網站或頁面中經常出現的部分,例如網站導覽。基本原則是,在您的測試套件中,只有一個地方了解特定(部分)頁面的 HTML 結構。

摘要

  • 公開方法代表頁面提供的服務。
  • 盡量不要公開頁面的內部機制。
  • 通常不要進行斷言。
  • 方法傳回其他頁面物件。
  • 不需要表示整個頁面。
  • 同一動作的不同結果被建模為不同的方法。

範例

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();
    }
}
最後修改於 2024 年 7 月 9 日:修正英文文法 (#1803)[部署網站] (67cf1f4fcda)