主题
界面
主题
《Page Object 与框架封装》解决了"代码怎么组织";本节解决UI 测试最致命的敌人:
"这条用例挂了" → 点重试 → 绿了 → 继续干活 ← 最危险的工作方式 ✗✗flaky(时好时坏)的用例比"一直失败的用例"危害更大 ✓✓ ——因为前者会让你学会"不信任测试" ✓
先算一笔账 ✗:
第 1 周:一条用例偶发失败 → 点重试过去了 ✓
第 2 周:三条用例偶发失败 → 点重试过去了 ✓
第 4 周:十条用例偶发失败 → 反正点重试就行,先合并吧 ✓
第 8 周:真失败来了,大家以为是 flaky → 点重试 → 还是红 → 没人看 → 事故 ✗✗✗★ flaky 的真正代价不是"浪费时间" ✓ ——而是:
它训练团队"忽略红灯" ✓✓
当红灯不再意味着"有问题"时,测试就失去了全部价值 ✗✗
这是本节最核心的一张表:
| 失败类型 | 重试有用吗 | 为什么 |
|---|---|---|
| 真缺陷(元素不存在、断言错)✗ | 完全无用 ✗✗ | 它每次都失败,重试只是把"立刻红"变成"晚点红" |
| 外部抖动(网络瞬断、第三方超时)★ | 有用 ✓ | 下次可能就好了 ✓ |
| 时序问题(等待不足)⚠️ | 看起来有用 ✗✗ | 重试掩盖了"等待写错了"这个真问题 ✗ |
| 资源竞争(并发抢同一份数据)⚠️ | 偶尔有用 ✗ | 治标不治本 ✗ |
★★ 第二行和第三行的区别最关键:
重试"治好了"时序问题,只是把失败概率从 20% 降到 2% ✗
而 2% 的失败会以"偶尔红一下"的形式长期存在 ✓✓
正确做法是修等待,而不是加重试 ✓
★ 本节的实测(UiStabilityTest.retry_cannot_fix_real_defect,已通过 ✓):
// 页面上不存在这个元素 —— 这是"确定性缺陷",不是"慢"
By missing = By.id("this-element-does-not-exist");
// 重试 3 次,每次等待 400ms
runWithRetry(3, () -> new WebDriverWait(driver, Duration.ofMillis(400))
.until(ExpectedConditions.visibilityOfElementLocated(missing)));输出:
===== 重试救不了真缺陷 =====
重试 3 次 × 每次等待 400ms → 总共耗时 1200+ ms 后仍然失败
结论:重试只是把"立刻失败"变成"晚一点失败",
却让报告上的失败次数看起来减少了 —— 这是最危险的用法★ 划重点:重试让"失败次数"减少,但缺陷没修 ✓✓
| # | 成因 | 特征 | 修法 |
|---|---|---|---|
| ① | Thread.sleep 固定等待 ✗✗ | 慢机器上必失败 | 换成显式等待条件 ✓ |
| ② | 依赖上一个用例的数据 ✗✗ | 单跑通过、全跑失败 ★★ | 每个用例自建自清 ✓ |
| ③ | 绝对下标定位(第 3 个按钮)✗ | 列表一增删就点错 | 用 data-* 属性相对定位 ✓ |
| ④ | 断言瞬时状态("加载中")✗ | 竞态 | 断言最终状态 ✓ |
| ⑤ | 全局共享浏览器实例 ✗ | 用例互相污染 | 每个用例独立 driver ✓ |
| ⑥ | 视口尺寸不固定 ⚠️ | 响应式布局导致元素不同 | 固定 --window-size ✓(T2-002) |
★★★ 本项目第 ② 条的真实记录 ✓✓✓:
在写这一节时,讲"flaky 成因"的那个测试自己因为"依赖初始数据"而失败了 ✓
cn.kb.learn.ui.UiStabilityTest.flaky_anti_patterns -- Time elapsed: 7.208 s <<< FAILURE!
org.opentest4j.AssertionFailedError: 初始数据是 2 条 —— 用例依赖初始数据是 flaky 的一个来源,
这里显式断言出来 ==> expected: <2> but was: <5>经过:该测试断言"初始数据是 2 条" ✗ ——但同一个应用实例被多个用例共享, 前面的用例创建了 3 条订单,于是变成 5 条 ✗✗
★ 它的讽刺之处在于:
测试的名字叫
flaky_anti_patterns,而它自己被"依赖初始数据"这个 flaky 成因击中了 ✓✓
修法(改成"至少 2 条",而不是"等于 2 条")✓:
// ★ 刻意写成"至少 2 条",而不是"等于 2 条":
// 同一个应用实例被多个用例共享时,前面的用例会改变数据。
assertTrue(driver.findElements(By.cssSelector("tr.order-row")).size() >= 2,
"至少应有 2 条初始订单;但绝不能断言精确数量");★★ 更根本的认识:
"依赖初始数据"不只是"怕别人改" ✓ ——还有"怕自己前面的用例改" ✓✓
所以判据是:"这个用例单独跑和全量跑,结果一样吗?" ✓✓
UI 失败只在一瞬间 ✓ ——而次日复盘时,你只有报告里的一行异常 ✗
三件套(本项目的 UiStabilityTest.failure_should_leave_artifacts,已通过 ✓):
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES); // ① 截图
Files.write(dir.resolve("failure-" + stamp + ".png"), png);
Files.writeString(dir.resolve("failure-" + stamp + ".html"),
driver.getPageSource()); // ② 页面源码
Files.writeString(dir.resolve("failure-" + stamp + ".txt"),
"URL: " + driver.getCurrentUrl() + "\n"
+ "标题: " + driver.getTitle() + "\n"
+ "断言: " + e.getMessage()); // ③ 上下文各自的作用:
| 证据 | 回答什么问题 |
|---|---|
| 截图 ★★ | "当时屏幕上是啥样"(弹窗挡住了?还是根本没渲染?) |
| 页面源码 ★★★ | "DOM 里到底有没有这个元素" ——区分"没渲染"与"选择器写错" ✓✓ |
| URL + 标题 + 断言消息 | "跑到哪一步了" ✓ |
★ 为什么"页面源码"比截图更重要 ✓✓:
截图告诉你"看起来不对",源码告诉你"为什么不对" ✓
比如:截图里看到"加载中……",源码里就能查到
data-loaded确实还没加上 ✓✓
★ 本项目把证据写到:
labs/ui-testing/target/ui-artifacts/failure-<时间戳>.{png,html,txt}(target/ 目录不会进版本库,但会被 CI 归档 —— T2-017 5.3 ✓)
如果一定要重试,把它限制在"已知的外部抖动"上 ✓:
| 可以重试 | 绝不能重试 |
|---|---|
| 第三方接口偶发超时 ✓ | 元素定位失败 ✗✗ |
| 测试环境的网络瞬断 ✓ | 断言失败 ✗✗ |
| 平台自身的资源调度 ⚠️ | "我不知道为什么失败" ✗✗✗ |
★ 最后一行是最重要的拒绝理由:
"我不知道为什么失败"时加的重试,等于把未知变成隐藏 ✓✓
应该做的是:让它失败、留证据、去查 ✓
★ 一个可操作的判据:
问:我能说清"这次失败的具体原因"吗?
能,且原因是"外部依赖偶发" → 可以重试 ✓
不能 / 原因是"时序" → 去修等待,不要重试 ✗✗★ 如果真要重试,也要"留痕" ✓:
重试成功的用例必须在报告里标出来(如"重试第 2 次通过")✓✓
否则它会变成一个"永远绿"的假象,而抖动率在悄悄上升 ✗✗
正确的流程(这是团队级约定,不是技术细节)✓:
① 发现 flaky
→ 记录:用例名、失败率、失败时的证据(截图/HTML)
② 定位成因
→ 对照 5.2 的六大成因逐条排查
③ 修复
→ 时序问题修等待;数据问题改隔离;定位问题改选择器
④ 修不好怎么办
→ 果断隔离(@Disabled + 注明原因与责任人)
→ 但必须有人跟进,不能"隔离了就算完了" ✗✗★ 第 ④ 条的边界:
隔离是"止损",不是"解决" ✓
被隔离的用例要列入待办,否则它会永远躺在报告里当"跳过" ✗(T2-017 5.2 ✓)
try {
assertEquals(expected, actual);
} catch (AssertionError e) {
Files.write(dir.resolve("f.png"), ((TakesScreenshot) driver)
.getScreenshotAs(OutputType.BYTES));
throw e; // ★ 不要吞掉异常
}@AfterEach @AfterEach
void tearDown(TestInfo info) {
if (info.getStatus() == TestExecutionResult.Status.FAILED) { // 只失败时留证
saveArtifacts(info.getDisplayName());
}
if (driver != null) { driver.quit(); }
}for (int i = 0; i < 3; i++) {
try { ...; return; } catch (RuntimeException ignored) { } // ✗ 失败被吞
}
throw new IllegalStateException("重试后仍失败"); // ✗ 但丢掉了原始原因★ 它有两个问题:① 掩盖了失败次数;② 抛出的异常丢失了第一次的堆栈 ✗✗
测试代码:labs/ui-testing/src/test/java/cn/kb/learn/ui/UiStabilityTest.java
四个用例:
| 用例 | 验证什么 | 结果 |
|---|---|---|
retry_cannot_fix_real_defect | 重试救不了确定性缺陷 ✓ | 通过 ✓ |
proper_wait_beats_retry | 正确等待胜过重试 ✓ | 通过 ✓ |
failure_should_leave_artifacts | 失败留证三件套 ✓ | 通过 ✓ |
flaky_anti_patterns | flaky 自查清单 ⚠️ | 首跑失败 ✗ → 修复后通过 ✓ |
★ 实测(首跑,真实记录):
Tests run: 4, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 32.46 s
--- in cn.kb.learn.ui.UiStabilityTest★ 那个 Failure 的内容:
flaky_anti_patterns -- Time elapsed: 7.208 s <<< FAILURE!
AssertionFailedError: 初始数据是 2 条 ==> expected: <2> but was: <5>★ 另一组关键数据(proper_wait_beats_retry):
===== 正确等待 vs 重试 =====
等 300ms(太短):超时,耗时 300+ ms
等 10s(条件驱动):通过,耗时约 2000 ms —— 条件一满足就返回,不是死等 10 秒
结论:把"等多久"换成"等什么条件",抖动就消失了,根本用不到重试★ 这三条数据合起来说明了本节的全部论点 ✓✓:
| 数据 | 说明 |
|---|---|
| 重试 3 次仍失败 | 重试救不了真缺陷 ✓ |
| 条件驱动约 2 秒通过 | 修等待比加重试有效 ✓ |
| 讲 flaky 的用例自己 flaky 了 | 数据显示的比讲的更有说服力 ✓✓ |
data-loaded 的等待改成 Thread.sleep(500):多次运行观察成功率(会发现偶发失败)★★;driver.quit():观察 msedge 进程累积 ⚠️(T2-002);target/ui-artifacts 里的 HTML:亲手体验"从源码看出失败原因" ★★★;| # | 错误 | 说明 |
|---|---|---|
| 1 | "点重试"成为默认动作 ✗✗ | 训练团队忽略红灯(第 4 节)✗✗ |
| 2 | 用重试掩盖时序问题 ✗✗ | 失败率从 20% 降到 2%,问题还在 ✗ |
| 3 | 断言"精确的初始数据量" ✗✗ | 本轮真实踩坑(5.2②)✗✗ |
| 4 | 不做失败留证 ✗✗ | 次日无法复盘(5.3)✗ |
| 5 | 重试后不记录"重试过" ✗ | 抖动率被隐藏 ✗ |
| 6 | 把 flaky 用例隔离后就忘了 ✗✗ | 它永远不会被修 ✗✗ |
| 7 | 重试时抛出"干净的异常",丢掉原始堆栈 ✗ | 排查无据 ✗ |
目标:亲手制造一次 flaky,并用"证据"定位它。
步骤:
scripts\ui-test.ps1,确认 UiStabilityTest 四个用例的结果 ✓;flaky_anti_patterns 的断言改回 assertEquals(2, ...),重跑 → 复现 expected: <2> but was: <N> ★★★;>= 2,重跑 → 确认通过 ✓;target/ui-artifacts,用截图 + HTML 解释一次失败 ✓。验收方式:能说出"第 3 步"的答案(提示:同一个应用实例被多个用例共享)✓。
扩展任务:给测试加"用例隔离"能力:
for (int i = 0; i < 3; i++) {
try {
driver.findElement(By.id("btn")).click();
return;
} catch (Exception e) {
// ignore
}
}这段重试代码有什么问题? 5. (编程)给 UiStabilityTest 加一个"重试留痕"的示例:重试成功时打印"第 N 次才通过",并说明为什么这个信息必须出现在报告里。
Thread.sleep 固定等待(看起来像"环境慢")✗;依赖初始数据 ✓(5.2②)《UI 并行执行与冒烟测试选择》:UI 测试太慢,怎么提速而不牺牲价值。