首页
/ Flutter 仓库测试 Flakiness 治理工作流:从识别、分类到修复的完整实践指南

Flutter 仓库测试 Flakiness 治理工作流:从识别、分类到修复的完整实践指南

2026-09-06 18:37:38作者:谭伦延

Flutter 框架仓库的持续集成规模庞大,测试抖动(Flakiness)曾是导致构建树大面积标红的主要因素之一。本文以仓库内 docs/infra/Reducing-Test-Flakiness.md 为骨架,系统梳理 Flutter 官方为压制 DeviceLab 等 CI 测试抖动而强制执行的一整套工作流:如何识别一次 flake、新增测试时如何预防抖动、每周自动化扫描如何发现高频抖动测试、拿到自动化 ticket 后如何一步步 Triage 定位根因,以及修复后重新启用测试的验收标准。读完本文,你将掌握一套可以直接复用在大型 CI 项目上的"防抖"方法论,并能结合本仓库的 .ci.yamlTESTOWNERSdev/devicelab 源码理解其落地细节。

一、什么是 Flakiness:问题背景与适用范围

测试抖动曾经造成了 Flutter 构建树(build tree)中很大比例的红色失败,因此仓库规定并强制执行下面的工作流来系统性减少抖动问题。治理对象首先聚焦于框架提交后的 DeviceLab 测试(post-submit DeviceLab tests),其逻辑后续会扩展到其他仅主机(host-only)测试上。换句话说,这是一套先治理"真机测试抖动"、再推广到"纯主机测试"的分阶段策略。

1.1 在构建仪表盘上识别一次 flake

在 Flutter 构建仪表盘上,一次 flake 被标识为一个带感叹号图标的盒子(box with an exclamation icon)。需要特别注意的是:有两种不同类型的抖动会呈现为同一种 flake 盒子,排查时必须先分清你遇到的是哪一种。

类型一:同一提交、同一任务被多次重跑(多构建)

同一个 commit 与同一个 task 被多次 rerun——早期运行失败、最后一次运行成功。这类情况判定为抖动,排查方式是点击不同的 build runs 来对比各次日志

类型二:单次构建内的测试运行器多次重跑(单构建内)

同一 commit、同一 task 只有一次构建,但该构建内部的 test runner 对测试做了多次重跑。这类情况需要点击测试步骤(test step)的 stdout 查看日志,日志末尾会显示本次执行中失败与成功的各次运行数据。

这一"单构建内多 run"行为在源码中能找到依据:DeviceLab 的 test runner 内置了自动重试逻辑。见 dev/devicelab/lib/framework/runner.dart

var result = TaskResult.success(null);
var failureCount = 0;
while (failureCount <= MetricsResultWriter.retryNumber) {
  result = await rerunTask(taskName, ...);
  if (!result.succeeded) {
    failureCount += 1;
    ...
  } else {
    section('Flaky status for "$taskName"');
    if (failureCount > 0) {
      print('Total ${failureCount + 1} executions: $failureCount failures and 1 false positive.');
      print('flaky: true');
    } else {
      print('Test passed on first attempt.');
      print('flaky: false');
    }
  }
}

而重试次数上限定义在 dev/devicelab/lib/framework/metrics_result_writer.dart

/// Threshold to auto retry a failed test.
static const int retryNumber = 2;

即默认情况下,一次失败的任务会被自动重试 2 次(合计最多 3 次执行)。这就是"单次构建内多 run"的根本来源:只要最后一次成功,任务整体就被上报为成功,但中间产生的失败会被记录为一次 flake。DeviceLab 的整体运行模型可参见 dev/devicelab/README.md:任务失败时自动重跑,最后一次成功即报成功但标记 flake;全部重跑都失败才上报失败且不采集性能指标。

二、预防:新增 DeviceLab 测试的标准流程

新测试如果一上来就直接在正式环境全量运行,抖动风险会直接污染主干构建。因此仓库要求所有新 DeviceLab 测试都必须走"先试运行、再转正"的两阶段流程。

2.1 测试文件的落位与所有权

DeviceLab 测试是一段独立的 Dart 程序,存放在 dev/devicelab/bin/tasks 目录下(每个文件对应一个 task,task 名即去除 .dart 后缀的文件名,例如 flutter_gallery__transition_perf)。任务使用 package:flutter_devicelab/framework/framework.dart 定义与运行,一个程序只允许定义一个 task,task 之间互不干扰,各自独立上报 dashboard。

新增测试时需要做的第一件事是:提交 PR 添加测试文件,并在仓库根目录的 TESTOWNERS 文件中为该测试创建所有权条目。TESTOWNERS 的作用在文件头部注释中写得很清楚:当新的 flaky bug 被提交时,会根据该文件找到负责人,默认把 sub-team TL 指派给这个 bug,并打上对应 sub-team 标签用于进一步 Triage。条目格式为"测试文件路径 + 负责人 GitHub 账号 + 团队标签"的映射,例如:

