Angular 中的不稳定测试(Flaky Test)排查与修复:基于 Bazel 的完整工作流实战
在大型前端框架仓库中,"偶发失败的测试"(flaky test)是 CI 稳定性最大的隐形杀手:同一份代码时过时挂,既污染提交历史,也会浪费工程师大量定位时间。本文以 Angular 仓库内置的 Agent 工作流文档 .agent/workflows/fix-flaky-tests.md 为主线,完整还原其在当前仓库(Bazel + Jasmine 测试体系)下排查与修复 flaky test 的九步流程,并结合 tools/bazel/jasmine_test.bzl、tools/testing/node_tests.init.mts 等测试基础设施源码,说明每一步命令背后的实际作用。读完后,你将能够独立运用 --runs_per_test、JASMINE_RANDOM_SEED、分片控制等手段,在 Angular 这类大型仓库中系统性地定位、复现并修复不稳定测试。
一、工作流总览:一次"找 flake、修 flake"的闭环
工作流文档将整个过程概括为一句话:"Investigate flaky tests in the repo and propose fixes to improve stability." 其高层流程(High-level process)共 9 步:
- 运行仓库中的测试,寻找 flakes;
- 找到多个 flakes 后,一次只专注解决一个;
- 创建新分支
flakes/${relevantNameFromTest}; - 尽最大可能复现该 flake;
- 调试测试,理解其失败模式;
- 尝试修复,并用
--runs_per_test验证; - 将变更连同完整说明提交,然后进入下一个测试;
- 按用户要求的次数迭代(未指定时默认 5 个分支);
- 当找不到更多 flaky test 或迭代次数用尽时停止,并向用户汇报发现与修复结果。
这套流程的核心设计哲学是:一次一个 flake(focus on one at a time)+ 每轮一个分支(Distinct test fixes should go in different branches)。这避免了多个问题的归因互相干扰,也保证了每个修复都有独立的、可审查的提交历史。
二、第一步:用 --runs_per_test 高效发现 flake
工作流给出的发现阶段建议是:
bazel test //packages/core/... --runs_per_test=<N>
两条关键注意事项(原文逐条继承):
- 利用 Bazel 的
--runs_per_test标志:让同一个测试目标连续运行 N 次。稳定测试会全部通过,而 flaky 测试会在重复运行中"自曝"——这是把"偶发失败"转化为"可观察概率"的最直接手段; - 不要耗尽当前机器的全部资源,一次只跑一个测试子集(例如
bazel test //packages/core/...),而不是无差别地全仓压测。
这条建议在当前仓库中有很强的现实依据:集成测试文档 integration/README.md 明确指出,Bazel 若用满所有 CPU 核心并行运行测试,"this can lead to test timeouts and flakes and freeze up your machine"(可能导致测试超时和 flake,并卡死机器)。因此仓库推荐用 --local_test_jobs=<N> 限制并发:
pnpm bazel test --local_test_jobs=<N> //integration/...
甚至建议将该配置写入 .bazelrc.user 以长期生效。换言之,排查 flake 之前,先确保你的本地环境本身不会"制造" flake,否则你修掉的只是资源争用,而不是测试本身的问题。
从测试基础设施看,Angular 的节点端单元测试通过 tools/bazel/jasmine_test.bzl 中的 angular_jasmine_test / zoneless_jasmine_test 宏定义。该宏在封装 @devinfra//bazel/jasmine:jasmine.bzl 的 jasmine_test 规则时,会自动附加 spec 文件匹配 glob(**/*+(.|_)spec.js / .mjs / .cjs)并通过 --require 注入初始化脚本:
# tools/bazel/jasmine_test.bzl(节选)
def angular_jasmine_test(name, data = [], fixed_args = [], **kwargs):
jasmine_test(
name = name,
data = data + ["//tools/testing:node", "//tools/testing:node_tests.init.mjs"],
fixed_args = fixed_args + ["--require=$$JS_BINARY__RUNFILES/$(rlocationpath //tools/testing:node_tests.init.mjs)"],
**kwargs
)
这解释了为什么 bazel test //packages/core/... 这类目标能直接工作:测试目标名下的 *.spec.ts 会被自动编译、发现并执行,--runs_per_test 等 Bazel 通用测试标志对这些目标同样有效。
三、分支与提交纪律:每个 flake 一个分支
发现 flake 后,工作流要求:
- 创建新分支,命名规范为
flakes/${relevantNameFromTest}(例如flakes/routing-lazy-load),分支名即问题定位线索; - 文档 Additional notes 部分明确了三条分支/提交策略:
- 涉及相同或相关文件的多个修复,可以放在同一个提交或同一分支的多个提交中;
- 不同的测试修复必须放在不同分支,每次调查开一个新分支;
- 这些分支可以推送到
origin,但不要为其创建 PR。
这一约束的价值在于:flaky 测试的修复往往需要反复验证(多次 --runs_per_test),把"调查中的分支"与"最终 PR"解耦,可以让修复在验证充分、提交信息完整之后再走正式评审流程。
四、复现 flake:锁定随机顺序与干扰来源
复现是修 flake 中最难的一步,工作流给出了三个具体手段:
4.1 固定随机种子:--test_env JASMINE_RANDOM_SEED=1234
工作流建议:
bazel test //path/to:test --test_env JASMINE_RANDOM_SEED=1234
Jasmine 支持按种子随机化 spec 执行顺序,flaky 测试常因"恰好排在前面的某个测试"而失败。通过 --test_env 注入 JASMINE_RANDOM_SEED,可以把某次 CI 上失败时的 spec 排布原样复现出来,从而判断失败是否与测试执行顺序相关。这是把"随机失败"变成"确定性失败"的关键手段(该环境变量由工作流文档推荐,供 Jasmine 测试运行器消费,用于锁定失败现场的排序)。
4.2 用 xit / fit 隔离干扰测试
如果多个测试互相影响(共享全局状态、单例、DOM 环境等),工作流建议临时用 xit 跳过其他测试、用 fit 只跑嫌疑测试:
xit('suspect test A', () => { ... }); // 临时跳过
fit('the flaky test', () => { ... }); // 只跑这一个
逐个排除"嫌疑人",往往能很快锁定是哪个前序测试污染了环境。
4.3 排除浏览器差异与分片影响
-
如果 flake 看起来并非浏览器特有(例如 Node 端测试、或在 Chrome 下并不复现),可临时忽略 Firefox 测试:
bazel test //path/to:test --test_tag_filters -firefox -
使用
--test_sharding_strategy disabled将测试放在单个 shard 中运行,排除"分片把相关 spec 拆到不同执行单元"造成的假象。
这三招组合起来,覆盖了 flaky 测试最常见的三类诱因:执行顺序随机、跨测试状态污染、执行环境(浏览器/分片)差异。
五、调试原则:问"为什么 flaky",而不是"为什么 failed"
工作流在调试步骤中有一句点题式的强调(原文):
Try to understand why the test was flaky, not just why it failed. Understanding the inconsistency is important to finding the correct fix.
也就是说,"这一次断言为什么挂了"只是表象,真正要回答的是"为什么这次挂了、而平时不挂"。常见的不一致性来源,结合当前仓库的测试初始化逻辑 tools/testing/node_tests.init.mts 可以看到典型场景:该初始化脚本通过 TestBed.initTestEnvironment 初始化测试环境、用 domino 提供 Node 端的 DOM 文档、并设置 isNode/isBrowser 全局标志。若某个测试对"初始化时机""全局 DOM 状态""zone 是否加载"敏感,而在不同执行顺序/并发下这些条件恰好不同,测试就会时过时挂。找到这类不一致性根源,才是"正确的修复"。
六、修复与验证:用 --runs_per_test 收尾
验证阶段的工作流要求:
- 尝试修复,并用
--runs_per_test验证:修复后把同一个目标重复跑多轮,观察失败率是否归零; - 反复迭代,直到出现一个"看起来确实有效"的修复;
- 如果陷入僵局、没有实质进展:记录已学到的结论与卡点,把已有进展提交,换一个 flake 去修,最后统一向用户汇报未能修复的内容——这是典型的"止损 + 透明汇报"策略,避免在单个问题上无限耗时;
- 一条硬性边界:不要试图大幅改动 Angular 的运行时行为(Don't try to make significant changes to Angular's runtime behavior),修复范围严格限定在"让测试稳定地通过或稳定地失败"。这条约束防止"修 flake"演变成一次不受控的框架级重构。
七、提交信息与迭代汇报
提交环节要求 commit message 必须包含:
- 你认为该测试为什么会 flaky 的理论(theory);
- 这个修复如何消除或降低了该 flakiness。
迭代与终止条件:
- 默认迭代 5 个分支("Iterate as many times as the user requests you to, default 5 branches if not otherwise specified");
- 当找不到更多 flaky test、或迭代次数用尽时,停止并向用户汇报发现了什么、修复了什么,以及哪些 flake 尚未解决。
八、把流程落到当前仓库:一份可执行的最小清单
综合工作流文档与仓库测试设施,一个典型的调查回合可以这样执行:
# 1. 在子集上重复运行,发现 flake(限制并发,避免资源争用本身制造 flake)
pnpm bazel test --runs_per_test=5 --local_test_jobs=4 //packages/core/...
# 2. 建独立分支
# git checkout -b flakes/<relevantNameFromTest>
# 3. 固定随机种子复现失败顺序
pnpm bazel test //packages/core:target --test_env JASMINE_RANDOM_SEED=1234
# 4. 排除干扰源(按需)
pnpm bazel test //packages/core:target --test_tag_filters -firefox
pnpm bazel test //packages/core:target --test_sharding_strategy disabled
# 5. 修复后用重复运行验证
pnpm bazel test //packages/core:target --runs_per_test=10
其中,测试目标的发现机制由 tools/bazel/jasmine_test.bzl 中的宏保证(自动匹配 *.spec.js/mjs/cjs 并注入 tools/testing/node_tests.init.mts 初始化脚本);并发限制建议则来自 integration/README.md 中关于 --local_test_jobs 的说明。
小结
Angular 仓库的 flaky test 修复工作流本质上是一套"概率工程 + 归因纪律"的方法论:用 --runs_per_test 把偶发失败概率化,用 JASMINE_RANDOM_SEED、xit/fit、分片与浏览器标签过滤把随机性收敛为确定性现场,用"每 flake 一分支、修复不改运行时"的约束控制爆炸半径,最后用重复运行验证、以含归因理论的提交信息收尾。对任何使用 Bazel + Jasmine 的大型前端仓库,这套流程都是可以直接迁移复用的 flake 治理模板。
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