Skip to content

Page Object 与框架封装 ​

T2-010必学高级约 90 分钟JDK 21测试专项 B:接口·UI·性能 · UI 自动化

1. 本节定位 ​

《Selenium 元素定位与显式等待》解决了"怎么稳定地找到元素";本节解决**"代码怎么组织"**:

text
10 个用例、每个用例写 8 个 By.xxx  → 页面一改版,要改 80 处     ✗
把定位器收进一个类                → 页面改版,只改 1 个文件      ✓

Page Object(PO)就是那个类 ✓ ——但它也是最容易被做成"假 PO"的模式 ✗

2. 学习目标 ​

  • 说出 PO 的三件事(收定位器 / 封等待 / 露业务语义)★★★;
  • 能识别"只包了 findElement 的假 PO",并知道它的危害 ★★★(含本轮真实案例);
  • 知道 PO 该与不该放什么(尤其"PO 不做断言")★★★;
  • 知道 PO 的返回类型选择(this vs 下一个页面)★★;
  • 知道 PO 的边界与演进方向(组件对象、Screenplay)★。

3. 前置知识检查 ​

  • 完成 T2-009 的定位与等待实测了吗?✓
  • 理解"方法的封装"与"职责单一"吗?★★

4. 为什么需要这个知识 ​

先看"不用 PO"在页面改版时的代价 ✗:

text
页面把 id="page-title" 改成了 id="page-heading"
  → 所有写了 By.id("page-title") 的测试类都要改
  → 本项目的 OrderPageObjectTest 里,直写版写了几处? 8 处
  → 如果有 20 个测试类,就要改 160 处         ✗✗

而用 PO ✓:

text
只改 OrderPage 里的一个常量:
  private static final By TITLE = By.id("page-title");
  → 改成 By.id("page-heading")
  → 测试代码改动量:0 行                        ✓✓

★ 但这只是 PO 的"第一层价值" ——下面这条更重要 ✓✓:

PO 的定位器是"静态的",而 UI 是"动态的" ✓

页面上的元素会"晚出现"、"消失"、"被替换" ——这些时序问题必须有人负责 ✓

如果 PO 不负责,它就会把问题"转嫁"给调用方 ✗✗(本节 5.2 的真实案例)✓

5. 核心概念 ​

5.1 PO 的三件事 ★★★ ​

#职责反例
① 收定位器 ★★所有 By.xxx 集中在一处测试里散落 By.id(...) ✗
② 封等待 ★★★把"等什么条件"封在动作里让调用方自己 new WebDriverWait ✗✗
③ 露业务语义 ★★★方法名说业务(createOrder),不说 DOM(clickButton)clickSubmitButton() ✗

★ 用一句话概括:

调用方只该知道"我要做什么业务",不该知道"页面上有什么元素、要等什么条件" ✓✓

本项目的 OrderPage 就按这三件事写 ✓:

java
public class OrderPage {
    // ① 定位器集中在这里:页面结构变了,只改这一段
    private static final By TITLE = By.id("page-title");
    private static final By ORDER_ROWS = By.cssSelector("tr.order-row");
    private static final By PRODUCT_INPUT = By.id("product");
    private static final By SUBMIT = By.cssSelector("[data-testid='create-submit']");
    ...

    // ② 等待封在动作里:调用方不必知道"要先等标题出现"
    public OrderPage open() {
        driver.get(baseUrl + "/ui/orders");
        wait.until(ExpectedConditions.visibilityOfElementLocated(TITLE));
        return this;
    }

    // ③ 业务语义:读起来像"创建订单",不像"填三个输入框再点按钮"
    public OrderPage createOrder(String product, String quantity, String address) {
        ...
        driver.findElement(SUBMIT).click();
        wait.until(ExpectedConditions.presenceOfElementLocated(
                By.xpath("//td[@class='cell-product' and text()='" + product + "']")));
        return this;
    }
}

5.2 假 PO:只包了 findElement 的那种 ★★★ ​

这是本节最重要的内容 ——因为本项目真的踩了 ✓

修复前的 createOrder ✗:

