主题
界面
主题
《Selenium 元素定位与显式等待》解决了"怎么稳定地找到元素";本节解决**"代码怎么组织"**:
10 个用例、每个用例写 8 个 By.xxx → 页面一改版,要改 80 处 ✗
把定位器收进一个类 → 页面改版,只改 1 个文件 ✓Page Object(PO)就是那个类 ✓ ——但它也是最容易被做成"假 PO"的模式 ✗
this vs 下一个页面)★★;先看"不用 PO"在页面改版时的代价 ✗:
页面把 id="page-title" 改成了 id="page-heading"
→ 所有写了 By.id("page-title") 的测试类都要改
→ 本项目的 OrderPageObjectTest 里,直写版写了几处? 8 处
→ 如果有 20 个测试类,就要改 160 处 ✗✗而用 PO ✓:
只改 OrderPage 里的一个常量:
private static final By TITLE = By.id("page-title");
→ 改成 By.id("page-heading")
→ 测试代码改动量:0 行 ✓✓★ 但这只是 PO 的"第一层价值" ——下面这条更重要 ✓✓:
PO 的定位器是"静态的",而 UI 是"动态的" ✓
页面上的元素会"晚出现"、"消失"、"被替换" ——这些时序问题必须有人负责 ✓
如果 PO 不负责,它就会把问题"转嫁"给调用方 ✗✗(本节 5.2 的真实案例)✓
| # | 职责 | 反例 |
|---|---|---|
| ① 收定位器 ★★ | 所有 By.xxx 集中在一处 | 测试里散落 By.id(...) ✗ |
| ② 封等待 ★★★ | 把"等什么条件"封在动作里 | 让调用方自己 new WebDriverWait ✗✗ |
| ③ 露业务语义 ★★★ | 方法名说业务(createOrder),不说 DOM(clickButton) | clickSubmitButton() ✗ |
★ 用一句话概括:
调用方只该知道"我要做什么业务",不该知道"页面上有什么元素、要等什么条件" ✓✓
本项目的 OrderPage 就按这三件事写 ✓:
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;
}
}findElement 的那种 ★★★ 这是本节最重要的内容 ——因为本项目真的踩了 ✓
修复前的 createOrder ✗:
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"(定位器集中了、方法名是业务语义了)✓ ——但它缺了第二件事 ✗
测出来的结果(真实记录):
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 比直写更危险 ——因为它给人一种"已经封装好了"的错觉 ✗✗
修复 ✓:
driver.findElement(SUBMIT).click();
// 等"这一行真的出现"再返回
wait.until(ExpectedConditions.presenceOfElementLocated(
By.xpath("//td[@class='cell-product' and text()='" + product + "']")));
return this;★ 修复的要点不是"加了等待",而是"等待放在了正确的层" ✓✓:
等待是页面行为的固有部分 ——"提交后页面会跳转回来"是这张页面的知识 ✓
所以这个知识属于 PO,不属于调用方 ✓✓
| 该放 ★★ | 不该放 ✗ |
|---|---|
| 定位器(private static final By) | 断言(assertEquals)✗✗ |
| 页面动作(点击、填写、导航) | 测试数据构造(那是数据工厂的事)✗ |
| 等待条件 ★★★ | 业务规则判断(那是被测系统的事)✗ |
页面状态读取(rowCount()、title()) | 日志与报告逻辑 ✗ |
★ "PO 不做断言"是最重要的一条 ✓✓:
为什么:
同一张页面在不同场景下的期望值不同 ✓
"创建后应该有 3 行"是测试的期望**,不是**页面的属性**** ✓✓
PO 一旦写死断言,就绑死了唯一一种预期 ✗
本项目 OrderPageObjectTest 就是"PO 读状态、测试做断言" ✓:
// PO 提供状态
int before = page.rowCount();
// 测试做断言
assertTrue(page.rowCount() > before);★ "页面状态读取方法"是 PO 的好设计 ✓ ——它把"怎么读"封起来,"读出来算不算对"留给测试 ✓✓
两种选择,各有场景:
| 返回 | 适用 | 例子 |
|---|---|---|
this ★★ | 同一页面上的连续操作(链式) | page.createOrder(...).delete(...) ✓ |
| 另一个 PO ★ | 跳转到别的页面 | loginPage.loginAs(u).shouldSee(DashboardPage) ✓ |
★ 本项目的选择:返回 this ✓ ——因为所有操作都在同一张页面上 ✓
OrderPage page = new OrderPage(driver, BASE_URL).open();
page.createOrder("测试商品B", "3", "广州市天河区");
int after = page.rowCount();★ 一个提醒:
不要为了"链式好看"而强行使每个方法都返回
this✗如果一个动作会导致页面跳转,返回下一个页面的 PO 更准确 ✓(读代码时能看出跳转 ✓)
PO 不擅长什么:
| 问题 | PO 的局限 |
|---|---|
| 多个页面共享的组件(导航栏、弹窗) | PO 是按页面组织的,会重复 ✗ |
| 跨页面的流程 | PO 不该知道完整流程 ✗ |
| 复杂的数据准备 | PO 不适合做数据 ✔(应交给 API)✓ |
两个演进方向:
| 方向 | 思路 | 适用 |
|---|---|---|
| 组件对象(Component Object) ★★ | 把"导航栏/列表/弹窗"抽成独立对象,PO 组合它们 | 组件复用的中大型项目 ✓ |
| Screenplay 模式 ★ | 以"角色-任务-能力"组织(Actor / Task / Ability) | 流程复杂、多人协作 ✓ |
★ 但有一条务实的建议:
小项目先把 PO 写对(三件事做全),不要急着上 Screenplay ✓✓
本项目 22 个用例,一个
OrderPage就够 ✓
★ 更该先做的一件事:把"数据准备"从 UI 里挪出去 ✓✓:
// ✗ 用 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 会展开)✓
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();
}
}OrderPage 见 5.1 ✓
见 5.2 ✓
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 | 页面改版的影响面收敛到一个文件 ✓ |
★ 实测(修复前,真实记录):
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★ 两版的对照(这是本节的核心数据):
版本 A(直写定位器 + 手写等待) → 通过 ✓
版本 B(PO,但 createOrder 没等)→ 失败 ✗ Index -1 out of bounds代码量对比(同一条业务路径):
By.* 出现次数 | 手写等待处数 | |
|---|---|---|
| 版本 A(直写) | 8 次 | 4 处 |
| 版本 B(PO) | 0 次 | 0 处 ✓ |
★ 所以 PO 的收益是"真实的" ✓ ——但它有个前提:
等待必须一起封进去 ✓✓
只收定位器、不封等待的 PO,等于"把 8 处 By 换成 8 个方法调用",而把时序问题留给了调用方 ✗✗
createOrder 里的等待注释掉:复现 Index -1 out of bounds ★★;System.out.println(page.allOrderNos()):看"创建前后列表的实际内容" ★;OrderPage.TITLE 改成错误的 id:观察"只改一处,所有用例一起红" ✓ ——这正是"影响面收敛"的两面性 ✓;By. 出现次数":PO 写法应该接近 0 ✓。| # | 错误 | 说明 |
|---|---|---|
| 1 | 假 PO:只收定位器不封等待 ✗✗ | 本轮真实踩坑(5.2)✗✗ |
| 2 | PO 里写断言 ✗✗ | 绑死唯一预期(5.3)✗ |
| 3 | 方法名说 DOM 不说业务(clickBtn2())✗ | 读代码看不出在干什么 ✗ |
| 4 | PO 之间互相依赖、循环引用 ✗ | 难维护 ✗ |
| 5 | 一个 PO 塞进整个站点的所有方法 ✗ | 变成"上帝类" ✗ |
| 6 | 用 UI 造测试数据 ✗✗ | 慢且脆(5.5)✗✗ |
| 7 | 以为用了 PO 就稳定了 ✗✗ | 模式不解决时序问题,等待才解决 ✓✓ |
目标:亲手体验"假 PO 的危害"与"真 PO 的收益"。
步骤:
scripts\ui-test.ps1,确认 OrderPageObjectTest 四个用例的结果 ✓;OrderPage.createOrder 末尾的等待注释掉,重跑 → 复现 Index -1 out of bounds ★★;version_a(直写版)删掉,只留 PO 版,思考:如果一开始就只写 PO 版,你会不会发现"缺等待"这个问题 ★★★;OrderPage 加一个 deleteAll(),但先想清楚:它该不该有返回值、该不该断言 ✓。验收方式:能说出"第 4 步"的答案(提示:直写版的价值在于它是"对照组" ✓)。
扩展任务:把"数据准备"从 UI 挪到接口:
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 改对:收定位器、封等待、去断言、露业务语义。
login 可以,但 assertLoginSuccess 越界了 ✓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();
}《UI 测试稳定性与失败重试边界》:什么该重试、什么绝不能重试。