Skip to content

UI 测试稳定性与失败重试边界 ​

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

1. 本节定位 ​

《Page Object 与框架封装》解决了"代码怎么组织";本节解决UI 测试最致命的敌人:

text
"这条用例挂了" → 点重试 → 绿了 → 继续干活      ← 最危险的工作方式 ✗✗

flaky(时好时坏)的用例比"一直失败的用例"危害更大 ✓✓ ——因为前者会让你学会"不信任测试" ✓

2. 学习目标 ​

  • 说出 flaky 的代价,以及"点重试"文化是怎么形成的 ★★★;
  • 说清重试能救什么、绝不能救什么 ★★★;
  • 说出 flaky 的六大成因,并能在报告里识别它们 ★★★;
  • 会写"失败留证"(截图 + HTML + 上下文) ★★★;
  • 知道"重试的正确用法"与其边界 ★★;
  • 知道"隔离并记录 flaky"的处理流程 ★★。

3. 前置知识检查 ​

  • 完成 T2-010 的 PO 实测了吗?✓
  • 知道显式等待与隐式等待的差别吗?(T2-009)✓

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

先算一笔账 ✗:

text
第 1 周:一条用例偶发失败 → 点重试过去了 ✓
第 2 周:三条用例偶发失败 → 点重试过去了 ✓
第 4 周:十条用例偶发失败 → 反正点重试就行,先合并吧 ✓
第 8 周:真失败来了,大家以为是 flaky → 点重试 → 还是红 → 没人看 → 事故 ✗✗✗

★ flaky 的真正代价不是"浪费时间" ✓ ——而是:

它训练团队"忽略红灯" ✓✓

当红灯不再意味着"有问题"时,测试就失去了全部价值 ✗✗

5. 核心概念 ​

5.1 重试能救什么、不能救什么 ★★★ ​

这是本节最核心的一张表:

失败类型重试有用吗为什么
真缺陷(元素不存在、断言错)✗完全无用 ✗✗它每次都失败,重试只是把"立刻红"变成"晚点红"
外部抖动(网络瞬断、第三方超时)★有用 ✓下次可能就好了 ✓
时序问题(等待不足)⚠️看起来有用 ✗✗重试掩盖了"等待写错了"这个真问题 ✗
资源竞争(并发抢同一份数据)⚠️偶尔有用 ✗治标不治本 ✗

★★ 第二行和第三行的区别最关键:

重试"治好了"时序问题,只是把失败概率从 20% 降到 2% ✗

而 2% 的失败会以"偶尔红一下"的形式长期存在 ✓✓

正确做法是修等待,而不是加重试 ✓

★ 本节的实测(UiStabilityTest.retry_cannot_fix_real_defect,已通过 ✓):

java
// 页面上不存在这个元素 —— 这是"确定性缺陷",不是"慢"
By missing = By.id("this-element-does-not-exist");

// 重试 3 次,每次等待 400ms
runWithRetry(3, () -> new WebDriverWait(driver, Duration.ofMillis(400))
        .until(ExpectedConditions.visibilityOfElementLocated(missing)));

输出:

text
===== 重试救不了真缺陷 =====
  重试 3 次 × 每次等待 400ms → 总共耗时 1200+ ms 后仍然失败
  结论:重试只是把"立刻失败"变成"晚一点失败",
       却让报告上的失败次数看起来减少了 —— 这是最危险的用法

★ 划重点:重试让"失败次数"减少,但缺陷没修 ✓✓

5.2 flaky 的六大成因 ★★★ ​

#成因特征修法
①Thread.sleep 固定等待 ✗✗慢机器上必失败换成显式等待条件 ✓
②依赖上一个用例的数据 ✗✗单跑通过、全跑失败 ★★每个用例自建自清 ✓
③绝对下标定位(第 3 个按钮)✗列表一增删就点错用 data-* 属性相对定位 ✓
④断言瞬时状态("加载中")✗竞态断言最终状态 ✓
⑤全局共享浏览器实例 ✗用例互相污染每个用例独立 driver ✓
⑥视口尺寸不固定 ⚠️响应式布局导致元素不同固定 --window-size ✓(T2-002)

★★★ 本项目第 ② 条的真实记录 ✓✓✓:

在写这一节时,讲"flaky 成因"的那个测试自己因为"依赖初始数据"而失败了 ✓

text
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 条")✓:

java
// ★ 刻意写成"至少 2 条",而不是"等于 2 条":
//   同一个应用实例被多个用例共享时,前面的用例会改变数据。
assertTrue(driver.findElements(By.cssSelector("tr.order-row")).size() >= 2,
        "至少应有 2 条初始订单;但绝不能断言精确数量");

★★ 更根本的认识:

"依赖初始数据"不只是"怕别人改" ✓ ——还有"怕自己前面的用例改" ✓✓

所以判据是:"这个用例单独跑和全量跑,结果一样吗?" ✓✓

5.3 失败必须留证 ★★★ ​

UI 失败只在一瞬间 ✓ ——而次日复盘时,你只有报告里的一行异常 ✗

三件套(本项目的 UiStabilityTest.failure_should_leave_artifacts,已通过 ✓):

java
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 确实还没加上 ✓✓

★ 本项目把证据写到:

text
labs/ui-testing/target/ui-artifacts/failure-<时间戳>.{png,html,txt}

(target/ 目录不会进版本库,但会被 CI 归档 —— T2-017 5.3 ✓)

5.4 重试的正确用法 ★★ ​

如果一定要重试,把它限制在"已知的外部抖动"上 ✓:

可以重试绝不能重试
第三方接口偶发超时 ✓元素定位失败 ✗✗
测试环境的网络瞬断 ✓断言失败 ✗✗
平台自身的资源调度 ⚠️"我不知道为什么失败" ✗✗✗

★ 最后一行是最重要的拒绝理由:

"我不知道为什么失败"时加的重试,等于把未知变成隐藏 ✓✓

应该做的是:让它失败、留证据、去查 ✓

★ 一个可操作的判据:

text
问:我能说清"这次失败的具体原因"吗?
  能,且原因是"外部依赖偶发" → 可以重试 ✓
  不能 / 原因是"时序" → 去修等待,不要重试 ✗✗

★ 如果真要重试,也要"留痕" ✓:

重试成功的用例必须在报告里标出来(如"重试第 2 次通过")✓✓

否则它会变成一个"永远绿"的假象,而抖动率在悄悄上升 ✗✗

5.5 把 flaky 当缺陷处理 ★★★ ​

正确的流程(这是团队级约定,不是技术细节)✓:

text
① 发现 flaky
   → 记录:用例名、失败率、失败时的证据(截图/HTML)

② 定位成因
   → 对照 5.2 的六大成因逐条排查

③ 修复
   → 时序问题修等待;数据问题改隔离;定位问题改选择器

④ 修不好怎么办
   → 果断隔离(@Disabled + 注明原因与责任人)
   → 但必须有人跟进,不能"隔离了就算完了" ✗✗

★ 第 ④ 条的边界:

隔离是"止损",不是"解决" ✓

被隔离的用例要列入待办,否则它会永远躺在报告里当"跳过" ✗(T2-017 5.2 ✓)

6. 递进示例 ​

6.1 最小示例:留证 ​

java
try {
    assertEquals(expected, actual);
} catch (AssertionError e) {
    Files.write(dir.resolve("f.png"), ((TakesScreenshot) driver)
            .getScreenshotAs(OutputType.BYTES));
    throw e;                       // ★ 不要吞掉异常
}

6.2 业务示例:把留证放进 @AfterEach ​

java
@AfterEach
void tearDown(TestInfo info) {
    if (info.getStatus() == TestExecutionResult.Status.FAILED) {   // 只失败时留证
        saveArtifacts(info.getDisplayName());
    }
    if (driver != null) { driver.quit(); }
}

6.3 反例:吞掉异常的重试 ​

java
for (int i = 0; i < 3; i++) {
    try { ...; return; } catch (RuntimeException ignored) { }   // ✗ 失败被吞
}
throw new IllegalStateException("重试后仍失败");                  // ✗ 但丢掉了原始原因

★ 它有两个问题:① 掩盖了失败次数;② 抛出的异常丢失了第一次的堆栈 ✗✗

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

测试代码: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_patternsflaky 自查清单 ⚠️首跑失败 ✗ → 修复后通过 ✓

★ 实测(首跑,真实记录):

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

★ 那个 Failure 的内容:

text
flaky_anti_patterns -- Time elapsed: 7.208 s <<< FAILURE!
AssertionFailedError: 初始数据是 2 条 ==> expected: <2> but was: <5>

★ 另一组关键数据(proper_wait_beats_retry):

text
===== 正确等待 vs 重试 =====
  等 300ms(太短):超时,耗时 300+ ms
  等 10s(条件驱动):通过,耗时约 2000 ms —— 条件一满足就返回,不是死等 10 秒
  结论:把"等多久"换成"等什么条件",抖动就消失了,根本用不到重试

★ 这三条数据合起来说明了本节的全部论点 ✓✓:

数据说明
重试 3 次仍失败重试救不了真缺陷 ✓
条件驱动约 2 秒通过修等待比加重试有效 ✓
讲 flaky 的用例自己 flaky 了数据显示的比讲的更有说服力 ✓✓