java
public OrderPage createOrder(String product, String quantity, String address) {
    wait.until(ExpectedConditions.visibilityOfElementLocated(PRODUCT_INPUT)).clear();
    driver.findElement(PRODUCT_INPUT).sendKeys(product);
    driver.findElement(QUANTITY_INPUT).clear();
    driver.findElement(QUANTITY_INPUT).sendKeys(quantity);
    driver.findElement(ADDRESS_INPUT).clear();
    driver.findElement(ADDRESS_INPUT).sendKeys(address);
    driver.findElement(SUBMIT).click();
    return this;               // ✗ 点完就返回了 —— 没等页面跳转回来
}

它看起来"很 PO"(定位器集中了、方法名是业务语义了)✓ ——但它缺了第二件事 ✗

测出来的结果(真实记录):

text
Tests run: 4, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 31.75 s
  --- in cn.kb.learn.ui.page.OrderPageObjectTest

cn.kb.learn.ui.page.OrderPageObjectTest.version_b_page_object -- Time elapsed: 7.885 s <<< ERROR!
java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 4

根因:createOrder 返回后立刻 rowCount(),而页面还在跳转 —— 数到了空列表 ✗✗

★ 而同一个测试类里的"直写版"却通过了 ✓✓✓:

版本写法结果
版本 A(直写定位器)手写了 WebDriverWait 等新行出现通过 ✓
版本 B(假 PO)定位器集中了,但没等待失败 ✗✗

★★★ 这个对比的价值极高 ✓✓✓:

"用了设计模式"不等于"更可靠" ✓

假 PO 比直写更危险 ——因为它给人一种"已经封装好了"的错觉 ✗✗

修复 ✓:

java
driver.findElement(SUBMIT).click();
// 等"这一行真的出现"再返回
wait.until(ExpectedConditions.presenceOfElementLocated(
        By.xpath("//td[@class='cell-product' and text()='" + product + "']")));
return this;

★ 修复的要点不是"加了等待",而是"等待放在了正确的层" ✓✓:

等待是页面行为的固有部分 ——"提交后页面会跳转回来"是这张页面的知识 ✓

所以这个知识属于 PO,不属于调用方 ✓✓

5.3 PO 该放什么、不该放什么 ★★ ​

该放 ★★不该放 ✗
定位器(private static final By)断言(assertEquals)✗✗
页面动作(点击、填写、导航)测试数据构造(那是数据工厂的事)✗
等待条件 ★★★业务规则判断(那是被测系统的事)✗
页面状态读取(rowCount()、title())日志与报告逻辑 ✗

★ "PO 不做断言"是最重要的一条 ✓✓:

为什么:

同一张页面在不同场景下的期望值不同 ✓

"创建后应该有 3 行"是测试的期望**,不是**页面的属性**** ✓✓

PO 一旦写死断言,就绑死了唯一一种预期 ✗

本项目 OrderPageObjectTest 就是"PO 读状态、测试做断言" ✓:

java
// PO 提供状态
int before = page.rowCount();
// 测试做断言
assertTrue(page.rowCount() > before);

★ "页面状态读取方法"是 PO 的好设计 ✓ ——它把"怎么读"封起来,"读出来算不算对"留给测试 ✓✓

5.4 PO 的返回类型 ★★ ​

两种选择,各有场景:

返回适用例子
this ★★同一页面上的连续操作(链式)page.createOrder(...).delete(...) ✓
另一个 PO ★跳转到别的页面loginPage.loginAs(u).shouldSee(DashboardPage) ✓

★ 本项目的选择:返回 this ✓ ——因为所有操作都在同一张页面上 ✓

java
OrderPage page = new OrderPage(driver, BASE_URL).open();
page.createOrder("测试商品B", "3", "广州市天河区");
int after = page.rowCount();

★ 一个提醒:

不要为了"链式好看"而强行使每个方法都返回 this ✗

如果一个动作会导致页面跳转,返回下一个页面的 PO 更准确 ✓(读代码时能看出跳转 ✓)

5.5 PO 的边界与演进 ★ ​

PO 不擅长什么:

问题PO 的局限
多个页面共享的组件(导航栏、弹窗)PO 是按页面组织的,会重复 ✗
跨页面的流程PO 不该知道完整流程 ✗
复杂的数据准备PO 不适合做数据 ✔(应交给 API)✓