/dev/devicelab/bin/tasks/flutter_gallery__transition_perf.dart @jtmcdole @flutter/engine

2.2 先在 staging 池试运行

新测试不应直接进入生产(PROD)环境。流程要求:

  1. .ci.yaml 中为目标启用 bringup: true,把它放进 staging(试运行)池
  2. 在 Flutter 构建仪表盘上持续监控该测试的执行情况。

.ci.yaml 是整个 CI 目标(target)的定义文件,仓库注释说明 infra 依据此文件为每个 commit 生成要执行的任务清单。当前仓库中仍有大量目标处于 bringup: true 状态(例如 Linux snippets 目标在 .ci.yaml,以及多个 devicelab_drone recipe 目标),可见这是常用的灰度标记。此外,.ci.yaml 中的 staging_build_linux 平台属性还设置了 ignore_flakiness: "true"(见 .ci.yaml),进一步印证 staging 环境对抖动是"容忍但不阻断"的定位。

2.3 验证稳定后转正

如果经过一段时间试运行没有出现抖动问题,就可以把该测试切换到生产环境启用。转正的标志就是把该目标在 .ci.yaml 中的 bringup: true 标记关闭(移除或切换该字段),让它的成败结果开始计入主干构建门禁。

小结:bringup: true 本质上是一道"新任务白名单"闸门——处于 bringup 状态的任务即使失败也不会把树打红,是隔离"新增抖动风险"的第一道防线。需要说明的是,原文此处表述为"Switch bringup to true"实为"切换掉 bringup 使能状态",与后文"修复后移除 bringup: true 重新启用"一致,均指转正动作。

三、检测:每周自动化扫描与 2% 阈值

仅靠人工观察无法覆盖全部任务的抖动,因此仓库依靠自动化脚本每周扫描来发现高频抖动测试:脚本会统计过去 15 天内各测试的执行数据,筛出最抖动的测试,并执行两类处置动作。

3.1 触发条件:Flaky Ratio >= 2%

只要存在某个测试 builder 的 Flaky Ratio(抖动率)达到或超过 2%,即触发处理流程。这里的统计对象是"一组被运行的测试集合",而不仅是单条用例——理解这一点对后续 Triage 很重要(见 4.1"验证重复失败")。

3.2 命中阈值后的两个动作

对抖动率超标的测试,脚本会执行以下动作:

动作一:创建(或复用)追踪 bug

  • 若该问题在 bug 池中尚不存在追踪记录,则自动创建一个 tracking bug;
  • 默认将 sub-team TL 指派为该 bug 的负责人,用于后续 Triage 或再指派;
  • 为该 bug 打上 P0 标签,表示优先级最高、需要立即处理。

动作二:若不是 shard 测试,直接在 .ci.yaml 中把该测试标记为 flaky

  • 具体做法是更新 .ci.yaml 中该测试对应的条目;
  • bringup: true 行的上方追加一行形如 # TODO(username): github issue url 的注释,把该目标暂时"降级"为不阻断树(相当于用 bringup: true 把已知抖动测试隔离起来,避免持续污染主干),同时把责任人和问题链接显式写在目标条目附近,方便后续认领。

区分"shard 测试"与"非 shard 测试"是有意义的:shard 测试内部包含大量单元测试,抖动可能来自其中某一条用例,因此不会整体打 bringup: true;而非 shard 测试(如一个个独立的 DeviceLab task)则直接整体标记。

3.3 关闭后的 15 天宽限期

如果某个抖动 issue 被关闭(例如修复完成),自动化脚本会给予 15 天的宽限期:只有在该宽限期后同一抖动仍然存在,脚本才会重新提交(refile)该 issue,避免修复者刚关闭 bug 就被"误伤"式地重复打扰。

四、Triage:定位 flaky 测试的根因

弄清楚"一组测试为什么会抖动"往往并不容易。官方为此沉淀了一整套从粗到细的排查技巧,核心思路是:先借助自动化产物缩小范围,再区分基础设施问题与测试代码问题,最后通过模式识别逼近根因

4.1 用好自动生成的 ticket

当测试被标记为 flaky 后,自动化脚本生成的 ticket 会提供三类高价值线索:

  • 同一提交上最近的抖动实例链接:指向对应的 LUCI build 页面(如可用);
  • 抖动构建列表:一串指向各自 LUCI build 页面的链接;
  • 最近测试运行记录:指向 Flutter 构建仪表盘中、已按目标测试集合过滤好的最近运行视图。

这些信息全部服务于同一个目的——快速缩小问题范围,因此在拿到 ticket 后应当优先逐条点开查看,而不是漫无目的地翻日志。

