页面对象模式 POM 深度实战——可维护 E2E 测试的架构设计
页面对象模式 POM 深度实战——可维护 E2E 测试的架构设计
适读人群:希望提升 E2E 测试可维护性的工程师 | 阅读时长:约 17 分钟 | 核心价值:掌握 POM 的进阶设计技巧,构建经得起长期维护的 E2E 测试架构
没有 POM 的测试代码,三个月后是什么样
小林加入我们团队时,之前的 E2E 测试代码库已经运行了半年,没有人用 POM。
他接手的时候描述说:像在处理一锅意大利面。
登录代码写在每个测试文件里,selector 字符串散落在几十个文件中,有的用 #login-btn,有的用 button[type='submit'],有的用 //button[@id='login-btn'],明明是同一个按钮。当登录页面改版,把邮箱输入框的 ID 从 #email 改成 #user-email 时,需要在 43 个文件里做替换。
这就是没有 POM 的代价。
今天我来写 POM 的深度实战,不只是基础用法,而是在大型项目中经过验证的架构设计。
POM 的核心原则
在深入代码之前,先把 POM 的设计原则说清楚:
原则一:每个 Page Object 对应一个页面或独立的页面区域
LoginPage、DashboardPage、CheckoutPage- 也可以是页面的一部分:
NavigationBar、ProductCard、DatePicker
原则二:Page Object 封装 UI 交互,暴露业务方法
- 不暴露
Locator对象给外部 - 方法名是业务动词:
loginAs()、addToCart()、placeOrder()
原则三:Page Object 方法返回值是导航目标 Page Object
- 点击"登录"按钮成功后返回
DashboardPage - 点击"添加到购物车"后返回
ProductPage(留在原页面)或CartPage(跳转)
原则四:断言尽量放在测试代码里,Page Object 负责操作
- Page Object 可以有验证方法(如
verifyLoaded()),但测试代码里做具体业务断言更清晰
基础架构:BasePage
package com.example.pages;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Locator;
import com.microsoft.playwright.options.LoadState;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
/**
* 所有 Page Object 的基类
* 封装通用功能:等待、截图、页面标题断言等
*/
public abstract class BasePage {
protected final Page page;
protected final String baseUrl;
public BasePage(Page page, String baseUrl) {
this.page = page;
this.baseUrl = baseUrl;
}
/**
* 等待页面加载完成(等待 DOM 稳定,不等网络空闲)
*/
protected BasePage waitForPageLoad() {
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
return this;
}
/**
* 等待特定 Loading 指示器消失
*/
protected BasePage waitForLoadingToDisappear() {
Locator loadingSpinner = page.locator(".global-loading");
if (loadingSpinner.isVisible()) {
loadingSpinner.waitFor(
new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.HIDDEN)
.setTimeout(30000)
);
}
return this;
}
/**
* 等待 Toast 通知出现并消失
*/
protected BasePage waitForToastDisappear() {
Locator toast = page.locator(".toast-notification");
try {
toast.waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.VISIBLE)
.setTimeout(5000));
toast.waitFor(new Locator.WaitForOptions()
.setState(com.microsoft.playwright.options.WaitForSelectorState.HIDDEN)
.setTimeout(5000));
} catch (Exception e) {
// Toast 可能出现得很快就消失了,忽略超时
}
return this;
}
/**
* 获取当前页面 URL
*/
public String getCurrentUrl() {
return page.url();
}
/**
* 截图(用于调试)
*/
public byte[] screenshot() {
return page.screenshot(new Page.ScreenshotOptions().setFullPage(true));
}
/**
* 子类实现:验证页面已正确加载
*/
public abstract BasePage verifyLoaded();
}组件化设计:可复用的页面组件
复杂的页面通常包含可复用的 UI 组件,比如日期选择器、下拉菜单、数据表格。把这些组件封装成独立的对象,可以在多个 Page Object 中复用。
导航栏组件
package com.example.pages.components;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Locator;
import com.example.pages.*;
/**
* 顶部导航栏组件——在多个页面中复用
*/
public class NavigationBar {
private final Page page;
private final Locator logo;
private final Locator searchInput;
private final Locator cartIcon;
private final Locator userMenu;
public NavigationBar(Page page) {
this.page = page;
this.logo = page.locator("header .logo");
this.searchInput = page.locator("header input.search");
this.cartIcon = page.locator("header .cart-icon");
this.userMenu = page.locator("header .user-menu");
}
public SearchResultPage search(String keyword) {
searchInput.fill(keyword);
searchInput.press("Enter");
return new SearchResultPage(page);
}
public CartPage openCart() {
cartIcon.click();
return new CartPage(page);
}
public String getCartItemCount() {
return page.locator("header .cart-badge").textContent();
}
public LoginPage logout() {
userMenu.click();
page.locator(".dropdown-menu [data-action='logout']").click();
return new LoginPage(page);
}
public ProfilePage goToProfile() {
userMenu.click();
page.locator(".dropdown-menu [data-action='profile']").click();
return new ProfilePage(page);
}
}数据表格组件
package com.example.pages.components;
import com.microsoft.playwright.Locator;
import java.util.List;
import java.util.ArrayList;
/**
* 通用数据表格组件
*/
public class DataTable {
private final Locator tableLocator;
public DataTable(Locator tableLocator) {
this.tableLocator = tableLocator;
}
/**
* 获取总行数
*/
public int getRowCount() {
return tableLocator.locator("tbody tr").count();
}
/**
* 获取特定行的列值
* @param rowIndex 从 0 开始
* @param columnIndex 从 0 开始
*/
public String getCellText(int rowIndex, int columnIndex) {
return tableLocator
.locator("tbody tr").nth(rowIndex)
.locator("td").nth(columnIndex)
.textContent().trim();
}
/**
* 找到包含特定文本的行并点击操作按钮
*/
public void clickActionOnRow(String rowIdentifier, String actionName) {
tableLocator.locator("tbody tr")
.filter(new Locator.FilterOptions().setHasText(rowIdentifier))
.first()
.getByRole(com.microsoft.playwright.options.AriaRole.BUTTON,
new Locator.GetByRoleOptions().setName(actionName))
.click();
}
/**
* 获取所有行的某列数据
*/
public List<String> getColumnValues(int columnIndex) {
List<String> values = new ArrayList<>();
int rowCount = getRowCount();
for (int i = 0; i < rowCount; i++) {
values.add(getCellText(i, columnIndex));
}
return values;
}
/**
* 验证表格按某列排序(升序)
*/
public boolean isColumnSortedAscending(int columnIndex) {
List<String> values = getColumnValues(columnIndex);
for (int i = 0; i < values.size() - 1; i++) {
if (values.get(i).compareTo(values.get(i + 1)) > 0) {
return false;
}
}
return true;
}
}在 Page Object 中使用组件
public class OrderListPage extends BasePage {
private final NavigationBar navigationBar;
private final DataTable orderTable;
private final Locator pageTitle;
public OrderListPage(Page page, String baseUrl) {
super(page, baseUrl);
this.navigationBar = new NavigationBar(page);
this.orderTable = new DataTable(page.locator("table.order-list"));
this.pageTitle = page.locator("h1.page-title");
}
@Override
public OrderListPage verifyLoaded() {
assertThat(pageTitle).hasText("我的订单");
return this;
}
public NavigationBar nav() {
return navigationBar;
}
public int getOrderCount() {
return orderTable.getRowCount();
}
public OrderDetailPage openOrderDetail(String orderId) {
orderTable.clickActionOnRow(orderId, "查看详情");
return new OrderDetailPage(page, baseUrl);
}
public OrderListPage verifyOrderInList(String orderId) {
assertThat(page.locator("table.order-list tbody"))
.containsText(orderId);
return this;
}
}流式 API 设计(Fluent Interface)
流式 API 让测试代码读起来像英语句子:
// 流式 API 的测试代码
@Test
void shouldCompleteFullShoppingFlow() {
new LoginPage(page, BASE_URL)
.loginAs("buyer@test.com", "Password123") // 登录
.verifyLoaded() // 验证首页已加载
.nav().search("蓝牙耳机") // 搜索商品
.verifyResultsDisplayed() // 验证搜索结果
.clickFirstResult() // 点击第一个结果
.verifyProductLoaded() // 验证商品页加载
.selectVariant("颜色", "黑色") // 选择规格
.selectVariant("容量", "32G")
.setQuantity(2) // 设置数量
.addToCart() // 加入购物车
.nav().openCart() // 打开购物车
.verifyItemCount(2) // 验证购物车商品数
.proceedToCheckout() // 去结算
.fillShippingAddress("张三", "13812345678", "北京市朝阳区...")
.selectPayment("微信支付")
.placeOrder() // 下单
.verifyOrderCreated() // 验证订单创建成功
.verifyOrderStatusIs("待付款");
}这段测试代码,即使不懂技术的产品经理也能看懂,这就是 POM + 流式 API 的价值。
高级技巧:Factory 模式创建 Page Object
当测试需要根据环境或条件使用不同实现时,Factory 模式很有用:
package com.example.pages;
/**
* Page Object 工厂——统一创建入口,方便切换实现
*/
public class PageFactory {
private final com.microsoft.playwright.Page playwrightPage;
private final String baseUrl;
public PageFactory(com.microsoft.playwright.Page page, String baseUrl) {
this.playwrightPage = page;
this.baseUrl = baseUrl;
}
public LoginPage loginPage() {
return new LoginPage(playwrightPage, baseUrl);
}
public DashboardPage dashboardPage() {
return new DashboardPage(playwrightPage, baseUrl);
}
public ProductListPage productListPage() {
return new ProductListPage(playwrightPage, baseUrl);
}
// 以登录状态直接进入特定页面(跳过 UI 登录流程,更快)
public DashboardPage loginAndGetDashboard(String email, String password) {
// 通过 API 获取 token,然后设置 Cookie,绕过 UI 登录
String token = AuthAPI.getToken(email, password);
playwrightPage.navigate(baseUrl);
playwrightPage.evaluate(
"token => localStorage.setItem('auth_token', token)",
token
);
playwrightPage.navigate(baseUrl + "/dashboard");
return dashboardPage().verifyLoaded();
}
}在测试中使用:
public class CheckoutTest extends BaseTest {
private PageFactory pages;
@BeforeEach
void setUp() {
super.setUp();
pages = new PageFactory(page, BASE_URL);
}
@Test
void shouldCalculateShippingCostCorrectly() {
// 使用 API 登录,不走 UI 登录流程(更快)
pages.loginAndGetDashboard("buyer@test.com", "Password123")
.nav().search("重型包裹")
.clickFirstResult()
.addToCart()
.nav().openCart()
.proceedToCheckout()
.fillShippingAddress("张三", "13812345678", "广州市天河区...")
.verifyShippingCostIsNotZero();
}
}踩坑实录
坑一:Page Object 的 Locator 字段初始化顺序问题
现象: Page Object 构造函数中初始化 Locator,但有时候报空指针异常。
原因: 在父类构造函数中调用了可以被子类重写的方法,子类字段还没初始化。
// 错误:
public abstract class BasePage {
public BasePage(Page page) {
this.page = page;
verifyLoaded(); // 在构造函数中调用抽象方法——危险!
}
public abstract void verifyLoaded();
}
public class LoginPage extends BasePage {
private final Locator title; // 还没初始化
public LoginPage(Page page) {
super(page); // super() 调用 verifyLoaded(),但 title 还是 null
this.title = page.locator("h1");
}
@Override
public void verifyLoaded() {
assertThat(title).isVisible(); // NPE!
}
}解法: 不在构造函数中调用 verifyLoaded(),改为由测试代码显式调用:
public abstract class BasePage {
public BasePage(Page page, String baseUrl) {
this.page = page;
this.baseUrl = baseUrl;
// 不在构造函数里调用任何可被重写的方法
}
}坑二:组件对象持有过期的 Locator
现象: 创建 DataTable 组件对象后,页面刷新,再操作 DataTable 时报错。
原因: 虽然 Locator 是惰性的,但如果把 DataTable 构造函数的 tableLocator 参数是通过 page.locator() 传进来的 Locator 对象,那没问题;但如果传的是 ElementHandle,就会持有旧 DOM 引用。
解法: 组件的构造函数接收 Page 和选择器字符串,而不是 Locator 对象:
// 推荐方式
public class DataTable {
private final Page page;
private final String tableSelector;
public DataTable(Page page, String tableSelector) {
this.page = page;
this.tableSelector = tableSelector;
}
// 每次操作时动态查找
public int getRowCount() {
return page.locator(tableSelector + " tbody tr").count();
}
}坑三:流式 API 中异常信息丢失
现象: 流式调用中某一步断言失败,但报错信息只显示最外层方法名,看不出是哪一步失败了。
解法: 在关键步骤加上 Allure.step() 或者自定义步骤日志:
public DashboardPage loginAs(String email, String password) {
System.out.println("[LoginPage] 尝试登录: " + email);
navigate();
enterUsername(email);
enterPassword(password);
DashboardPage result = clickLoginButton();
System.out.println("[LoginPage] 登录成功,跳转到首页");
return result;
}目录结构规范
成熟的 POM 目录结构:
src/test/java/
├── base/
│ └── BaseTest.java
├── factory/
│ └── PageFactory.java
├── pages/
│ ├── BasePage.java
│ ├── LoginPage.java
│ ├── DashboardPage.java
│ ├── ProductListPage.java
│ ├── ProductDetailPage.java
│ ├── CartPage.java
│ ├── CheckoutPage.java
│ ├── OrderListPage.java
│ ├── OrderDetailPage.java
│ └── components/
│ ├── NavigationBar.java
│ ├── DataTable.java
│ ├── DatePicker.java
│ ├── Pagination.java
│ └── Modal.java
├── tests/
│ ├── LoginTest.java
│ ├── SearchTest.java
│ ├── CartTest.java
│ └── CheckoutTest.java
└── utils/
├── TestDataAPI.java
└── AuthHelper.java小结
POM 的价值不只是"代码复用",更是让测试代码和业务逻辑对齐。产品经理写的 PRD 和你的测试代码,应该能互相对应。
核心设计要点:
BasePage封装通用等待和工具方法- 组件化处理可复用 UI 模块(导航栏、表格、日期选择器)
- 方法返回值是导航目标,支持流式调用
Factory统一创建入口,支持 API 登录等快速前提条件设置
做好 POM,你的 E2E 测试能用 3 年不重构。