两个演进方向:

方向思路适用
组件对象(Component Object) ★★把"导航栏/列表/弹窗"抽成独立对象,PO 组合它们组件复用的中大型项目 ✓
Screenplay 模式 ★以"角色-任务-能力"组织(Actor / Task / Ability)流程复杂、多人协作 ✓

★ 但有一条务实的建议:

小项目先把 PO 写对(三件事做全),不要急着上 Screenplay ✓✓

本项目 22 个用例,一个 OrderPage 就够 ✓

★ 更该先做的一件事:把"数据准备"从 UI 里挪出去 ✓✓:

java
// ✗ 用 UI 造数据:慢、脆,而且要经过一堆无关操作
page.createOrder(...); page.createOrder(...); page.createOrder(...);

// ✓ 用接口造数据,UI 只验证"界面能否呈现"
given().body(...).post("/api/orders");     // [T2-005](../api/T2-005)
page.open().shouldShowRows(3);             // UI 只做"看一眼"

这条是 UI 测试提速的第一手段 ✓✓(T2-012 会展开)✓

6. 递进示例 ​

6.1 最小示例:一个方法的 PO ​

java
public class HelloPage {
    private final WebDriver driver;
    private static final By GREETING = By.id("greeting");

    public HelloPage(WebDriver driver, String baseUrl) { ... }

    public String open() {
        driver.get(baseUrl + "/ui/hello");
        return driver.findElement(GREETING).getText();
    }
}

6.2 业务示例:本项目 OrderPage ​

见 5.1 ✓

6.3 反例:假 PO ​

见 5.2 ✓

7. 完整可运行代码与实测 ​

PO 类:labs/ui-testing/src/test/java/cn/kb/learn/ui/page/OrderPage.java对照测试:labs/ui-testing/src/test/java/cn/kb/learn/ui/page/OrderPageObjectTest.java

对照测试的四个用例:

用例内容
version_a_direct_locators直写定位器(含手写等待)—— 作为对照
version_b_page_object用 PO(修复前缺等待 ✗ → 修复后通过 ✓)
page_object_encapsulates_waiting等待被封在 PO 里 ✓
page_object_limits_blast_radius页面改版的影响面收敛到一个文件 ✓

★ 实测(修复前,真实记录):

text
Tests run: 4, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 31.75 s
  --- in cn.kb.learn.ui.page.OrderPageObjectTest

version_b_page_object -- Time elapsed: 7.885 s <<< ERROR!
java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 4

★ 两版的对照(这是本节的核心数据):

text
版本 A(直写定位器 + 手写等待) → 通过 ✓
版本 B(PO,但 createOrder 没等)→ 失败 ✗  Index -1 out of bounds

代码量对比(同一条业务路径):

By.* 出现次数手写等待处数
版本 A(直写)8 次4 处
版本 B(PO)0 次0 处 ✓

★ 所以 PO 的收益是"真实的" ✓ ——但它有个前提:

等待必须一起封进去 ✓✓

只收定位器、不封等待的 PO,等于"把 8 处 By 换成 8 个方法调用",而把时序问题留给了调用方 ✗✗

8. Debug 观察点 ​

  1. 把 createOrder 里的等待注释掉:复现 Index -1 out of bounds ★★;
  2. 把等待加回来:观察通过,并留意耗时(等待是"条件驱动",不会拖慢) ✓;
  3. 在测试里 System.out.println(page.allOrderNos()):看"创建前后列表的实际内容" ★;
  4. 把 OrderPage.TITLE 改成错误的 id:观察"只改一处,所有用例一起红" ✓ ——这正是"影响面收敛"的两面性 ✓;
  5. 统计"测试类里的 By. 出现次数":PO 写法应该接近 0 ✓。

9. 常见错误 ​

