首页
/ 在 Flutter 仓库中新增 CI 测试分片(Test Shard)完整指南:test.dart 与 .ci.yaml 的配置实践

在 Flutter 仓库中新增 CI 测试分片(Test Shard)完整指南:test.dart 与 .ci.yaml 的配置实践

2026-09-06 18:10:49作者:田桥桑Industrious

在 Flutter/flutter 主仓库的日常开发中,flutter test 的全量回归会拆分到多个并行执行的“测试分片”(test shard)上,而新增一套独立的测试分组(例如把某个包或某类测试从现有 shard 中拆出)需要同时修改三处:LUCI builder 定义、执行分片的 Dart runner(dev/bots/test.dart)以及 CI 编排文件(.ci.yaml)。本篇以 docs/infra/Adding-a-new-Test-Shard.md 为主线,结合仓库内真实的分片注册表与 target 配置,说明“新增分片应当遵循的落地顺序、配置键语义与 bringup 灰度流程”,读完后你将能够在 Flutter 源码树中独立完成一次新测试分片的添加与上线。

分片机制概览:一次构建如何在 CI 上被“切分”

Flutter 的主仓库回归体量很大(框架单测、工具链测试、示例构建、Web/桌面集成等),无法靠单台机器在规定时间内跑完,因此 CI 采用两层拆分:

  • shard(分片):指某一类测试的集合,例如 framework_teststool_testsanalyzeweb_canvaskit_tests 等,由 dev/bots/test.dart 中的注册表定义。
  • subshard(子分片):在同一 shard 内部再按主题或按数量切分,例如 framework_tests 下又分为 widgetslibrariesslowmiscimpeller(见 dev/bots/suite_runners/run_framework_tests.dart)。

从执行链路看,LUCI 上的每个“builder”会运行一段 recipe,而 Framework 侧大多使用 flutter/flutter_drone recipe——它本身不做测试逻辑,只是把 shard/subshard 键透传给仓库根目录下的 dev/bots/test.dart 去执行真正的测试。也就是说,“分片”对 recipe 是无感知的,真正的分片调度与测试发现逻辑全部发生在仓库内部的 Dart 脚本中。关于这套 build 基础设施的整体说明,可先阅读 dev/bots/README.md

一个 Flutter/LUCI 构建所需的四个要素

原文档把“一个 Flutter CI 测试分片”应具备的要素归纳为以下四项,每一项都对应仓库中的具体文件:

  1. 一个独立的 LUCI builder。在 LUCI 中测试分片与 builder 一一对应,通常同时需要 pre-submit(LUCI 术语中的 “try”)与 post-submit(“prod”)两套。Framework 侧的 builder 清单定义在 infra 仓库的 config/framework_config.star(仓库外部托管,不在当前源码树内)。
  2. 一条被执行 recipe。builder 会指定一个 recipe(基于 Starlark 的脚本),决定真正执行的 CI 步骤。绝大多数 Framework 测试使用 flutter/flutter_drone.py recipe。如何编辑 recipe 以及本地联调方式(led get-builder ... 等命令)见 dev/bots/README.md 的 “Editing a recipe” 一节——该节完整说明了从克隆 recipes 仓库、修改、recipes.py test train 更新期望输出、到用 led 在指定 PR 上试跑并提交 CL 的整个迭代周期。
  3. .ci.yaml 中登记 builder。该文件被 Flutter 的 build dashboard 读取并用于调度构建,是仓库内可直接查看与修改的一环。
  4. 一个真正能运行该分片的 Dart 入口。这一点对应 dev/bots/test.dart 中注册的 shard 名——它是第 3 步 .ci.yamlshard 键的“契约实现”。

其中前两项定义在仓库之外(由 infra/recipes 团队维护),需要在源码树内完成的工作集中在第 3、4 步以及它们的顺序编排上。

深入 .ci.yaml:一个 target 条目的字段语义

仓库根目录的 .ci.yaml(共 7800 余行)是 Framework 全部 CI target 的“单一事实来源”,其头部注释明确写道:Flutter infra 依据该文件为每次提交生成待执行任务清单,且 flutter_drone recipe 会依据本文件中的 shard 键把分片委托给仓库内的 dev/bots/test.dart。文件由三个顶层块组成:

  • enabled_branches:允许调度这些 target 的分支(如 masterflutter-x.y-candidate.z 发布候选分支)。
  • platform_properties:可复用的平台属性模板(如 linuxmac_arm64linux_android_emu 等),定义了操作系统、CPU、依赖项(dependencies,包括 android_sdk、open_jdk、gradle 等及版本)、device_type、KVM 等机器环境。
  • targets:真正被调度执行的 builder 列表。

以 Linux 上真实的 framework_tests 拆分结果为例,.ci.yaml 中可以看到 4 个 target 共享同一个 shard,但通过不同的 subshard 区分:

- name: Linux framework_tests_libraries
  recipe: flutter/flutter_drone
  timeout: 60
  properties:
    dependencies: >-
      [
        {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}
      ]
    shard: framework_tests
    subshard: libraries
    tags: >
      ["framework","hostonly","shard", "linux"]
  runIf:
    - dev/**
    - packages/flutter/**
    - packages/flutter_driver/**
    ...
  • name:builder 名称,需全局唯一,在 dashboard 上以该名称呈现。
  • recipe:要执行的 recipe,Framework 测试统一写 flutter/flutter_drone;devicelab 任务则写 devicelab/devicelab_drone
  • timeout:单位为分钟,该 builder 的整体超时上限(如上述示例为 60 分钟)。
  • properties:传给 recipe 的自定义属性,其中 shardsubshard/subshards 必须与 dev/bots/test.dart 中注册的名称完全一致,否则脚本会报 “Invalid shard/subshard” 错误;dependencies 声明机器上需要预装的工具链;tags 供 dashboard 分类展示(如 ["framework", "hostonly", "shard", "linux"])。
  • runIf:路径过滤白名单,只有提交触及这些路径时才调度该 builder,用来节省无谓的 CI 消耗。注意:framework_tests_* 系列都列入了 dev/**packages/flutter/**.ci.yamlengine/**DEPS 等关键路径。
  • bringup:灰度开关,见下文“bringup 上线流程”。
  • presubmit:是否参与 PR(pre-submit)验证。默认参与;设置为 false 则只在 post-submit(合入 master 后)运行,例如仅用于发布分支或后置缓存的 target(可参考 .ci.yamlfuchsia_precache 的写法)。
  • enabled_branches:仅在个别分支生效时使用。

仓库中也存在同时标记 bringup: truepresubmit: false 的 target(例如 .ci.yaml 中的 Linux flavors_test_linux),说明二者相互独立:bringup 决定是否允许失败/阻塞,presubmit 决定是否进入 PR 门禁。

test.dart:shard 注册表与 SHARD/SUBSHARD 契约

dev/bots/test.dart 是所有 Framework CI 测试的 Dart 入口。其头部注释给出了关键约定:测试默认支持按 shard 与 subshard 拆分,可通过 --dry-run 预览将要执行的测试列表;本地调试时只需设置 SHARDSUBSHARD 两个环境变量。

脚本通过 selectShardSHARD 环境变量映射到具体的 runner:

await selectShard(<String, ShardRunner>{
  'add_to_app_life_cycle_tests': addToAppLifeCycleRunner,
  'build_tests': _runBuildTests,
  'framework_coverage': frameworkCoverageRunner,
  'framework_tests': frameworkTestsRunner,
  'tool_tests': _runToolTests,
  'tool_tests_commands': _runCommandsToolTests,
  'web_canvaskit_tests': webTestsSuite.runWebCanvasKitUnitTests,
  'analyze': analyzeRunner,
  'docs': docsRunner,
  ...
});

这段注册表就是 .ci.yamlshard 键的合法取值集合。分片的实际调度逻辑在 dev/bots/utils.dartselectShard/selectSubshard 中实现:它们通过常量 kShardKey = 'SHARD'kSubshardKey = 'SUBSHARD' 读取环境变量,若变量为空则按注册表顺序逐个执行所有分片;若变量值不在注册表中,则调用 foundError 报错并列出所有可选值。

在 shard 内部,各测试单元既可以被继续切分为“命名 subshard”(如 framework_tests 下的 widgets/libraries/slow/misc/impeller),也可以被切分为“编号 subshard”。后者由 dev/bots/utils.dartselectIndexOfTotalSubshard 支持,SUBSHARD 采用 "{index}_{total}" 格式(如 1_3 表示共三份中的第一份),调度器无需仓库提交即可增减总份数;若格式非法会打印 Invalid subshard name ... Expected format "[int]_[int]" 的错误提示。用于均匀分配测试的区间算法见 selectTestsForSubSharddev/bots/utils.dart):先把测试数按份数取整均分,再将余数逐个放入前几个桶中。

framework_tests 为例,dev/bots/suite_runners/run_framework_tests.dart 通过 selectSubshard 注册了五个命名子分片,分别承担:widgets 运行 packages/flutter/test/widgets 及其 track-widget-creation 变体、release/profile 模式测试;libraries 运行除 widgets 目录外的其余 packages/flutter/test 子目录;slow 运行编译量大、耗时的平台无关测试(tracing 测试、dart fix golden 对比、test_private 等);misc 汇总 devicelab/analysis 之外各包的测试;impeller--enable-impeller 打开 Impeller 后端跑全量测试。

新增 Framework 测试分片的落地步骤(按序执行)

原文档强调:必须按下述顺序合入修改,否则迁移期间会出现失败构建。顺序的核心逻辑是——先让 Dart runner 认识新 shard,再让 CI 编排引用它,最后用 bringup 灰度到可以阻塞主树。

第一步:在 test.dart 中注册新 shard

任何全新 shard 必须首先加入 dev/bots/test.dart 的分片注册表,并在本 Framework 仓库中合入该修改。实现时通常还会在对应的 suite_runners/ 文件中补充具体的 runner 逻辑(例如新建 run_xxx_tests.dart),并在 dev/bots/test.dart 头部 import。合入后,任何 .ci.yaml 的 target 若把 shard 指到该名称,才能被 runner 正确解析。

需要区分两类“新增”:

  • 新增一套此前不存在的 shard(例如新引入一个大型测试目录),必须改 test.dart;
  • 仅将已有测试从某 shard 拆分到更多 subshard(复用同一个 shard 名),无需改动 test.dart——因为 subshard 只是测试集合的再分配,runner 逻辑并未变化。

第二步:在 .ci.yaml 中添加 builder 并标记 bringup

在仓库根目录的 .ci.yamltargets 块中追加新的 target,命名建议沿用平台前缀约定(如 LinuxMacMac_arm64Windows),recipe 使用 flutter/flutter_drone。务必满足三点:

  1. properties.shard 以及 subshard/subshards 与第一步 test.dart 中的注册严格一致;
  2. runIf 按新分片实际覆盖的源码范围设置,避免无关提交触发;
  3. 必须标记 bringup: true。新 shard 一律先进 bringup,先在 master 上验证通过后,才允许其阻塞主树。

bringup: true 的效果是:该 target 的结果暂不会因失败而标红阻止树(也不会在 PR pre-submit 阶段运行),从而避免把未经灰度验证的新增分片直接变成全体开发者的门禁。仓库中可随时找到处于 bringup 状态的 target 作为范本,例如 .ci.yaml 中的 Linux snippets

第三步:在 build dashboard 上观察并等待自动摘除 bringup

合入第二步后,持续在 Flutter build dashboard 上监控新 shard 的 CI 结果。当满足 连续 50 次构建通过且无 flakes 时,flake bot 会自动创建一条 PR,将 .ci.yaml 中该 target 的 bringup: true 参数移除。移除后:

  • 该分片开始具备阻塞主树的能力,任何回归都会在 post-submit 被拦截;
  • 除非显式设置了 presubmit: false,否则新 shard 会自动开始参与 pre-submit(PR 验证)。

flake bot 每周运行一次(通常在周三),因此摘除 bringup 的节奏以周为单位。

特殊情况:重命名豁免

文档末尾给出了一条重要豁免:若新的 post-submit target 是由现有 target 重命名而来,则无需走 bringup 流程。因为重命名前后的测试集合与稳定性记录是连续的,直接以正式状态上线不会引入未经验证的失败风险。

bringup 流程背后的工程意图

bringup: true 作为新分片上线必经之路,本质上是把 CI 变更视为与产品代码同等重要的“灰度发布”:

  • 分阶段:先在 post-submit 静默运行并积累信心(50 次全绿无 flake),再开放阻塞与 pre-submit 门禁;
  • 可回退:一旦灰度期出现不稳定,可直接在 .ci.yaml 中保留/恢复 bringup: true 而无需改动任何测试代码;
  • 自动化收敛:摘除动作由 flake bot 依据客观阈值(连续 50 次)自动发起,避免人工遗忘导致新分片长期游离于门禁之外。

同时,标记顺序也防止了中间态:若先合入 .ci.yaml 而 test.dart 尚未识别该 shard,runner 会在 selectShard 阶段报 Invalid shard,产生必然失败的构建;若先摘除 bringup 而未经 50 次验证,则可能把不稳定分片直接压到主树上。这就是原文档要求“按顺序合入”的原因。

本地验证与调试建议

在把改动交给 CI 之前,可完全在本地模拟分片执行。以运行 framework_testswidgets 子分片为例,在仓库根目录执行:

SHARD=framework_tests SUBSHARD=widgets bin/cache/dart-sdk/bin/dart dev/bots/test.dart

更稳妥的做法是先做 dry-run 预览(不会真正执行测试,只打印将要运行的命令与测试清单):

SHARD=framework_tests SUBSHARD=widgets bin/cache/dart-sdk/bin/dart dev/bots/test.dart --dry-run

对于按数量切分的编号 subshard,可验证分片区间分配是否符合预期(示例为三分片中的第一份):

SHARD=build_tests SUBSHARD=1_3 bin/cache/dart-sdk/bin/dart dev/bots/test.dart --dry-run

其他常用参数还包括:--test-randomize-ordering-seed=<n> 固定乱序种子以便复现问题、--verbose 关闭输出静默、--abort-on-error 遇到首个错误即退出。若想用本地自编译的引擎跑测试,可追加 --local-engine=host_debug_unopt --local-engine-host=host_debug_unopt(示例见 dev/bots/test.dart 的注释与参数解析逻辑 dev/bots/test.dart)。

相关文档索引

需要说明的是:LUCI builder 的机器清单(framework_config.star)、recipe 定义(flutter/flutter_drone.py)以及 .ci.yaml 的完整字段规范文档(cocoon 仓库的 CI_YAML.md)均托管在 Flutter 的独立 infra/recipes 仓库中,不在本源码树内;本文已用仓库内可验证的 .ci.yamldev/bots/test.dart 覆盖了源码侧的全部改动面,剩余环节请在 infra 侧文档指引下配合完成。

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