8. Debug 观察点 ​

  1. 把一个断言失败用例跑三遍:观察它每次都失败 ★ ——这就是"确定性缺陷" ✓;
  2. 把 data-loaded 的等待改成 Thread.sleep(500):多次运行观察成功率(会发现偶发失败)★★;
  3. 注释掉 driver.quit():观察 msedge 进程累积 ⚠️(T2-002);
  4. 打开 target/ui-artifacts 里的 HTML:亲手体验"从源码看出失败原因" ★★★;
  5. 统计某个用例"单跑 vs 全量跑"的结果:如果有差异,就是用例间污染 ★★★。

9. 常见错误 ​

#错误说明
1"点重试"成为默认动作 ✗✗训练团队忽略红灯(第 4 节)✗✗
2用重试掩盖时序问题 ✗✗失败率从 20% 降到 2%,问题还在 ✗
3断言"精确的初始数据量" ✗✗本轮真实踩坑(5.2②)✗✗
4不做失败留证 ✗✗次日无法复盘(5.3)✗
5重试后不记录"重试过" ✗抖动率被隐藏 ✗
6把 flaky 用例隔离后就忘了 ✗✗它永远不会被修 ✗✗
7重试时抛出"干净的异常",丢掉原始堆栈 ✗排查无据 ✗

10. 动手实验 ​

目标:亲手制造一次 flaky,并用"证据"定位它。

步骤:

  1. 跑 scripts\ui-test.ps1,确认 UiStabilityTest 四个用例的结果 ✓;
  2. 把 flaky_anti_patterns 的断言改回 assertEquals(2, ...),重跑 → 复现 expected: <2> but was: <N> ★★★;
  3. 思考:为什么"单跑这个类"会通过,而"全量跑"会失败?★;
  4. 把断言改成 >= 2,重跑 → 确认通过 ✓;
  5. 打开 target/ui-artifacts,用截图 + HTML 解释一次失败 ✓。

验收方式:能说出"第 3 步"的答案(提示:同一个应用实例被多个用例共享)✓。

扩展任务:给测试加"用例隔离"能力:

  • 每个用例开始前,记录"当前订单数" ✓;
  • 用例结束后,把新增的订单删掉(回到初始数量)✓;
  • 说明:为什么"恢复现场"比"断言初始值"更可靠(提示:前者不依赖任何假设)✓✓

11. 练习题 ​

  1. 判断:重试可以提高测试的稳定性。(对 / 错)
  2. 填空:flaky 的六大成因里,**最容易被误认为"环境问题"**的是 ______;本项目真实踩到的是 ______。
  3. 简答:为什么"重试次数减少"看起来像好事,其实很危险?
  4. (代码阅读)
java
for (int i = 0; i < 3; i++) {
    try {
        driver.findElement(By.id("btn")).click();
        return;
    } catch (Exception e) {
        // ignore
    }
}

这段重试代码有什么问题? 5. (编程)给 UiStabilityTest 加一个"重试留痕"的示例:重试成功时打印"第 N 次才通过",并说明为什么这个信息必须出现在报告里。

参考答案(要点)
  1. 错。重试只能覆盖"外部抖动" ✓ ——对真缺陷与时序问题都是掩盖 ✓(5.1)
  2. Thread.sleep 固定等待(看起来像"环境慢")✗;依赖初始数据 ✓(5.2②)
  3. 因为它让失败"看不见" ✗ ——失败率只是下降,没有归零;而团队学会了"点重试" ✓★(第 4 节)
  4. ① 吞掉了所有异常,原始原因丢失 ✗;② 无论成功失败都没有留证 ✗; ③ "元素找不到"这类确定性缺陷也被重试了 3 次 ✗(5.1)
  5. 要点:用计数器记录第几次成功 ✓ ——这个信息必须进报告,因为"重试第 3 次才过"意味着抖动率很高 ✓

12. 掌握标准 ​

  • [ ] 能说出 flaky 的真正代价(训练团队忽略红灯) ★★★;
  • [ ] 能说清重试能救什么、绝不能救什么 ★★★;
  • [ ] 说出 flaky 的六大成因,并会对照排查 ★★★;
  • [ ] 会写失败留证三件套,并知道"源码比截图更重要" ★★★;
  • [ ] 知道"我不知道为什么失败"是拒绝重试的理由 ★★★;
  • [ ] 知道"依赖初始数据"也包括"被自己前面的用例改" ★★★;
  • [ ] 知道隔离 flaky 必须有人跟进 ✓。

13. 扩展资源 ​

14. 下一步学习 ​

《UI 并行执行与冒烟测试选择》:UI 测试太慢,怎么提速而不牺牲价值。

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

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