4.2 先识别基础设施问题

很多抖动其实与测试本身无关,而是基础设施(infra)造成的。这类问题往往难以直接从 stdout 日志里看出来——例如超时(timeout)这类问题,用 execution details 比用 test_stdout 更容易判断。官方推荐的定位步骤是:

  1. 在 LUCI build 页面上,找到失败的步骤(应为带感叹号的红色图标并处于展开状态);
  2. 点击 execution details 链接;
  3. 在日志底部查找非零退出码(non-zero exit codes)
  4. 若发现非零退出码,可到基础设施支持渠道寻求进一步指导。

此外,官方文档还列举了几类常见的基础设施问题,供对照排查:

  • 设备未找到:日志中出现类似 adb: device 'ZY223CXXGL' not found 的错误;
  • 设备中途消失:测试执行过程中设备(device)掉线;
  • Firebase 测试抖动:由 Firebase 侧回归引起;
  • Xcode 缓存被污染:导致 iOS 相关任务异常;
  • 瞬时网络问题:网络抖动导致拉取依赖或上传结果失败。

4.3 再识别真正失败的测试

有时候报错并不会直接点名是哪条测试失败,这时需要深入 test_stdout 找线索。推荐的排查动作如下:

  1. 在 LUCI build 页面上定位失败的步骤(红底感叹号图标且已展开),例如 run flutter_view_ios__start_up 这类步骤名;
  2. 点击 test_stdout 链接;
  3. 在日志中搜索 ERROR:不要漏掉冒号),每个 ERROR: 的上方都会有一个 RUNNING: 标记,表示当时正在执行的测试/任务;
  4. 分析"最近的 RUNNING: 与对应 ERROR:"之间的输出:
    • 重点关注 Failed assertion:——这通常直接指向 dart/flutter 测试中具体的断言失败;
    • 对于非 dart/flutter 测试,RUNNING:ERROR: 之间的输出形态差异很大,但通常较短,往往能直接提示失败原因与解决方向;
  5. 如果是在单元测试上抖动的 shard 测试,则在日志中搜索 [E](错误标记),效率更高。

这里体现了排查思路的关键区别:execution details 用于查基础设施层问题,test_stdout 用于查测试本身的失败点,二者要配合使用。

4.4 识别抖动模式

抖动天生不稳定,如果单看失败日志无法定位,寻找规律往往比反复看失败日志更有效。最有用的工具就是自动化 ticket 中"最近运行记录"给出的 flutter dashboard 链接——它已经把视图过滤到相关的抖动测试集合上。在此基础上官方给出了三个由浅入深的分析方向。

4.4.1 验证失败是否为同一问题

需要记住:flake 是基于"一组测试的集合"上报的,而不是基于某条具体失败。这意味着把抖动率推高到 2% 阈值之上的,可能其实是不止一个问题。虽然这种情况比较少见,但确实偶尔发生。没必要花大量时间去验证"每一次失败都相同",但花一些时间验证"部分失败确实相同",从长期看能省下更多时间——自动化 ticket 中的"Flaky builds"列表就是做这件事的最佳起点,dashboard 上也常常能找到更多额外的抖动构建。

4.4.2 寻找首次发生实例

抖动的成因分两类:一类是长期潜伏的——抖动率长期低于 2%,由各种小幅上升慢慢积累而成;另一类是有明确起点的——某个明显位置开始出现抖动。后者通常容易解决得多,因此值得去追根因。官方给出的启发式做法:

  1. 在 Flutter dashboard 上不断向下滚动加载更多构建;
  2. 滚到出现一长串绿色构建为止(例如整整一两屏都是绿的)——这是一个启发式判断,未必是真正的"第一次"实例,但对 Triage 足够好用;
  3. 再向上滚回,找到第一个红色构建或带感叹号的绿色构建
  4. 点击失败/抖动构建旁的头像,会弹出该次失败对应的 commit 链接;
  5. 检查这个 commit 是否直接影响失败测试——例如新增了随机性、新增了断言等改动;
  6. 同时留意可能影响这些测试的 roll(依赖/引擎升级)
  7. 把"首次抖动实例"与"此前最后一次成功运行"做对比(对比技巧见 4.4.3)。

4.4.3 对比成功运行