#错误说明
1假 PO:只收定位器不封等待 ✗✗本轮真实踩坑(5.2)✗✗
2PO 里写断言 ✗✗绑死唯一预期(5.3)✗
3方法名说 DOM 不说业务(clickBtn2())✗读代码看不出在干什么 ✗
4PO 之间互相依赖、循环引用 ✗难维护 ✗
5一个 PO 塞进整个站点的所有方法 ✗变成"上帝类" ✗
6用 UI 造测试数据 ✗✗慢且脆(5.5)✗✗
7以为用了 PO 就稳定了 ✗✗模式不解决时序问题,等待才解决 ✓✓

10. 动手实验 ​

目标:亲手体验"假 PO 的危害"与"真 PO 的收益"。

步骤:

  1. 跑 scripts\ui-test.ps1,确认 OrderPageObjectTest 四个用例的结果 ✓;
  2. 把 OrderPage.createOrder 末尾的等待注释掉,重跑 → 复现 Index -1 out of bounds ★★;
  3. 加回等待,重跑 → 确认通过 ✓;
  4. 把 version_a(直写版)删掉,只留 PO 版,思考:如果一开始就只写 PO 版,你会不会发现"缺等待"这个问题 ★★★;
  5. 给 OrderPage 加一个 deleteAll(),但先想清楚:它该不该有返回值、该不该断言 ✓。

验收方式:能说出"第 4 步"的答案(提示:直写版的价值在于它是"对照组" ✓)。

扩展任务:把"数据准备"从 UI 挪到接口:

  • 用 T2-005 的 REST Assured 造 3 条订单 ✓;
  • UI 只验证"页面能显示这 3 条" ✓;
  • 对比两种方式的耗时(UI 造数据 vs 接口造数据)★★。

11. 练习题 ​

  1. 判断:用了 Page Object 就能提高测试稳定性。(对 / 错)
  2. 填空:PO 的三件事是 、、______;其中最容易被漏掉的是 ______。
  3. 简答:为什么 PO 不应该写断言?
  4. (代码阅读)
java
public class LoginPage {
    private final WebDriver driver;

    public void login(String user, String pwd) {
        driver.findElement(By.id("u")).sendKeys(user);
        driver.findElement(By.id("p")).sendKeys(pwd);
        driver.findElement(By.id("btn")).click();
    }

    public void assertLoginSuccess() {
        assertEquals("欢迎", driver.findElement(By.id("welcome")).getText());
    }
}

这个类做错了哪两件事? 5. (编程)把上题的 LoginPage 改对:收定位器、封等待、去断言、露业务语义。

参考答案(要点)
  1. 错。PO 只解决"组织"问题,不解决"时序"问题 ✗ ——等待必须一起封进去 ✓★(5.2)
  2. 收定位器、封等待、露业务语义;封等待 ✓✓
  3. 因为期望值属于"测试",不属于"页面" ✓ ——同一张页面在不同场景下的期望不同 ✓(5.3)
  4. ① 定位器散落在方法里(没有集中)✗;② 方法里写断言 ✗✗ —— 另外它还缺等待 ✗,且方法名 login 可以,但 assertLoginSuccess 越界了 ✓
  5. 要点:
    java
    private static final By USER = By.id("u");
    private static final By PWD = By.id("p");
    private static final By SUBMIT = By.id("btn");
    private static final By WELCOME = By.id("welcome");
    
    public LoginPage login(String user, String pwd) {
        wait.until(ExpectedConditions.visibilityOfElementLocated(USER)).sendKeys(user);
        driver.findElement(PWD).sendKeys(pwd);
        driver.findElement(SUBMIT).click();
        return this;            // 或返回下一个页面的 PO
    }
    
    public String welcomeText() {            // 读状态,不做断言
        return wait.until(ExpectedConditions.visibilityOfElementLocated(WELCOME)).getText();
    }

12. 掌握标准 ​

  • [ ] 能说出 PO 的三件事,并知道哪件最容易被漏 ★★★;
  • [ ] 能识别假 PO,并说明它的危害 ★★★;
  • [ ] 知道 PO 不做断言,并能解释原因 ★★★;
  • [ ] 知道 PO 的返回类型怎么选 ✓;
  • [ ] 知道"数据准备应该交给接口" ★★;
  • [ ] 不迷信"用了模式就更可靠" ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《UI 测试稳定性与失败重试边界》:什么该重试、什么绝不能重试。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引