页面对象模型
注意:此页面合并了来自多个来源的内容,包括 Selenium wiki
概述
在您的 Web 应用程序的用户界面中,存在您的测试与之交互的区域。页面对象仅在测试代码中将这些区域建模为对象。这减少了重复代码的数量,并且意味着如果用户界面发生更改,则只需在一个地方应用修复即可。
页面对象是一种设计模式,它在测试自动化中变得流行,用于增强测试维护并减少代码重复。页面对象是一个面向对象的类,它充当您的 AUT 页面的接口。然后,测试在需要与该页面的用户界面交互时,使用此页面对象类的方法。好处是,如果页面的用户界面发生更改,则测试本身不需要更改,只需更改页面对象中的代码即可。随后,所有支持新用户界面的更改都位于一个位置。
优点
- 测试代码和页面特定代码(如定位器(如果您正在使用 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>
产品页面有一个产品列表。这种对象关系称为组合。简单来说,某事物是由另一事物组成的。
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 中的页面有多个组件,或者整个站点使用通用组件(例如,导航栏),则可以提高可维护性并减少代码重复。
测试中使用的其他设计模式
在测试中还可以使用其他设计模式。讨论所有这些内容超出了本用户指南的范围。在这里,我们只想介绍这些概念,以使读者了解可以做的一些事情。如前所述,许多人都在博客上写过关于此主题的文章,我们鼓励读者搜索关于这些主题的博客。
实施说明
可以认为 PageObject 同时面向两个方向。面向测试的开发者,它们代表特定页面提供的服务。背对开发者,它们应该是唯一深入了解页面(或页面的一部分)HTML 结构的对象。将页面对象的方法视为提供页面提供的“服务”,而不是暴露页面的详细信息和机制,是最简单的。例如,考虑任何基于 Web 的电子邮件系统的收件箱。它提供的服务包括编写新电子邮件、选择阅读一封电子邮件以及列出收件箱中电子邮件的主题行。这些如何实现对测试并不重要。
因为我们鼓励测试的开发者尝试思考他们正在与之交互的服务,而不是实现,所以 PageObject 很少会暴露底层的 WebDriver 实例。为了促进这一点,PageObject 的方法应该返回其他 PageObject。这意味着我们可以有效地为用户在应用程序中的旅程建模。这也意味着,如果页面相互关联的方式发生变化(例如,当登录页面要求用户首次登录服务时更改其密码,而之前没有这样做时),只需更改适当的方法签名就会导致测试编译失败。换句话说;当我们在页面之间更改关系并在 PageObject 中反映这一点时,我们可以知道哪些测试会失败,而无需运行它们。
这种方法的一个结果是,可能需要(例如)对成功和不成功的登录进行建模;或者点击可能会根据应用程序的状态产生不同的结果。当发生这种情况时,通常在 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
}
}
上面提供的代码显示了一个重点:测试,而不是 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 时,检查 WebDriver 是否位于正确的页面上。这在下面的示例中完成。
最后,PageObject 不需要表示整个页面。它可以表示站点或页面中经常出现的部分,例如站点导航。基本原则是,在您的测试套件中,只有一个地方知道特定(部分)页面的 HTML 结构。
总结
- 公共方法代表页面提供的服务。
- 尽量不要暴露页面的内部结构。
- 通常不要进行断言。
- 方法返回其他的页面对象(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();
}
}