当我们开发 UI 自动化测试用例时,需要引用页面中的元素(数据)才能够进行点击(动作)并显示出页面内容。如果我们开发的用例是直接对 HTML 元素进行操作,则这样的用例无法 “应对” 页面中 UI 的更改。

PageObject 模式就是对 HTML 页面以及元素细节的封装,并对外提供应用级别的 API,使你摆脱与 HTML 的纠缠。

什么是 PageObject 模型?

PageObject 模型是一种设计模式,其核心是减少代码重复(最小化代码更新/维护用例)以降低用例开发的工作量。利用 PageObject 模型,为每个网页创建 Page 类,测试场景中用的定位器/元素存储在单独的类文件中,并且测试用例在不同的文件中,使代码更加模块化。由于元素定位器和测试脚本是分开存储的,因此对 Web UI 元素的任何更改只需要在测试场景代码中进行更改即可。

基于 PageObject 模型的实现包含以下两点:

Page 类——将页面封装成 Page 类,页面元素为 Page 类的成员元素,页面功能放在 Page 类方法里。

测试类——针对这个 Page 类定义一个测试类,在测试类调用 Page 类的各个类方法完成测试。它使用 Page 类中的页面方法/方法与页面的 UI 元素进行交互。如果网页的 UI 有变化,只需要更新 Page 类,测试类无需改动。

为什么使用 PageObject 模型?

随着项目新需求的不断迭代,开发代码和测试代码的复杂性增加。因此,开发自动化测试代码时必须遵循正确的项目结构。否则,代码可能会变得难以维护。

Web 由各种 WebElement(例如,菜单项、文本框、复选框、单选按钮等)的不同网页组成。测试用例与这些元素交互,如果 Selenium 定位器没有以正确的方式管理,代码的复杂性将成倍增加。

测试代码的重复或定位器的重复使用会降低代码的可读性,从而导致代码维护的开销成本增加。例如,测试电子商务网站的登录功能,我们使用 Selenium 进行自动化测试,测试代码可以与网页的底层 UI 或定位器进行交互。如果修改了 UI 或该页面上元素的路径发生了变化,会发生什么情况?自动化测试用例将失败,因为该用例执行的过程在网页上找不到依赖的页面元素。如果你对所有网页采用相同的测试开发方法。在这种情况下,测试者必须花费大量精力来即时更新分散在不同页面中的定位器。

PO 模式优点

PageObject 模型的优点
现在大家已经了解了 PageObject 设计模式的基础知识,让我们来看看使用该设计模式的一些优点:

提高可重用性——不同 POM 类中的 PageObject 方法可以在不同的测试用例/测试套件中重用。因此,由于页面方法的可重用性增加,整体代码量将大大减少。

提升可维护性——由于测试场景和定位器是分开存储的,它使代码更清晰,并且在维护测试代码上花费的精力更少。

降低 UI 更改对用例造成的影响——即使 UI 中经常发生更改,也只需要在对象存储库(存储定位器)中进行更改,对测试场景几乎没有影响。

便与多个测试框架集成——由于测试实现与 PageObject 的存储库分离,我们可以将相同的存储库与不同的测试框架一起使用。例如,Test Case-1 可以使用 Robot 框架,Tese Case - 2 可以使用 pytest 框架等,单个测试套件可以包含使用不同测试框架实现的测试用例。

PageObject 实践

首先我们先看一个反例,一个不使用 PageObject 模式的自动化测试示例(测试用户登录场景):

/***
* 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 的定位器没有分离,两者耦合在一起。如果 AUT 的 UI 更改布局或登录的输入和处理方式,则用例本身必须更改。

如果多个页面都需要登录,则定位器将分布在多个测试用例中。

使用 PageObject 模式,测试方法(登录)写法如下:

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

  /**
    * 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 */
}

登录测试用例使用上述两个 PageObject,如下所示。

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

  @Test
  public void testLogin() {
    SignInPage signInPage = new SignInPage(driver);
    /// login  
    HomePage homePage = signInPage.loginValidUser("userName", "password");
     // assert login result
    assertThat(homePage.getMessageText(), is("Hello userName"));
  }

}

注意事项

从上述例子中,可以看出 PageObject 的设计方式有很大的灵活性,这里也总结一下使用 PageObject 开发用例的注意事项:

PageObject 本身不进行断言。断言是测试用例的一部分,应该始终包含在测试代码中,即与测试内容相关的代码不应包含在 PageObject 中。

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

单一的验证可以包含在 PageObject 内,即验证页面以及页面上的关键元素是否正确加载,且此验证应在实例化 PageObject 时完成。在上面的示例中, HomePage 构造函数检查预期页面是否加载完毕以执行测试代码。

附:以 PageObject 模式开发的完整的登录场景代码

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

荐书

本书致力于帮助 Python 开发人员挖掘这门语言及相关程序库的优秀特性,避免重复劳动,同时写出简洁、流畅、易读、易维护,并且具有地道 Python 风格的代码。本书尤其深入探讨了 Python 语言的高级用法,涵盖数据结构、Python 风格的对象、并行与并发,以及元编程等不同的方面。


↙↙↙阅读原文可查看相关链接,并与作者交流