首页
/ Angular 中的不稳定测试(Flaky Test)排查与修复:基于 Bazel 的完整工作流实战

Angular 中的不稳定测试(Flaky Test)排查与修复:基于 Bazel 的完整工作流实战

2026-09-05 20:12:53作者:秋泉律Samson

在大型前端框架仓库中,"偶发失败的测试"(flaky test)是 CI 稳定性最大的隐形杀手:同一份代码时过时挂,既污染提交历史,也会浪费工程师大量定位时间。本文以 Angular 仓库内置的 Agent 工作流文档 .agent/workflows/fix-flaky-tests.md 为主线,完整还原其在当前仓库(Bazel + Jasmine 测试体系)下排查与修复 flaky test 的九步流程,并结合 tools/bazel/jasmine_test.bzltools/testing/node_tests.init.mts 等测试基础设施源码,说明每一步命令背后的实际作用。读完后,你将能够独立运用 --runs_per_testJASMINE_RANDOM_SEED、分片控制等手段,在 Angular 这类大型仓库中系统性地定位、复现并修复不稳定测试。

一、工作流总览:一次"找 flake、修 flake"的闭环

工作流文档将整个过程概括为一句话:"Investigate flaky tests in the repo and propose fixes to improve stability." 其高层流程(High-level process)共 9 步:

  1. 运行仓库中的测试,寻找 flakes;
  2. 找到多个 flakes 后,一次只专注解决一个;
  3. 创建新分支 flakes/${relevantNameFromTest}
  4. 尽最大可能复现该 flake;
  5. 调试测试,理解其失败模式;
  6. 尝试修复,并用 --runs_per_test 验证;
  7. 将变更连同完整说明提交,然后进入下一个测试;
  8. 按用户要求的次数迭代(未指定时默认 5 个分支);
  9. 当找不到更多 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.bzljasmine_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 部分明确了三条分支/提交策略:
    1. 涉及相同或相关文件的多个修复,可以放在同一个提交或同一分支的多个提交中;
    2. 不同的测试修复必须放在不同分支,每次调查开一个新分支;
    3. 这些分支可以推送到 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_SEEDxit/fit、分片与浏览器标签过滤把随机性收敛为确定性现场,用"每 flake 一分支、修复不改运行时"的约束控制爆炸半径,最后用重复运行验证、以含归因理论的提交信息收尾。对任何使用 Bazel + Jasmine 的大型前端仓库,这套流程都是可以直接迁移复用的 flake 治理模板。

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