<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Flaky on 秋 草 观 “测” 台</title><link>https://chengxiaqiucao.github.io/tags/flaky/</link><description>Recent content in Flaky on 秋 草 观 “测” 台</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>|by 城下秋草（公众号： 秋草说测试）</copyright><lastBuildDate>Wed, 04 Nov 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://chengxiaqiucao.github.io/tags/flaky/index.xml" rel="self" type="application/rss+xml"/><item><title>被重试掩盖的失败：把 CI 里"第二次通过"变成可排查的缺陷队列</title><link>https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/</link><pubDate>Wed, 04 Nov 2026 09:00:00 +0800</pubDate><guid>https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/</guid><description>&lt;img src="https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/cover.webp" alt="Featured image of post 被重试掩盖的失败：把 CI 里"第二次通过"变成可排查的缺陷队列" />&lt;p>一次失败、一次通过、最终绿灯。这是大多数启用自动重试的 CI 流水线每天产生的结果，也是最容易丢失信息的结果：失败的那次尝试在日志里被覆盖、在成功任务的清理中被删除，团队只看到&amp;quot;这次构建是绿的&amp;quot;。真正的问题在于，能让第二次通过的原因往往不是&amp;quot;随机抖动&amp;quot;，而是某个具体的状态、时序或数据条件——它同样会在生产环境里被用户撞上。&lt;/p>
&lt;p>有一套做法值得完整拆解：在测试套件结束时把每次重试写成结构化记录并单独留存，按周汇成一份按影响面排序的清单，再让编码代理沿着记录进入代码去定位根因。它不追求&amp;quot;让 CI 更绿&amp;quot;，而是反过来，把被重试压下去的失败重新暴露出来。&lt;/p>
&lt;h2 id="第一步让失败的那次尝试活过成功清理">第一步：让失败的那次尝试活过成功清理
&lt;/h2>&lt;p>多数 CI 配置会在任务成功后删除应用日志与诊断产物，这是合理的成本控制，但恰好会删掉排查间歇性失败最需要的证据。解决办法不是保留全部日志，而是在套件结束时，为每个发生过重试的测试节点写一个很小的 JSON 文件：&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;span class="lnt">4
&lt;/span>&lt;span class="lnt">5
&lt;/span>&lt;span class="lnt">6
&lt;/span>&lt;span class="lnt">7
&lt;/span>&lt;span class="lnt">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;./spec/features/report_spec.rb[1:2]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;location&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;./spec/features/report_spec.rb:20&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;renders a synthetic report&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;failed_attempts&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;final_result&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;passed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;first_error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;expected the default window size&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>里面有两个字段值得单独说。&lt;code>id&lt;/code> 是测试框架给出的唯一示例编号，&lt;code>location&lt;/code> 是源码行号。共享示例（shared examples）会让多个不同测试用例指向同一行，只按行号聚合会把互不相关的失败合并成一团，两个都留才能正确分组。&lt;/p>
&lt;p>这份 JSON 要写到一个&lt;strong>独立于成功任务清理范围&lt;/strong>的目录，并作为构建产物上传。以常见的 CI 服务为例，只需要在配置里加一行把该目录声明为 artifacts；换任何 CI 平台，等价机制都能做到。核心约束只有一条：即便这次测试最终通过，重试历史也必须保留下来。&lt;/p>
&lt;p>重试策略本身也要写清楚：普通用例给一次重试，涉及 JavaScript 浏览器的用例给两次。次数不是重点，重点是&lt;strong>每次重试都要留下为什么结果会变&lt;/strong>的线索。单纯增加重试次数只会让报告更安静，不会回答问题。&lt;/p>
&lt;h2 id="第二步把记录汇成一份有优先级的周度队列">第二步：把记录汇成一份有优先级的周度队列
&lt;/h2>&lt;p>有了记录，下一步是聚合。一个定时任务通过 CI 平台 API 读取过去一周的重试产物，按示例编号分组，然后按&lt;strong>出现过&amp;quot;先失败后通过&amp;quot;的不同流水线数量&lt;/strong>降序排列。&lt;/p>
&lt;p>这里有个容易做错的细节：同一个流水线里某用例失败两次，排名只算一条流水线。因为排序想衡量的是&amp;quot;这个不稳定问题影响过多少次独立构建&amp;quot;，而不是&amp;quot;它一共失败了多少次&amp;quot;。同时保留首次错误信息、分支名与失败尝试次数，供后续阅读。&lt;/p>
&lt;p>还有一类要单独计数：&lt;strong>所有尝试都失败&lt;/strong>的用例。这个区分只描述观测到的运行结果，并不等于&amp;quot;根因是确定性的&amp;quot;——一个间歇性问题同样可能把重试次数耗尽，所以不能据此把两类问题贴上不同标签。&lt;/p>
&lt;p>当某个示例超过设定的周度预算阈值时，让摘要任务本身失败，借用团队已有的 CI 失败通知把清单推到人面前。此时把报告交给能读取代码仓库与构建产物的编码代理，就进入定位环节。&lt;/p>
&lt;p>&lt;img src="https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/01-fig1_hu_83cb9ef2bd28c4d1.webp"
width="1024"
height="820"
srcset="https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/01-fig1_hu_ed4aa59c7fcf933b.webp 480w, https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/01-fig1_hu_83cb9ef2bd28c4d1.webp 1024w"
loading="lazy"
class="gallery-image"
data-flex-grow="124"
data-flex-basis="299px"
>
&lt;/p>
&lt;p>重试记录从产生、留存到形成周度优先级队列的完整链路&lt;/p>
&lt;h2 id="第三步看清第二次为什么通过四个真实案例">第三步：看清&amp;quot;第二次为什么通过&amp;quot;——四个真实案例
&lt;/h2>&lt;p>下面几类问题有个共同点：重试并不是让测试自己恢复了，而是&lt;strong>重试机制顺手重置了出错的那个状态&lt;/strong>。如果不看第一次失败，根本不会知道产品里有缺陷。&lt;/p>
&lt;h3 id="浏览器重启抹掉了上一个用例留下的窗口尺寸">浏览器重启抹掉了上一个用例留下的窗口尺寸
&lt;/h3>&lt;p>一组截图比对失败，报的是截图尺寸变化，重试通常就过了。根因在更早的用例：计费相关测试把浏览器窗口改成了非默认尺寸，却没有还原，后面的截图用例继承了这个 viewport。而重试配置会在重跑前重启浏览器，窗口回到默认尺寸，于是第二次通过。&lt;/p>
&lt;p>修复方式是在每个浏览器用例开始前显式设定默认窗口尺寸，需要其他尺寸的用例自己再改。这个案例里，&amp;ldquo;重试通过了&amp;quot;恰好提供了关键线索：既然换一个新浏览器就好，说明状态是外部带进来的，不是被测逻辑自己恢复的。&lt;/p>
&lt;h3 id="一个撇号打断自动生成的-curl-命令">一个撇号打断自动生成的 cURL 命令
&lt;/h3>&lt;p>某仪表盘会根据模板 schema 生成示例 cURL 命令，测试用生成数据执行这些命令。生成的假姓名里偶尔带撇号，而内联命令用单引号包住 JSON 请求体——撇号提前结束了 shell 字符串，命令就断了。下一次尝试生成了另一个名字，命令又能跑通。&lt;/p>
&lt;p>同一功能还有一个可下载的 Bash 脚本，把 JSON 放在带引号的 heredoc 里，却错误地套用了为单引号参数准备的转义，结果改坏了请求体。两处需要完全不同的处理：内联命令要为 shell 上下文转义撇号，heredoc 里的 JSON 则必须原样保留。&lt;/p>
&lt;p>对应的回归测试很值得借鉴：把 &lt;code>curl&lt;/code> 替换成一个函数，在 Bash 里真实执行生成的脚本，用该函数捕获本该发出的请求体，再解析校验原始值，全程不发起网络请求。用 &lt;code>Editor's proof&lt;/code> 这类带撇号的合成值回放修复前后两个版本：修复前内联命令在 Bash 里直接失败，可下载脚本能跑但请求体无法解析为 JSON；修复后两者都保留了原文。&lt;/p>
&lt;p>这里最容易被选错的&amp;quot;捷径&amp;quot;是改测试数据、不让姓名里出现撇号。那会让测试更稳定，同时让产品对所有合法输入继续出错。&lt;/p>
&lt;h3 id="临时文件在渲染进程读取前被回收">临时文件在渲染进程读取前被回收
&lt;/h3>&lt;p>一个内联的 HTML 转 PDF 渲染器，会把页眉页脚 HTML 写入临时文件，再把路径交给渲染进程。代码在构造命令时把临时文件对象放在局部变量里；方法返回后，命令字符串还留着路径，但已经没有任何引用持有那个对象。&lt;/p>
&lt;p>Ruby 的 &lt;code>Tempfile&lt;/code> 把清理挂在垃圾回收上。如果 GC 恰好在渲染进程打开文件之前触发，文件就已经不在了。测试是否通过，取决于垃圾回收发生的时机——这是最典型的&amp;quot;看似随机&amp;quot;的失败。&lt;/p>
&lt;p>修复是把页眉页脚对象保存在服务实例上，直到渲染完成，再在 &lt;code>ensure&lt;/code> 块里关闭并删除。回归测试在&amp;quot;构造命令&amp;quot;与&amp;quot;模拟渲染进程读取文件&amp;quot;之间&lt;strong>强制触发 GC&lt;/strong>，断言文件内容仍然可读、且结束后被清理。主动制造确定性的失败条件，比等着在某个 PDF 用例里&amp;quot;撞运气&amp;quot;要可靠得多。&lt;/p>
&lt;h3 id="冻结了-ruby-的时钟redis-的时钟还在走">冻结了 Ruby 的时钟，Redis 的时钟还在走
&lt;/h3>&lt;p>限流测试用的计数器按固定时间窗口统计。用例一旦跨过窗口边界，请求就被计到新窗口里，预期阈值永远达不到。第一版修复是冻结进程时钟，但计数器存在 Redis 里并带过期时间，Redis 仍在按真实时间流逝。如果在窗口末尾附近冻结，计数器的剩余存活时间极短，问题依旧。&lt;/p>
&lt;p>最终做法是冻结在整点起始处，与这批用例使用的时间窗口对齐，让计数器拥有完整过期周期；同时仍需保证用例在该周期内跑完。这个案例的价值在于：评审在第一版修复之后发现了第二个时钟，第一次的解释只覆盖了系统里的一半计时机制。&lt;/p>
&lt;p>同一轮排查还带出另外几类问题：数据库清理开始时后台 worker 仍在运行、测试辅助函数对共享时钟库的冻结状态相互覆盖、样本数据生成器用真值判断跳过了下界为零时的边界调整（在 JavaScript 里 0 是 falsy，但它在这个 schema 里是合法下界）。每一类都属于&amp;quot;重试会让它消失、用户却可能遇到&amp;quot;的范畴。&lt;/p>
&lt;p>&lt;img src="https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/01-fig2_hu_81b443d809eab84c.webp"
width="1024"
height="683"
srcset="https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/01-fig2_hu_2bea94c3ea199544.webp 480w, https://chengxiaqiucao.github.io/post/148-flaky-retry-ci-digest/01-fig2_hu_81b443d809eab84c.webp 1024w"
loading="lazy"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>四类被重试掩盖的真实缺陷：状态泄漏、转义上下文、对象生命周期、双时钟&lt;/p>
&lt;h2 id="第四步给编码代理划清任务边界">第四步：给编码代理划清任务边界
&lt;/h2>&lt;p>把周度报告交给编码代理时，任务描述应当明确到可验收，而不是&amp;quot;帮忙看看这些失败&amp;rdquo;。一套可复用的要求是：&lt;/p>
&lt;ul>
&lt;li>找到对应的 CI 运行记录并读取失败产物，沿着测试代码追到应用代码；&lt;/li>
&lt;li>能在本地复现的先复现，再补一条回归测试，要求修复前失败、修复后通过；&lt;/li>
&lt;li>互不相关的根因拆成独立提交，便于逐条评审与回滚；&lt;/li>
&lt;li>跑完受影响的检查并自查 diff，无法复现的项目明确列出，不得含糊带过；&lt;/li>
&lt;li>推送之前停下来等待人工审批。&lt;/li>
&lt;/ul>
&lt;p>之所以强调&amp;quot;产物与代码要一起读&amp;quot;，是因为单看任一边都得不出结论：截图尺寸不匹配本身解释不了 viewport 是被谁改的，文件不存在本身解释不了那个 Ruby 对象为什么会变成可回收状态。代理真正的价值是把这两类信息串起来，而不是替代人做最终判断。&lt;/p>
&lt;p>排查结论里凡是&amp;quot;后续开发必须遵守&amp;quot;的规则，写进离代码最近的 &lt;code>AGENTS.md&lt;/code>（或等价的团队约定文件）。比如&amp;quot;浏览器用例可以改自己的 viewport，但下一个用例必须从已知尺寸开始&amp;quot;——setup 代码负责强制，注释负责解释原因。更进一步，能把规则做成静态检查的就别靠记忆：为常见的 flaky 写法补一条自定义 lint 规则，让 CI 直接拦下，比指望有人记得半年前那次排查更有效。&lt;/p>
&lt;h2 id="落地清单">落地清单
&lt;/h2>&lt;p>如果你的套件已经在重试，不必先重构整套体系，按下面的顺序做即可：&lt;/p>
&lt;ol>
&lt;li>在套件结束时为每次重试写一条结构化记录，字段至少包含唯一示例编号、源码位置、失败尝试次数、最终结果和首次错误；&lt;/li>
&lt;li>把记录放到成功任务清理不到的目录，并配置为构建产物上传；&lt;/li>
&lt;li>每周按&amp;quot;出现过先失败后通过的不同流水线数&amp;quot;聚合排序，设一个预算阈值，超了就让摘要任务失败并通知；&lt;/li>
&lt;li>挑一条反复出现的问题，要求给出&amp;quot;第二次为什么通过&amp;quot;的明确解释，而不是简单加重试次数；&lt;/li>
&lt;li>修复必须附带能确定性复现的回归测试，审查通过后再合入常规 CI、QA 与发布流程。&lt;/li>
&lt;/ol>
&lt;p>这套流程目前还没有公开的工时节省数据，修复轮次也太新，暂时无法判断长期能把复发率压到多少，后续几周的周报才能回答。但它的直接效果已经很清楚：过去需要人专门腾出半天去追、且经常追到一半线索就消失的问题，现在可以在启动排查后并行去做别的事，回来只需评估解释是否成立、审查改动是否合理。&lt;/p>
&lt;p>也正因如此，代理写出的代码质量变得更重要：一个可复现的失败、一条断言真实意图的测试，是它能锚住的具体目标。而把重试历史保留下来，等于把更多这类目标摆到了桌面上。&lt;/p></description></item><item><title>移动端 E2E 为什么会 flaky：四种 Web 测试遇不到的失效模式</title><link>https://chengxiaqiucao.github.io/post/146-mobile-e2e-flakiness/</link><pubDate>Wed, 28 Oct 2026 09:00:00 +0800</pubDate><guid>https://chengxiaqiucao.github.io/post/146-mobile-e2e-flakiness/</guid><description>&lt;img src="https://chengxiaqiucao.github.io/post/146-mobile-e2e-flakiness/cover.webp" alt="Featured image of post 移动端 E2E 为什么会 flaky：四种 Web 测试遇不到的失效模式" />&lt;p>重试不是修复。加长超时不是修复。隔离清单不是修复。那个团队共用的、专门用来发&amp;quot;已知不稳定，忽略即可&amp;quot;的 Slack 频道，更不是修复。&lt;/p>
&lt;p>这些话大多数测试团队都认同，但真正的问题在于：移动端 E2E 测试之所以不稳定，原因和 Web 测试根本不是同一类；而每一个从 Web 玩法里借来的解法，都会让问题在结构上持续恶化。&lt;/p>
&lt;p>一个反复出现的过程是这样的：一个团队接手端到端测试套件，测试开始失败时加自动重试，重试不够就把超时调长，最后彻底不再信任这套用例。CI 变红时没人去看，也没人知道那是真的缺陷还是&amp;quot;又是一个普通的周二&amp;quot;。&lt;/p>
&lt;p>这不是纪律问题。问题在于移动端 E2E 有四种 Web 端根本不存在的失效模式，而 Web 优先的修复思路，把错误的心智模型一起搬了过来。&lt;/p>
&lt;h2 id="移动端的不稳定是一个不同的问题">移动端的不稳定是一个不同的问题
&lt;/h2>&lt;p>Web 自动化跑在浏览器里。浏览器是一个沙箱化的、大体确定性的环境：DOM 可查询、动画可关闭、网络请求可拦截、渲染引擎跨运行表现可预测。Web 测试不稳定时，原因也早已被研究清楚——异步等待、测试顺序依赖、共享状态污染，任何一本测试教材都会讲到。&lt;/p>
&lt;p>移动端自动化跑在一整个操作系统之上，这一差别改变了所有从 Web 测试借来的假设。&lt;/p>
&lt;p>一个移动应用测试必须应付测试脚本从未触发过的系统级中断；必须应付一个&amp;quot;点击时机差 10 毫秒结果就不同&amp;quot;的手势模型；必须活过那些在第 47 次运行和第 1 次运行行为不同的 CI 模拟器；还必须在一个设备生态中产出一致结果——仅 Android 就横跨数千家厂商和数十个仍在活跃的操作系统版本。&lt;/p>
&lt;p>用 Web 的解法修移动端的不稳定，就像拿汽车刹车维修手册去诊断飞机液压系统故障：词汇是重叠的，物理规律不是。&lt;/p>
&lt;p>针对 Web 成因的那些修复（统一异步等待模式、隔离测试状态、强制测试顺序）对 Web 的问题是对的。放到移动端，它们大概只能解决一半问题，另一半的成因不同，需要不同的解法。&lt;/p>
&lt;p>从数据看，这个问题并没有随着工具成熟而好转。一份覆盖三年、超过一千万次构建的移动开发行业报告显示，报告测试不稳定的移动团队比例从 2022 年的 10% 上升到 2025 年的 26%。流水线越复杂，不稳定越严重。&lt;/p>
&lt;p>&lt;img src="https://chengxiaqiucao.github.io/post/146-mobile-e2e-flakiness/05-fig1_hu_a35256f31869fbc3.webp"
width="1024"
height="683"
srcset="https://chengxiaqiucao.github.io/post/146-mobile-e2e-flakiness/05-fig1_hu_53f7eb880eafdad6.webp 480w, https://chengxiaqiucao.github.io/post/146-mobile-e2e-flakiness/05-fig1_hu_a35256f31869fbc3.webp 1024w"
loading="lazy"
class="gallery-image"
data-flex-grow="150"
data-flex-basis="360px"
>
&lt;/p>
&lt;p>上半：模拟器响应能力随运行推进持续劣化；下半：手势时序的判定边界并不等距&lt;/p>
&lt;h2 id="四种-web-测试从未见过的失效模式">四种 Web 测试从未见过的失效模式
&lt;/h2>&lt;h3 id="一模拟器行为漂移">一、模拟器行为漂移
&lt;/h3>&lt;p>CI 流水线跑在模拟器上，不是真机。模拟器是硬件的软件模拟，和任何运行在资源受限 CI 环境中的软件一样，它的行为会在&lt;strong>单次测试运行的过程中&lt;/strong>持续劣化。&lt;/p>
&lt;p>套件刚开始时模拟器响应良好：GPU 加速是热的、内存干净、虚拟 CPU 有余量。四十个用例之后，同一个模拟器可能正处在内存压力下、虚拟 CPU 正在降频，或者因为事件队列积压而开始丢弃手势事件。测试代码一模一样，环境已经漂了。&lt;/p>
&lt;p>Web CI 环境没有同等规模的问题，因为浏览器实例轻量且无状态。而模拟器跑的是一整个移动操作系统：摄像头驱动、蓝牙协议栈、网络模拟、GPS 模拟——哪怕你的测试只碰了一个登录页面，这些也全都在跑。&lt;/p>
&lt;p>典型症状是：稳定通过的位置 1–15 的用例，到了位置 30–50 开始间歇性失败。开发者的反应是加重试；重试偶尔有效，因为第二次尝试恰好赶上模拟器状态稍好的时刻；而根因——模拟器在运行中途改变了行为——从未被处理。&lt;/p>
&lt;h3 id="二手势时序敏感">二、手势时序敏感
&lt;/h3>&lt;p>移动端交互的本质是手势。一次轻点不是一次点击，一次滑动不是一次滚动，这个差别比听起来重要得多。&lt;/p>
&lt;p>浏览器点击会触发一组离散事件（mousedown、mouseup、click），DOM 以可预测的方式处理。移动端手势则是一串带有时间属性的触摸事件流，由操作系统结合上下文解释：按住 100 毫秒是轻点，按住 500 毫秒会触发长按上下文菜单；一次起手位置偏左 5 像素的滑动，在特定设备或系统版本上可能被识别成滚动而不是滑动。&lt;/p>
&lt;p>自动化框架确实暴露了手势 API，但底层时序行为因 iOS 与 Android 而异、因系统版本而异、也因模拟器与真机而异。一条在本地模拟器上稳定的手势命令，可能在一台虚拟化特征不同的 CI 宿主机上失败。长列表往往最先暴露这种差异，因为一次快速滑动跨越的行数每次都不同——这也是为什么无限滚动的测试通常会一次只滑一步、然后等目标单元格出现。&lt;/p>
&lt;p>Web 没有这个问题，点击事件在浏览器层被抽象掉了。移动端开发者没有等价的抽象层，只能直接对接一个因实现而异的操作系统手势模型。&lt;/p>
&lt;h3 id="三系统权限弹窗">三、系统权限弹窗
&lt;/h3>&lt;p>iOS 和 Android 会在应用流程中弹出系统级权限对话框：相机、麦克风、位置、通知、通讯录、蓝牙。这些弹窗不是你的测试触发的，是操作系统依据内部状态决定何时展示。&lt;/p>
&lt;p>弹窗一旦在测试中途出现，就会盖住界面。测试脚本尝试点击一个已经被遮挡的元素，点击失败，元素找不到，测试报告一条与被测代码毫无关系的失败。&lt;/p>
&lt;p>弹窗出现的时机并不完全确定：干净的模拟器状态可能在第 1 次运行不弹通知权限，而在第 3 次运行弹出，因为此时应用已安装得足够久，系统决定该提示了。团队的应对是在测试 setup 里加&amp;quot;关弹窗&amp;quot;逻辑；而系统行为随 iOS 与 Android 版本变化，在某版本上有效的 setup 脚本，在另一个版本上会静默失效。&lt;/p>
&lt;p>这一类不稳定尤其昂贵，因为失败报告指向的是错误的元素——脚本当时想点的那一个，而不是真正挡住它的那个弹窗。&lt;/p>
&lt;h3 id="四网络状态与设备碎片化">四、网络状态与设备碎片化
&lt;/h3>&lt;p>移动应用运行在 Web 应用基本不用面对的网络条件里：WiFi 与蜂窝网络切换、请求过程中应用被切到后台、运营商特有的 DNS 行为、以及随信号质量变化的延迟。&lt;/p>
&lt;p>一个在纯 WiFi 模拟器上通过的测试，可能在真机上因为中途切到蜂窝网络而失败；一个能处理 200 毫秒 API 响应的测试，可能在网络模拟设置不同的 CI 环境里失败。这些不是异步等待问题，而是&lt;strong>环境保真度问题&lt;/strong>：测试环境和真实运行条件不一致。&lt;/p>
&lt;p>设备碎片化会进一步放大它。Android 生态从 1GB 内存、跑 Android 9 的入门机，一直延伸到当前旗舰硬件。一个针对&amp;quot;高配虚拟机 + Android 14 模拟器&amp;quot;设计的测试，在真实入门硬件上会遇到渲染时序差异、内存管理差异和垃圾回收行为差异。仅屏幕密度差异一项，就足以让在某台设备上生效的点击坐标在另一台上落空。&lt;/p>
&lt;p>基于选择器的测试套件假设视图层级跨设备稳定——而它经常并不稳定。&lt;/p>
&lt;h2 id="为什么脚本级的修复会让情况更糟">为什么脚本级的修复会让情况更糟
&lt;/h2>&lt;p>面对移动端不稳定，标准动作是一条可预测的升级路径：先重试，因为它是最省力的杠杆；然后加超时，因为多给点时间通常能让模拟器漂移和手势时序问题最终蒙混过去；最后隔离，因为有些用例稳定地不稳定，团队干脆不再指望它们通过。&lt;/p>
&lt;p>三者各有代价，而且都不出现在 CI 仪表盘上。&lt;/p>
&lt;p>&lt;strong>重试会掩盖真实失败。&lt;/strong> 第三次重试才通过的用例，是一条恰好走运的不稳定用例；它同时也可能是一次被重试逻辑盖住的真实回归。有一项被广泛引用的研究发现，不稳定导致了其测试套件中 84% 的&amp;quot;通过转失败&amp;quot;状态迁移——这意味着依赖重试的团队，正在习惯性地把真实回归归因为&amp;quot;只是不稳定&amp;quot;。测试套件本该提供的信心信号，从此不再可信。&lt;/p>
&lt;p>&lt;strong>加超时让套件变慢，却不动根因。&lt;/strong> 把一条手势超时从 3 秒延长到 8 秒，确实能覆盖&amp;quot;模拟器需要更多时间处理手势事件&amp;quot;的情形，但它也给这条用例的每一次运行加了 5 秒。一个 200 条用例、每条多出 3 秒超时缓冲的套件，整体要多跑 10 分钟。反馈周期变长意味着开发者发现失败时已经切到下一个任务了。&lt;/p>
&lt;p>&lt;strong>隔离把问题推迟到一个永远不会到来的将来。&lt;/strong> &amp;ldquo;下个迭代再修隔离用例&amp;quot;几乎从不发生，因为修它们要先搞清根因，而搞清根因需要环境调查，比写新用例还慢。于是隔离清单越来越长；而那些被隔离的用例代表的是真实的覆盖空洞——回归就藏在那些空洞里。&lt;/p>
&lt;p>三种做法的共同问题是一样的：把一个结构性问题当成维护问题来处理。不稳定不是因为团队不够细致，而是因为&lt;strong>基于选择器的测试把用例耦合到了 UI 的实现细节上&lt;/strong>，而这些细节会随着应用演进、系统更新和 CI 环境漂移不断变化。&lt;/p>
&lt;p>有工程团队的公开数据是：即便在检测与监控工具上投入巨大，仍有 15–21% 的构建受到不稳定影响。检测与监控能告诉你是哪些用例坏了，但它们不会告诉你为什么移动端特有的失效模式还在源源不断地制造新的坏用例。&lt;/p>
&lt;p>到了某个临界点，比例会反转：质量团队一开始是 90% 的时间写用例、10% 维护；债务累积之后变成 10% 写、90% 维护。这种压缩，就是脚本级修复跑步机在团队尺度上的样子。&lt;/p>
&lt;h2 id="不依赖任何工具的五条落地动作">不依赖任何工具的五条落地动作
&lt;/h2>&lt;p>在讨论架构级方案之前，有几件事任何团队都可以立刻做，而且能显著减少上述四类失效：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>每个用例跑在干净的模拟器快照上&lt;/strong>，或至少按批次重建模拟器，直接消除&amp;quot;运行中途漂移&amp;quot;这一根因；&lt;/li>
&lt;li>&lt;strong>在测试准备阶段预置权限状态&lt;/strong>（通过启动参数或系统镜像配置），而不是在流程里靠脚本关弹窗；&lt;/li>
&lt;li>&lt;strong>把手势封装成一层抽象&lt;/strong>，统一在真实渲染状态之后再执行交互，而不是散落各处的硬编码等待常量；&lt;/li>
&lt;li>&lt;strong>为长列表与无限滚动采用&amp;quot;单步滑动 + 等目标出现&amp;rdquo;&lt;/strong> 的写法，替代依赖惯性距离的滑动；&lt;/li>
&lt;li>&lt;strong>把设备矩阵分层&lt;/strong>：日常流水线跑 2–3 个代表性配置，把碎片化验证放到独立的、频率更低但覆盖更广的批次里。&lt;/li>
&lt;/ul>
&lt;p>这些动作能把不稳定的总量压下来，但它们改变不了一个事实：只要用例依赖选择器，UI 变化、系统更新、设备差异就还会持续制造新的失效。&lt;/p>
&lt;h2 id="架构级的那一步去掉选择器依赖">架构级的那一步：去掉选择器依赖
&lt;/h2>&lt;p>基于选择器的模型——用 CSS 选择器、XPath、无障碍 ID 或资源 ID 定位元素——正是移动端用例脆弱的来源。属性一变，用例就坏；系统改变了视图层级的结构方式，用例就坏；一次布局调整把元素挪了 10 像素改变了它的边界，基于点击坐标的用例就坏。&lt;/p>
&lt;p>另一种做法是&lt;strong>按元素&amp;quot;看起来是什么、做什么&amp;quot;来识别它&lt;/strong>，而不是按实现属性。一个&amp;quot;提交&amp;quot;按钮之所以可被识别，是因为它写着&amp;quot;提交&amp;quot;、出现在表单的特定位置、并且响应点击。当这个按钮被重新设计、挪了位置、或者换了无障碍 ID，基于视觉的识别仍然能像人类测试者一样认出它。&lt;/p>
&lt;p>顺着这个角度回看前面四种失效模式，会发现它们的根都在选择器模型上：&lt;/p>
&lt;ul>
&lt;li>模拟器漂移导致手势事件落在错误元素上，因为元素位置在劣化的模拟器状态里发生了变化——按外观识别，就不依赖坐标；&lt;/li>
&lt;li>手势时序问题在使用硬编码时间常量时被放大——先观测实际渲染状态再交互，就消除了时序假设；&lt;/li>
&lt;li>系统权限弹窗之所以打断基于选择器的流程，是因为脚本在找一条已经被遮挡的元素路径——按视觉对象识别并关闭弹窗，不必为每个系统版本的弹窗格式改脚本；&lt;/li>
&lt;li>设备碎片化会破坏基于选择器的用例，因为布局在不同设备上渲染不同——按&amp;quot;用户看到的样子&amp;quot;识别，就能自动适配布局差异。&lt;/li>
&lt;/ul>
&lt;p>需要说明的是，这并不是一个能&amp;quot;装&amp;quot;到现有 Appium 或 Detox 套件上的功能，而是一种不同的测试执行架构：用例验证的用户流程不变，但不再依赖那些让流程难以维护的实现细节。&lt;/p>
&lt;p>评估这种转变时，正确的提问不是&amp;quot;它能不能消除全部不稳定&amp;quot;——答案是不能，网络故障和真实的环境问题依然会产生失败。真正的问题是：由 UI 变化、设备碎片化和选择器脆弱性造成的不稳定（也就是移动端特有不稳定中的大多数），从此变成一件在架构层修一次的事，还是继续作为每个迭代都要交的维护税。&lt;/p>
&lt;h2 id="结语">结语
&lt;/h2>&lt;p>这个循环是可预测的：不稳定出现 → 加自动重试 → 超时变长 → 套件变慢 → 信任流失 → 团队不再把 CI 变红当作值得调查的信号。这不是纪律失败，而是把 Web 测试的假设套用到移动端测试问题上的必然结果。&lt;/p>
&lt;p>移动端 E2E 的失败原因，在 Web 自动化里根本不存在。模拟器漂移、手势时序、系统弹窗、设备碎片化，都是移动端特有的问题，需要移动端特有的结构性解法，而不是更多层脚本级补丁。&lt;/p>
&lt;p>而维护跑步机没有可持续的配速，它只有一个出口。&lt;/p></description></item></channel></rss>