目录
概述
优点
主页的页面对象可能是这样的。
页面对象中的断言
页面组件对象
Product组件对象用于Products页面对象内部。
因此,现在,产品测试将使用页面对象和页面组件对象,如下所示。
实施说明
可以重写为:
总结
实例
在web应用程序的UI中,有一些区域可以与测试交互。页面对象仅将这些对象建模为测试代码中的对象。这减少了重复代码的数量,意味着如果UI发生更改,则只需在一个位置应用修复。
页面对象是一种在测试自动化中流行的设计模式,用于增强测试维护和减少代码重复。页面对象是一个面向对象的类,用作AUT页面的接口。然后,每当需要与该页面的UI交互时,测试就会使用该页面对象类的方法。好处是,如果页面的UI发生了更改,则测试本身不需要更改,只需要更改页面对象中的代码。随后,所有支持新UI的更改都位于同一位置。
测试代码和特定于页面的代码之间有一个清晰的分离,例如定位器(或者如果您使用的是UI映射,则使用它们)和布局。
在这两种情况下,这都允许在一个地方进行由于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"));
}
}
这种方法有两个问题。
应用页面对象技术,在下面的登录页面的页面对象示例中,可以像这样重写此示例。
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
/**
* Page Object encapsulates the Sign-in page.
*/
public class SignInPage {
protected WebDriver driver;
//
private By usernameBy = By.name("user_name");
//
private By passwordBy = By.name("password");
//
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;
// Hello userName
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构造函数都会检查期望的页面是否可用,并准备好接受来自测试的请求。
页面对象不一定需要表示页面本身的所有部分。马丁·福勒在早期就注意到了这一点,当时他首次创造了“面板对象”一词。
用于页面对象的相同原理可以用于创建“页面组件对象”,如后来所称,这些对象表示页面的离散块,并且可以包含在页面对象中。这些组件对象可以提供对这些离散块中的元素的引用,以及利用它们提供的功能或行为的方法。
例如,“产品”页面包含多个产品。
Products
每个产品都是“产品”页面的一个组件。
Backpack
产品页面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 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 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中的页面有多个组件,或者在整个站点中使用通用组件(例如导航栏),则可以提高可维护性并减少代码重复。
PageObjects可以被认为同时面向两个方向。面向测试的开发人员,它们表示特定页面提供的服务。远离开发人员,他们应该是唯一对页面(或页面的一部分)的HTML结构有深入了解的人。最简单的做法是将页面对象上的方法视为提供页面提供的“服务”,而不是公开页面的细节和机制。举个例子,想想任何基于网络的电子邮件系统的收件箱。它提供的服务包括撰写新电子邮件、选择阅读单个电子邮件以及在收件箱中列出电子邮件的主题行。如何实现这些对测试来说并不重要。
因为我们鼓励测试的开发人员尝试并思考他们正在交互的服务,而不是实现,所以PageObjects很少应该公开底层的WebDriver实例。为了便于实现这一点,PageObject上的方法应该返回其他PageObject。这意味着我们可以通过我们的应用程序有效地为用户的旅程建模。这也意味着,如果页面之间的关联方式发生变化(比如登录页面在用户第一次登录服务时要求用户更改密码,而之前用户没有这样做),简单地更改相应方法的签名就会导致测试无法编译。换言之;当我们改变页面之间的关系并将其反映在PageObjects中时,我们可以判断哪些测试将失败,而无需运行它们。
这种方法的一个后果是,可能需要对成功登录和不成功登录进行建模(例如);或者,根据应用程序的状态,点击可能会产生不同的结果。当这种情况发生时,PageObject上通常有多个方法:
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
}
}
上面给出的代码显示了一个重要的观点:测试而不是PageObjects应该负责对页面的状态进行断言。例如:
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时,检查WebDriver是否在正确的页面上。这在下面的示例中完成。
最后,PageObject不需要表示整个页面。它可能表示网站或页面中频繁出现的部分,例如网站导航。基本原理是,在测试套件中只有一个地方了解特定页面(部分页面)的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();
}
}