Flutter 仓库测试 Flakiness 治理工作流:从识别、分类到修复的完整实践指南
Flutter 框架仓库的持续集成规模庞大,测试抖动(Flakiness)曾是导致构建树大面积标红的主要因素之一。本文以仓库内 docs/infra/Reducing-Test-Flakiness.md 为骨架,系统梳理 Flutter 官方为压制 DeviceLab 等 CI 测试抖动而强制执行的一整套工作流:如何识别一次 flake、新增测试时如何预防抖动、每周自动化扫描如何发现高频抖动测试、拿到自动化 ticket 后如何一步步 Triage 定位根因,以及修复后重新启用测试的验收标准。读完本文,你将掌握一套可以直接复用在大型 CI 项目上的"防抖"方法论,并能结合本仓库的 .ci.yaml、TESTOWNERS 与 dev/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)环境。流程要求:
- 在 .ci.yaml 中为目标启用
bringup: true,把它放进 staging(试运行)池。 - 在 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 更容易判断。官方推荐的定位步骤是:
- 在 LUCI build 页面上,找到失败的步骤(应为带感叹号的红色图标并处于展开状态);
- 点击
execution details链接; - 在日志底部查找非零退出码(non-zero exit codes);
- 若发现非零退出码,可到基础设施支持渠道寻求进一步指导。
此外,官方文档还列举了几类常见的基础设施问题,供对照排查:
- 设备未找到:日志中出现类似
adb: device 'ZY223CXXGL' not found的错误; - 设备中途消失:测试执行过程中设备(device)掉线;
- Firebase 测试抖动:由 Firebase 侧回归引起;
- Xcode 缓存被污染:导致 iOS 相关任务异常;
- 瞬时网络问题:网络抖动导致拉取依赖或上传结果失败。
4.3 再识别真正失败的测试
有时候报错并不会直接点名是哪条测试失败,这时需要深入 test_stdout 找线索。推荐的排查动作如下:
- 在 LUCI build 页面上定位失败的步骤(红底感叹号图标且已展开),例如
run flutter_view_ios__start_up这类步骤名; - 点击
test_stdout链接; - 在日志中搜索
ERROR:(不要漏掉冒号),每个ERROR:的上方都会有一个RUNNING:标记,表示当时正在执行的测试/任务; - 分析"最近的
RUNNING:与对应ERROR:"之间的输出:- 重点关注
Failed assertion:——这通常直接指向 dart/flutter 测试中具体的断言失败; - 对于非 dart/flutter 测试,
RUNNING:与ERROR:之间的输出形态差异很大,但通常较短,往往能直接提示失败原因与解决方向;
- 重点关注
- 如果是在单元测试上抖动的 shard 测试,则在日志中搜索
[E](错误标记),效率更高。
这里体现了排查思路的关键区别:execution details 用于查基础设施层问题,test_stdout 用于查测试本身的失败点,二者要配合使用。
4.4 识别抖动模式
抖动天生不稳定,如果单看失败日志无法定位,寻找规律往往比反复看失败日志更有效。最有用的工具就是自动化 ticket 中"最近运行记录"给出的 flutter dashboard 链接——它已经把视图过滤到相关的抖动测试集合上。在此基础上官方给出了三个由浅入深的分析方向。
4.4.1 验证失败是否为同一问题
需要记住:flake 是基于"一组测试的集合"上报的,而不是基于某条具体失败。这意味着把抖动率推高到 2% 阈值之上的,可能其实是不止一个问题。虽然这种情况比较少见,但确实偶尔发生。没必要花大量时间去验证"每一次失败都相同",但花一些时间验证"部分失败确实相同",从长期看能省下更多时间——自动化 ticket 中的"Flaky builds"列表就是做这件事的最佳起点,dashboard 上也常常能找到更多额外的抖动构建。
4.4.2 寻找首次发生实例
抖动的成因分两类:一类是长期潜伏的——抖动率长期低于 2%,由各种小幅上升慢慢积累而成;另一类是有明确起点的——某个明显位置开始出现抖动。后者通常容易解决得多,因此值得去追根因。官方给出的启发式做法:
- 在 Flutter dashboard 上不断向下滚动加载更多构建;
- 滚到出现一长串绿色构建为止(例如整整一两屏都是绿的)——这是一个启发式判断,未必是真正的"第一次"实例,但对 Triage 足够好用;
- 再向上滚回,找到第一个红色构建或带感叹号的绿色构建;
- 点击失败/抖动构建旁的头像,会弹出该次失败对应的 commit 链接;
- 检查这个 commit 是否直接影响失败测试——例如新增了随机性、新增了断言等改动;
- 同时留意可能影响这些测试的 roll(依赖/引擎升级);
- 把"首次抖动实例"与"此前最后一次成功运行"做对比(对比技巧见 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_TEST是 dev/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)。理解了这一语义,就能明白为什么"带感叹号的绿色构建"恰恰是抖动治理最需要关注的信号——它意味着问题真实存在、只是尚未把树打红。
七、全流程回顾
把上述内容串起来,就是一套完整的"防抖"闭环:
- 预防:新 DeviceLab 测试先入 .ci.yaml 的
bringup: true(staging),并在 TESTOWNERS 登记所有权,稳定后再转正; - 检测:每周自动化扫描过去 15 天数据,抖动率 ≥ 2% 的测试自动建 P0 tracking bug(默认指派 sub-team TL),非 shard 测试直接在 .ci.yaml 中以
# TODO(...)+bringup: true隔离;issue 关闭后给予 15 天宽限期再决定是否重报; - Triage:借助自动化 ticket → 先用
execution details排除基础设施问题(非零退出码、设备掉线、缓存污染等)→ 再用test_stdout中的ERROR:/RUNNING:/Failed assertion:/[E]定位失败点 → 通过"重复失败确认、首次实例回溯、成功/失败对比"识别模式; - 修复:TL 主导修复,连续 50 次无抖动运行后移除
bringup: true重新启用;无法修复的测试从 dashboard 或 CI 中移除。
这套以"识别 → 隔离 → 定位 → 修复 → 验收"为核心的治理循环,既保证了主干构建的稳定性,也为抖动问题保留了完整的可追溯证据链,是大型开源项目治理 CI 不稳定性的一个成熟范本。仓库中 docs/infra 目录下其余基础设施相关文档(如 Dashboards、CI 分片等)可作为进一步深入阅读的入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00