主题
界面
主题
《Selenium UI 自动化基础》解决了"跑起来";本节解决 UI 测试里最高频的一类失败:
NoSuchElementException: Unable to locate element ← 十有八九是"时机",不是"选择器"本节的核心命题:
"找不到元素"里,真正"元素不存在"的只是少数 ✗
大多数是"元素还没出现"、"元素在 iframe 里"、"选择器定位到了别的地方" ✓✓
data-testid 最稳 ★★★;Thread.sleep)★★★;先看 UI 测试里最常见的四个"定位失败"及其真实原因 ✗:
| 报错 | 真实原因 | 占比 |
|---|---|---|
NoSuchElementException | 元素还没渲染(异步加载) | 高 ★★★ |
NoSuchElementException | 定位器指向了别的地方(全局选择器) | 中 ★★ |
ElementClickInterceptedException | 元素存在但被遮挡/还在动画中 | 中 ★★ |
StaleElementReferenceException | 元素被重新渲染了(DOM 换了新节点) | 低 ★ |
★ 只有极少数是"元素真的不存在" ✓
所以本节的策略是:
先分清"不存在"与"还没出现",再谈怎么定位 ✓✓
| 优先级 | 方式 | 写法 | 评价 |
|---|---|---|---|
| ① | data-testid ★★★ | By.cssSelector("[data-testid='create-submit']") | 最稳:为测试保留的契约 ✓✓ |
| ② | id ★★ | By.id("order-table") | 好,但可能为了布局被改 ⚠️ |
| ③ | cssSelector ★★ | By.cssSelector("p.summary") | 灵活、快 ✓ |
| ④ | xpath ★ | By.xpath("//td[@class='cell-product']") | 能表达"文本等于",但慢且脆 ⚠️ |
| ⑤ | name | By.name("product") | 表单字段好用 ✓ |
| ⑥ | linkText / tagName | By.linkText("登录") | 容易命中多个,慎用 ✗ |
★ 为什么这个优先级是这样排的:
CSS 类名 → 会被"改样式"顺手改掉 ✗
id → 会被"改布局"顺手改掉 ⚠️
data-* → 只会在"页面真的变了"时改 ✓✓本项目页面的写法(UiPageController):
<!-- 同时提供 id 与 data-testid,便于对比两种定位 -->
<button type="submit" id="create-btn" data-testid="create-submit">创建订单</button>★ 本项目的实测(UiEnvironmentCheckTest 与 OrderLocatorTest 都在用):
WebElement submit = driver.findElement(By.cssSelector("[data-testid='create-submit']"));
assertEquals("创建订单", submit.getText());先看一个真实会出 bug 的写法 ✗:
// 页面里每行都有"删除"按钮
driver.findElement(By.cssSelector(".btn-delete")).click(); // ✗ 点的是"第一个"删除按钮问题:findElement 返回的是"第一个匹配" ✓ ——而你要删的可能是第 3 行 ✗✗
正确写法:先定位行,再在行内定位按钮 ✓✓:
// ① 用稳定的属性定位到"那一行"
WebElement row = driver.findElement(By.cssSelector("tr[data-order-no='ORD-2']"));
// ② 在行内查找 —— 范围被限制住了
WebElement product = row.findElement(By.cssSelector(".cell-product"));
String name = product.getText();
row.findElement(By.cssSelector(".btn-delete")).click(); // ✓ 删的是这一行★ 这条规则的表述:
driver.findElement是"全局查找",element.findElement是"在范围内查找" ✓凡是"列表里的第 N 项",都必须用相对定位 ✓✓
本项目页面的设计(UiPageController)就是为了演示这一点 ——** 每个 <tr> 都带 data-order-no,每个删除按钮都在自己的行里**:
<tr class="order-row" data-order-no="ORD-2">
<td class="cell-no">ORD-2</td>
<td class="cell-product">人体工学椅</td>
...
<td><button class="btn-delete" data-order-no="ORD-2">删除</button></td>
</tr>★ 本节的实测(OrderLocatorTest.row_relative_locator_should_work,已通过 ✓):
===== 相对定位 =====
表格行数:2
要删除的行商品:人体工学椅
删除后行数:1(原 2)注意它是"删了指定的那一行"(人体工学椅 / ORD-2) ✓ ——而不是"删了第一行" ✓✓
| 隐式等待 | 显式等待 ★★ | Thread.sleep ✗ | |
|---|---|---|---|
| 写法 | driver.manage().timeouts().implicitlyWait(...) | new WebDriverWait(driver, ...).until(...) | Thread.sleep(2000) |
| 作用范围 | 全局(所有 findElement) | 单次调用 ✓ | 当前线程 |
| 等什么 | 只等"元素出现在 DOM 里" ✗ | 等任意条件 ✓✓ | 什么都不等,纯睡 ✗ |
| 找到后 | 立即返回 ✓ | 条件满足立即返回 ✓✓ | 仍要睡满 ✗✗ |
| 评价 | 可作为兜底 ✓ | 主力手段 ✓✓ | 反模式 ✗✗ |
★ 关键差别:隐式等待"只等元素存在",不等条件成立 ✓✓
本节的实测(UiStabilityTest.proper_wait_beats_retry,已通过 ✓):
===== 正确等待 vs 重试 =====
等 300ms(太短):超时,耗时 300+ ms
等 10s(条件驱动):通过,耗时约 2000 ms —— 条件一满足就返回,不是死等 10 秒
结论:把"等多久"换成"等什么条件",抖动就消失了,根本用不到重试★ 这张数据里最有价值的是第二行:
超时设 10 秒,实际只等了约 2 秒 ✓ ——因为页面里的 JS 是 2000ms 后写入内容 ✓
这就是"条件驱动"与"时间驱动"的本质区别 ✓✓
如果换成 Thread.sleep ✗:
Thread.sleep(2000); // ✗ 机器快时白等 2 秒;机器慢时还不够
driver.findElement(By.id("slow-panel")).getText();两个问题:
★ 所以"显式等待"同时解决了"稳定性"与"速度"两个问题 ✓✓
这是本轮真实踩到的坑 ——而且它以"编译错误"的形式出现 ✓
页面上的设计(UiPageController):
<div id="slow-panel" class="slow">加载中……</div>
<script>
setTimeout(function () {
document.getElementById('slow-panel').textContent = '最近一次同步:刚刚';
document.getElementById('slow-panel').setAttribute('data-loaded', 'true'); // ★ 2 秒后才加
}, 2000);
</script>关键:#slow-panel 这个元素"一开始就存在" ✓ ——存在时它是"加载中……" ✓ 而"加载完成"体现为一个属性 data-loaded="true" 的出现 ✓✓
所以:
// ✗ 这样写会立刻返回(元素本来就存在),然后读到"加载中……"
WebElement panel = driver.findElement(By.id("slow-panel"));
// ✗ 隐式等待也救不了:它只等"元素存在"
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(5));
driver.findElement(By.id("slow-panel")); // 立刻返回 ✗
// ✓ 显式等待"条件成立"
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.attributeToBe(By.id("slow-panel"), "data-loaded", "true"));★★ 一个真实踩过的坑:until() 的返回类型由"条件"决定 ✓✓
// ✗ 编译错误:attributeToBe 返回 Boolean,不是 WebElement
WebElement panel = new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.attributeToBe(By.id("slow-panel"), "data-loaded", "true"));
// error: 不兼容的类型 / 找不到符号 getText正确写法:
// ① 先等条件
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.attributeToBe(By.id("slow-panel"), "data-loaded", "true"));
// ② 再取元素
WebElement panel = driver.findElement(By.id("slow-panel"));
String text = panel.getText(); // ✓ "最近一次同步:刚刚"★ 这条值得单独记住:
返回
ExpectedCondition<Boolean>的条件(如attributeToBe、invisibilityOf、textToBe) 返回的就是布尔值 ✓ ——想要元素得再查一次 ✓✓
| 条件 | 等什么 | 返回 |
|---|---|---|
visibilityOfElementLocated ★★ | 元素可见(存在且有尺寸) | WebElement ✓ |
presenceOfElementLocated | 元素出现在 DOM(可能不可见) | WebElement ✓ |
elementToBeClickable ★ | 可见且可点击 | WebElement ✓ |
textToBe ★ | 文本等于某值 | Boolean ⚠️ |
attributeToBe ★ | 属性等于某值 | Boolean ⚠️ |
invisibilityOfElementLocated ★★ | 元素消失(删除/关闭后) | Boolean ⚠️ |
numberOfElementsToBeMoreThan ★ | 元素个数 > N(列表新增) | List<WebElement> ✓ |
urlContains / titleContains | 地址/标题变化 | Boolean ⚠️ |
★ 选条件的两个原则:
| 原则 | 例子 |
|---|---|
| 等"最终状态",不等"中间状态" ★★ | 等"列表已渲染"而不是"正在加载" ✗ |
| 等"最贴近业务的那个事实" ★★★ | 等"我创建的那个商品出现在列表里" ✓✓(比"等 3 条行"更准) |
本项目 OrderPage.createOrder 就是这么写的 ✓:
driver.findElement(SUBMIT).click();
// 等"这一行真的出现"再返回 —— 而不是等"行数变了"或"睡 2 秒"
wait.until(ExpectedConditions.presenceOfElementLocated(
By.xpath("//td[@class='cell-product' and text()='" + product + "']")));★ 这条写法是本项目的一个"修复成果" ✓ ——最初的版本只是 click() 就返回, 导致调用方读到旧列表,报 Index -1 out of bounds ✗(T2-010 有完整复盘)✓
| 反模式 | 为什么坏 | 正确做法 |
|---|---|---|
Thread.sleep(固定值) ✗✗ | 慢环境失败、快环境浪费 | 显式等待条件 ✓ |
| 隐式等待设得很大(如 30 秒)✗ | 每个 findElement 失败都要等 30 秒 ——用例一多,时间爆炸 ✗✗ | 隐式等 0~3 秒,靠显式等 ✓ |
| 隐式 + 显式混用 ⚠️ | 两者的总等待时间可能叠加(行为难以预测)✗ | 只用一个(推荐显式) ✓✓ |
| 等待失败就无条件重试 ✗✗ | 掩盖真缺陷(T2-011) | 只在"已知外部抖动"处重试 ✓ |
| 用"元素个数为 N"代替"元素是哪个" ⚠️ | 数量对但内容错也能通过 ✗ | 按内容定位 ✓ |
★ "隐式等待设得很大"这条的代价,用本项目的量级感受一下:
22 个 UI 用例,如果每个里有 3 次 findElement 失败(正常情况也可能发生)
隐式等待 30 秒 → 22 × 3 × 30 = 1980 秒 = 33 分钟 ✗✗
隐式等待 0(靠显式等)→ 失败立即抛出,几秒内定位到问题 ✓WebElement button = new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.elementToBeClickable(By.cssSelector("[data-testid='create-submit']")));
button.click();见 5.2 与 5.5 ✓
Thread.sleep + 全局定位 Thread.sleep(2000);
driver.findElement(By.cssSelector(".btn-delete")).click(); // ✗ 两个坑叠加测试代码:
labs/ui-testing/src/test/java/cn/kb/learn/ui/OrderLocatorTest.java(定位与等待)labs/ui-testing/src/test/java/cn/kb/learn/ui/UiStabilityTest.java(等待的边界)运行:
pwsh -File scripts\ui-test.ps1实测结果(本项目真实运行):
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 80.25 s
--- in cn.kb.learn.ui.OrderLocatorTest ← 定位相关 5 个用例全过用例构成:
| 用例 | 验证什么 |
|---|---|
five_locators_should_find_same_element | 五种定位方式都能找到,并各自计时 |
data_testid_should_be_preferred | data-testid 的稳定性 |
implicit_wait_should_fail_on_delayed_element | 隐式等待的边界:它等不到"条件成立" ✗ |
explicit_wait_should_succeed_on_delayed_element | 显式等待能等到"条件成立" ✓ |
row_relative_locator_should_work | 相对定位:删的是指定那一行 ✓ |
★ 其中第 3、4 个用例是最有教学价值的:
implicit_wait_should_fail_on_delayed_element → 5 个用例中的 1 个
explicit_wait_should_succeed_on_delayed_element它们用同一个元素、同一个条件,只换等待方式 ✓ ——一个失败、一个成功 ✓✓
★ 五种定位方式的耗时实测(各执行 200 次,同一台机器):
===== 五种定位方式的耗时(各执行 200 次)=====
id → 单次 11585.5 μs(约 11.6 ms),文本=订单管理
cssSelector(#id) → 单次 7987.2 μs(约 8.0 ms),文本=订单管理
tagName(h1) → 单次 10478.2 μs(约 10.5 ms),文本=订单管理
cssSelector(标签+类) → 单次 9489.8 μs(约 9.5 ms),文本=当前共 2 条订单
xpath(//h1[@id='page-title']) → 单次 6741.7 μs(约 6.7 ms),文本=订单管理★★ 这张表的结果"不符合教科书" ✓✓:
| 常见说法 | 实测 |
|---|---|
| "CSS 比 XPath 快" | XPath 反而最快(6.7 ms) ✗ |
| "id 定位最快" | id 反而最慢(11.6 ms) ✗ |
| "定位方式的选择影响性能" | 五种都在 6~12 ms 之间,差距不到 2 倍 ⚠️ |
★ 为什么 ✓✓✓:
单次 6~12 ms 这个量级,说明时间几乎全花在"一次 W3C 协议往返"上 ✓
而"选择器怎么解析"只是浏览器内部极小的一部分 ✓
所以"CSS 比 XPath 快"那个说法(针对浏览器内部解析)在 Selenium 场景下几乎不起作用 ✓✓
★ 这条实测的教学价值:
不要背"哪个更快",要测量 ✓✓
因为瓶颈在哪取决于你测量的整条链路,而不只是链路里你关注的那一段 ✓ (《进程与端口排查》 的"CPU 低却假死"是同一种思路)
★ 所以选定位方式应该按"可维护性"排序,而不是"性能" ✓:
可维护性:data-testid > id > cssSelector > xpath > name > linkText
性能 :五种方式差距不到 2 倍,且都被协议往返淹没 —— 不作为选择依据 ✗★ 本项目还有一个"失败留下的证据"可以看:
UiStabilityTest.failure_should_leave_artifacts 会把截图与页面源码写到:
labs/ui-testing/target/ui-artifacts/failure-<时间戳>.png
labs/ui-testing/target/ui-artifacts/failure-<时间戳>.html
labs/ui-testing/target/ui-artifacts/failure-<时间戳>.txt三件套的作用:截图看"长什么样"、HTML 看"DOM 是什么样"、txt 记"当时的 URL 与标题" ✓✓
--headless=new 去掉:亲眼看着"明明元素在屏幕上,脚本却说找不到" ★★★ —— 多数时候你能看到它正在加载 ✓;data-loaded 的条件改成"元素存在":观察它立刻返回并读到"加载中……" ★★;Thread.sleep 式的等待(把条件等待换成固定 sleep):观察在慢机器上失败 ⚠️;driver.getPageSource():确认元素到底在不在 DOM 里 ★;| # | 错误 | 说明 |
|---|---|---|
| 1 | 用 Thread.sleep 代替显式等待 ✗✗ | 慢环境失败、快环境浪费(5.6)✗✗ |
| 2 | 以为"元素存在"就是"准备好了" ✗✗ | 见 5.4 ✓ |
| 3 | 全局 findElement 操作列表项 ✗✗ | 删错行(5.2)✗✗ |
| 4 | 把 Boolean 条件当 WebElement 用 ✗✗ | 编译错误(5.4)✗ |
| 5 | 隐式等待设得过大 ✗✗ | 失败时长时间空等(5.6)✗ |
| 6 | 隐式与显式混用 ⚠️ | 等待时间叠加,行为难预测 ✗ |
| 7 | XPath 里用绝对路径(/html/body/div[3]/...)✗✗ | DOM 一改就全废 ✗ |
| 8 | 用 CSS 类名定位(div.mt-4.text-red-500)✗ | 样式类会变 ✗ |
目标:亲手制造一次"找不到元素",再分别用三种等待看结果。
步骤:
scripts\ui-test.ps1,确认 OrderLocatorTest 的 5 个用例都通过 ✓;explicit_wait_should_succeed_on_delayed_element 的等待改成 Thread.sleep(500) → 观察失败 ★;row_relative_locator_should_work 改成全局 findElement(By.cssSelector(".btn-delete")) → 观察它删掉的不是预期那一行 ★★★;target/ui-artifacts,看 failure_should_leave_artifacts 留下的截图与 HTML ✓。验收方式:能说出"第 4 步删掉的是哪一行、为什么"。
扩展任务:把页面的"慢面板"延迟从 2000ms 改成 5000ms:
driver.findElement(By.cssSelector(".btn-delete")) 在列表页里容易出 bug?driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(30));
driver.findElement(By.id("does-not-exist"));这段代码最坏情况下要等多久?为什么这是个问题? 5. (编程)给 OrderPage 加一个方法 waitForRowCount(int n),用"个数条件"等待,并说明它和 waitForSlowPanel 的差别。
visibilityOfElementLocated;textToBe;Boolean ✓(5.5)findElement 都要耗尽超时 ✓ —— 用例一多,失败时的总耗时会失控 ✓(5.6)numberOfElementsToBe 或 numberOfElementsToBeMoreThan ✓ 差别:个数条件只保证"有几行",不保证"是哪几行" ⚠️ —— 所以本项目 createOrder 特意用"按商品名文本定位"这种更贴近业务的等待 ✓✓data-testid 最稳 ★★★;until() 的返回类型由条件决定 ★★;Boolean ✓;《Page Object 与框架封装》:把定位器收进一个地方,让测试长得像业务。