有时候,找出"成功运行与失败运行之间到底差了什么",比从失败运行本身找原因更容易。当你已经确认了失败对象却仍找不到根因时,可以留意以下几类常见模式:

  • 测试是否在某种特定顺序下才会失败? Flutter 测试集合的运行顺序是随机化的,因此测试之间的交互偶发地触发问题并不罕见。若发现失败构建总是以某种顺序失败,还要反向验证成功构建是否不会以相同顺序失败,才能确认是顺序敏感问题。
  • 测试是否倾向于在特定时间点抖动? 如果测试没有使用类似 DateTime.now() 的时间依赖,那时间相关性可能只是巧合,但当你毫无头绪时仍值得检查。一个典型情况是:如果测试总在每台 VM 每天的第一次构建上抖动,大概率与机器每天的全新供应(fresh provisioning)有关。
  • 测试是否总在同一台 bot/设备上抖动? 如果是,大概率是硬件问题,这类问题应直接交给基础设施团队处理。

五、修复与重新启用

5.1 责任流转

抖动的修复遵循"TL 驱动"的流转模型:sub-team TL 负责协助 Triage、重新指派(reassign),并尝试修复抖动。

5.2 修复后的验收标准:连续 50 次无抖动运行

如果测试在 CI 中已被标记为 flaky(即处于 bringup: true 隔离状态),修复完成后不能立刻转正,必须先通过严格的验收:

  • 该任务需要在 Flutter 构建仪表盘上连续 50 次运行均无抖动问题;
  • 判定"无抖动"的标准是:任务盒子不带感叹号,且没有因同一个 flaky 失败而失败。

通过 50 次连续运行验证后,就可以在 .ci.yaml 中更新该测试条目——移除目标上的 bringup: true,让测试重新计入主干构建门禁。

5.3 不可修复测试的处理

如果抖动确实无法修复,则根据具体情况处理:要么把该测试从 Flutter 构建仪表盘上移除,要么彻底从 CI 中删除。这是一个务实的兜底策略——与其让一个长期抖动的测试反复污染主干并消耗人力,不如先把它移出关键路径。

六、附:本地复现与框架源码佐证

理解 DeviceLab 的运行机制有助于在实际排查中复现问题。以下几点来自仓库实际源码,可作为上述流程的补充证据。

  • 测试本地位移:DeviceLab 官方建议本地先用与 CI 相近的方式跑通测试再部署到 CI。可用 dev/devicelab/bin/test_runner.dart 按任务名运行单测:

    # 在 .../flutter/dev/devicelab 目录下
    ../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t {NAME_OF_TEST}
    

    其中 NAME_OF_TESTdev/devicelab/bin/tasks 下某个文件的 basename,例如 complex_layout__start_up。若要跳过自动重试以便稳定复现抖动,可加 --exit 参数(默认失败会重试 2 次),或使用 dev/devicelab/bin/run.dart 执行:

    ../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test --exit -t {NAME_OF_TEST}
    
  • 设备级保护机制:本机运行 DeviceLab 测试会改动环境——它会自动启停本机的 Gradle,还会在运行一定数量测试后自动重启 Android/iOS 测试设备。若怀疑"设备本身的状态"导致抖动,这也是排查时值得留意的变量(相关逻辑位于 devicelab 框架的设备管理代码中)。

  • 结果上报语义:如本文开头所述,任务以"最后一次执行"为准上报成功与否,中间失败的 run 会被标记为 flaky: true(见 dev/devicelab/lib/framework/runner.dart)。理解了这一语义,就能明白为什么"带感叹号的绿色构建"恰恰是抖动治理最需要关注的信号——它意味着问题真实存在、只是尚未把树打红。

七、全流程回顾

把上述内容串起来,就是一套完整的"防抖"闭环:

  1. 预防:新 DeviceLab 测试先入 .ci.yamlbringup: true(staging),并在 TESTOWNERS 登记所有权,稳定后再转正;
  2. 检测:每周自动化扫描过去 15 天数据,抖动率 ≥ 2% 的测试自动建 P0 tracking bug(默认指派 sub-team TL),非 shard 测试直接在 .ci.yaml 中以 # TODO(...) + bringup: true 隔离;issue 关闭后给予 15 天宽限期再决定是否重报;
  3. Triage:借助自动化 ticket → 先用 execution details 排除基础设施问题(非零退出码、设备掉线、缓存污染等)→ 再用 test_stdout 中的 ERROR:/RUNNING:/Failed assertion:/[E] 定位失败点 → 通过"重复失败确认、首次实例回溯、成功/失败对比"识别模式;
  4. 修复:TL 主导修复,连续 50 次无抖动运行后移除 bringup: true 重新启用;无法修复的测试从 dashboard 或 CI 中移除。

这套以"识别 → 隔离 → 定位 → 修复 → 验收"为核心的治理循环,既保证了主干构建的稳定性,也为抖动问题保留了完整的可追溯证据链,是大型开源项目治理 CI 不稳定性的一个成熟范本。仓库中 docs/infra 目录下其余基础设施相关文档(如 Dashboards、CI 分片等)可作为进一步深入阅读的入口。

登录后查看全文
热门项目推荐
相关项目推荐