Angular CI 偶发测试失败排查:fakeAsync 定时器泄漏根治实践
先说结论你看到的“CI 时好时坏”多半不是 CI 本身抽风而是测试代码里埋着一个只在特定时序下才会爆炸的雷。Angular 项目的 CI 偶发失败尤其如此因为 zone.js、fakeAsync、TestBed 这些机制叠加起来会让问题看起来像“随机出现”实际上每一步都有迹可循。这是“Angular 由一个 bug 说起”这个系列的第二十三篇。这次记录的是我这边某前端项目一次非常典型的“flaky test”排查过程现象就一句话main 分支的 GitHub Actions 流水线隔三差五红一次重跑又绿了。这种问题最恶心的地方在于它不给你一个稳定的复现入口你越急它越不出来你放着不管它又出来刷存在感。这次我把它彻底按住了顺手也整理了一套针对 Angular 项目偶发测试失败的排查方法希望能帮你少走几趟弯路。1. 问题现象与最初的判断1.1 一条“玄学”失败的流水线先说下项目背景一个 Angular 16 的中后台应用测试用的是 Jasmine Karma跑在 ChromeHeadless 上CI 用的是 GitHub Actions每次 push 会自动执行ng test --watchfalse --browsersChromeHeadless。改动不大测试也就几百个用例平时跑一遍大约四分钟。但最近两周流水线开始不稳定。失败信息大致是这两种Chrome Headless 120.0.6099.109 (Linux x86_64) DashboardComponent should trigger debounce search FAILED Error: 1 timer(s) still in the queue.Chrome Headless 120.0.6099.109 (Linux x86_64) SharedTableComponent should render row with custom template FAILED TypeError: Cannot read properties of undefined (reading length)注意看几乎所有失败都集中在DashboardComponent和SharedTableComponent这两个文件其他测试文件基本没出过问题。而且失败点会变这一会儿是 timer 队列问题一会儿是某个属性读取报错看起来毫无规律。当时团队里最直接的反应是CI 环境不稳定重跑一下确实重跑大概率就绿了。但这种事你忍一次两次可以连续两周都被同一根刺扎不好受了。我决定不重跑把整个链路扒开看看。1.2 排查的第一步收集证据而不是“重跑试试”很多人在这一步就急着改代码了。我的建议是先别动代码先把证据链补完整。“时好时坏”的问题最怕没记录你连失败了几次、什么时间段失败、失败时跑了哪些用例都不知道后面就只能靠猜。我当时做了一套相对笨但非常管用的证据采集方法在 GitHub Actions 里把所有测试相关的 step 加上continue-on-error: true并把测试日志完整上传为 artifact这样即使流水线标红日志也不会丢。把 Karma 的随机测试顺序打开。Jasmine 默认按文件定义顺序执行但你可以通过random: true打开随机顺序。如果问题只在随机顺序下出现那基本可以断定是测试之间互相污染。在 20 个 CI 样本里记录失败频率。我当时的统计是约 20 次运行中失败 6 次失败率 30%。而且失败大多集中在连续两次 build 之间也就是说有一定的时间局部性。这三步做完问题画像已经清晰了它不是环境级的大范围故障而是某个或者某几个测试在特定执行顺序下才暴露的问题。侥幸的是凡是失败的样本里都出现了1 timer(s) still in the queue这个错误。这个错误本身就是最重要的线索。1.3 用大白话说清楚 CI 偶发失败的常见原因在进入定位之前我先把“时好时坏”这个现象拆成三类方便大家对照排查环境类CI 机器性能波动、浏览器崩溃、网络拉取依赖失败。特征失败点非常随机重装依赖或换一台 runner 就能解决。并发类多个测试文件并行执行时共享了某些全局状态导致前一个文件的副作用影响后一个文件。特征只在全量跑时出现单独跑某个文件必绿。测试自身类测试内部有时序依赖比如 fakeAsync 没有 flush、真实定时器没有清理、组件实例没有销毁。特征文件循环跑多遍会偶现而且失败信息往往指向同一个模块。Angular 项目里绝大部分 flaky 都是第三类而且根源高度集中在“定时器泄漏”和“状态污染”这两个词上。我这次遇到的基本就是这套路的完整演绎。2. 逐一排除外部嫌疑环境、依赖与并发2.1 先排除机器性能和并发崩溃CI 用的是 GitHub 官方的ubuntu-latest镜像标准 2 核 CPU、7G 内存。对 Angular 项目来说这个配置算不上富裕但跑几百个测试也不至于内存溢出。不过我还是验证了一下本地 8 核机器上连续跑 30 次全量测试全部通过CI 上只要用同一个 commit 连续跑 20 次就出现大约 30% 的失败率。这组对比说明问题对执行环境有一定敏感度但核心原因不在资源本身。我还观察到失败日志里偶尔出现Disconnected字样这是 Karma 与浏览器之间 socket 断开的典型表现。如果只是浏览器崩溃往往伴随Chrome crashed或者Cannot read properties of null这种乱七八糟的错。我们的日志里没有这类现象所以基本可以排除“CI 机器太弱导致 Chrome 被杀”的假设。但资源弱确实会放大问题。后面你会发现同样的测试代码本地因为跑得快某些异步回调还没触发 spec 就结束了CI 上跑得慢回调触发了反而把状态搞坏。所以“在弱机器上容易复现”本身就是一个信号代码里存在未清理的异步逻辑。2.2 依赖安装与缓存穿透排查另一个容易背锅的地方是依赖安装。npm ci 在某些网络环境下会时快时慢要是 lock 文件版本和 package.json 不一致还会直接安装失败。但那是“必现”的失败不是“偶尔失败”所以先排除。我当时还是确认了两点package-lock.json 与 package.json 一致npm ci不会报错。CI 里没有使用自定义 npm 缓存依赖版本完全由 lock 文件锁定理论上每次安装出来的 node_modules 是一致的。为了保险我还在一个失败的样本里把缓存禁用重跑了一次依然失败。到这里外部嫌疑基本洗清该把目光收回到测试代码本身了。2.3 Angular 测试框架区的固定套路锁定版本、统一 Node 版本这个地方我想插一句Angular 项目里排查测试问题之前值得先把运行环境“固定死”。我见过太多团队本地 Node 18、CI Node 20Angular CLI 版本还不同结果出现一堆奇怪的时序差异。Angular 的 zone.js 对 Node 版本的细微差异很敏感尤其是setTimeout、Promise这类异步 API 的时序行为。我这边当时的版本组合是固定的Node.js 20.11 LTS Angular CLI 16.2.10 Karma 6.4.2 Jasmine 5.1.0 ChromeHeadless 对应版本版本锁定之后至少保证本地和 CI 在同一个起点上。接下来做的才是真正的“对线”。3. 真相藏在测试代码里从“间歇”到“必现”的还原3.1 最小化复现单测文件循环压力测试外部因素全部排除后我开始用最笨也最有效的方式逼它现身循环跑单个文件。命令大概是这样的for i in {1..30} do npx ng test --watchfalse --browsersChromeHeadless --includesrc/app/dashboard/dashboard.component.spec.ts flaky.log 21 echo run $i exit: $? done注意--include这个参数在 Angular CLI 16 里是生效的老版本可能要用/src/app/**/*.spec.ts配合 --include 路径来写。如果不想折腾 CLI 参数直接用 Jest 的话就是for i in {1..20}; do npx jest --runInBand src/app/dashboard/dashboard.component.spec.ts 21 | tail -20; done跑了 30 遍第 9 遍复现了错误还是那句Error: 1 timer(s) still in the queue.单文件能复现这就很关键了。它意味着问题跟跨文件并发没太大关系就是这一个文件内部的时序毛病。我又在这个文件内部做了一次减法把it块逐个注释看哪个用例单独跑会触发。最终锁定在这个用例上it(输入搜索词后应触发防抖查询, fakeAsync(() { fixture TestBed.createComponent(DashboardComponent); component fixture.componentInstance; component.ngOnInit(); fixture.detectChanges(); const input fixture.nativeElement.querySelector(input); input.value angular; input.dispatchEvent(new Event(input)); expect(component.query).toBe(angular); }));第一眼看上去逻辑没问题模拟输入、触发事件、断言字段值。问题就在这行dispatchEvent(new Event(input))背后。3.2 故障堆栈分析一条关键日志指向了什么1 timer(s) still in the queue是 Zone.js 在 fakeAsync 环境下给出的警告。它的意思是测试已经结束了但 fakeAsync 的虚拟时钟队列里还挂着一个定时器没有执行。定时器从哪来的答案是组件内部的防抖逻辑。DashboardComponent 里对查询条件做了一个debounceTime(500)的处理this.query$ this.searchControl.valueChanges.pipe( debounceTime(500), distinctUntilChanged(), switchMap((query) this.service.search(query)) );当测试里触发input事件后valueChanges发出值debounceTime(500)在 zone 内创建了一个 500ms 的定时器。但测试代码用的是fakeAsync它的虚拟时钟由 Zone.js 控制。如果不调用tick(500)或者flush()这个定时器永远留在队列里测试结束时 Zone.js 就会报警。那为什么本地不怎么报CI 偶尔报因为 Karma 跑完一个 spec 后浏览器事件队列里如果还挂着真实回调执行顺序会受 CPU 负载影响。本地机器快dispatchEvent之后的同步断言立刻完成spec 结束定时器还没排到就被清理了CI 机器慢或者前一个 spec 阻塞了一下定时器回调和 spec 结束之间发生了竞争有时候就正好撞上 Zone.js 的清理检查。3.3 根因fakeAsync、pending timer 与测试间的状态泄漏看到这里有人可能会说那我在用例里加一个tick(500)不就完事了确实tick(500)能解决当前这个 spec 的问题但只修这里还远远不够。因为我在排查过程中发现同一个 spec 文件里还有大量类似代码甚至在describe层级共享了同一个component和fixture变量导致状态在用例之间互相串。典型的问题片段长这样describe(DashboardComponent, () { let component: DashboardComponent; let fixture: ComponentFixtureDashboardComponent; beforeEach(async () { await TestBed.configureTestingModule({...}).compileComponents(); }); it(case A, fakeAsync(() { fixture TestBed.createComponent(DashboardComponent); component fixture.componentInstance; component.ngOnInit(); fixture.detectChanges(); // 没有 destroy, 没有 flush })); it(case B, fakeAsync(() { fixture TestBed.createComponent(DashboardComponent); component fixture.componentInstance; component.ngOnInit(); fixture.detectChanges(); })); });问题有四个fixture和component是 describe 级别的共享变量一旦某个用例提前返回后面的用例拿到的可能不是新实例而是残留的旧实例。没有在afterEach里执行fixture.destroy()组件实例、DOM、订阅器都不会释放。fakeAsync里的定时器没有flush或tick定时器不断累积。前一个用例的ngOnInit()里如果订阅了valueChanges这个订阅不会自动取消下一个用例创建新组件时两个组件的订阅同时存活可能互相触发变更检测。这四条叠加在一起才是“时好时坏”的真正根源。单独看某一个用例都是无辜的但放在同一个文件里、按不同顺序组合起来就会像多米诺骨牌一样一个触发一个。3.4 修复代码与前后对比我重写了这个 spec 文件核心思路是“每个用例独立初始化、独立销毁、显式处理所有异步”。修复后的用例长这样describe(DashboardComponent, () { let component: DashboardComponent; let fixture: ComponentFixtureDashboardComponent; beforeEach(async () { await TestBed.configureTestingModule({ declarations: [DashboardComponent], imports: [CommonModule, HttpClientTestingModule], }).compileComponents(); }); afterEach(() { if (fixture) { fixture.destroy(); } }); it(输入搜索词后应触发防抖查询, fakeAsync(() { fixture TestBed.createComponent(DashboardComponent); component fixture.componentInstance; component.ngOnInit(); fixture.detectChanges(); const input fixture.nativeElement.querySelector(input); input.value angular; input.dispatchEvent(new Event(input)); tick(1000); fixture.detectChanges(); expect(component.query).toBe(angular); flush(); })); });改动点一共三处在afterEach里显式fixture.destroy()确保每个用例结束都销毁组件释放订阅。在断言前tick(1000)把debounceTime(500)的定时器提前走完。在用例结尾flush()把 fakeAsync 队列里剩余任务全部清空。同文件里其他有真实定时器的用例我一律包进fakeAsync并且在结尾flush。有些用例涉及路由跳转或者动画还额外加了discardPeriodicTasks()来处理重复的周期任务。改完之后做了两组验证# 1. 单文件循环 30 次 for i in {1..30}; do npx ng test --watchfalse --browsersChromeHeadless --includesrc/app/dashboard/dashboard.component.spec.ts; done # 2. 全量测试循环 10 次 for i in {1..10}; do npx ng test --watchfalse --browsersChromeHeadless; done全部通过。CI 上连续 20 次构建也没有再出现失败。这个 bug 到此才算真正画上句号。4. CI 层的加固与长期防护4.1 给 CI 加“韧性”重试策略与分片运行根因修完只是第一步。工程上一个更现实的问题是你没法保证团队里每个人都写得出一尘不染的异步测试。所以 CI 这边也需要做一些加固把偶发问题的爆炸半径控制到最小。我当时的处理分两层第一层是重试。对于纯 flaky 的测试官方态度是“重试不能根治但能挡住噪声”。我在 GitHub Actions 里对测试 step 做了简单重试方式很粗暴但有效- name: Frontend Tests run: npx ng test --watchfalse --browsersChromeHeadless continue-on-error: true id: frontend-tests这里用continue-on-error先让红色任务不阻断主流程后面再根据退出码重试一次。不过后来我换成了更干净的方式写一个 shell 脚本循环跑最多三次只有三次都失败才真正返回非零退出码。第二个改动是分片。Angular 测试如果文件太多单台 runner 跑太久浏览器出幺蛾子的概率会上升。我按照模块把测试拆成了四个分片在 CI 上用 matrix 并行执行strategy: matrix: shard: [1, 2, 3, 4] steps: - name: Run Frontend Tests run: npx ng test --watchfalse --browsersChromeHeadless --shard${{ matrix.shard }}/4分片后每个 runner 的测试量减少整体跑得更快单点故障的影响也变小。4.2 把“玄学”变成“科学”日志、artifacts、失败信息结构化我强烈建议每个前端项目在 CI 里把测试日志和覆盖率报告都存成 artifact。这不是选择题而是必答题。具体做法是在 GitHub Actions 测试 step 之后加一个 upload-artifact 步骤- name: Upload Test Logs if: always() uses: actions/upload-artifactv4 with: name: frontend-test-logs path: | test-results/ coverage/ retention-days: 7有了日志以后排查 flaky 就快很多。你不需要反复重跑猜原因直接在 artifact 里搜索FAILED、timer(s) still in the queue、Error:这些关键词一搜一个准。另外一个容易忽略的点是“失败用例的名称”。Jasmine 默认会在日志里打印完整的 spec 描述比如DashboardComponent should trigger debounce search FAILED这个描述本身就是定位线索所以写测试用例描述的时候最好把“前置条件 目标行为 异常分支”写全别写should work这种废话。日志结构化之后哪怕你自己不排查团队其他成员接手也能快速定位。4.3 flaky 测试速查表这里我把自己踩过的一些典型 Angular 测试问题整理成了表格方便你以后排查时对照。现象高概率原因处理方式报1 timer(s) still in the queuefakeAsync 内存在未 flush 的定时器在用例内tick()或flush()报ExpressionChangedAfterItHasBeenCheckedError变更检测在异步回调后没有被正确触发fixture.detectChanges()或手动触发ChangeDetectorRef.markForCheck()用例之间互相影响单独跑都绿全量跑必红describe 内共享变量未重置把component、fixture定义为局部变量afterEach里destroy随机报Cannot read properties of undefined异步数据还没返回就断言或者组件尚未初始化使用fakeAsynctick或fixture.whenStable()测试有时超时真实 HTTP 请求未走 mock使用HttpClientTestingModule并确保所有请求被flushMock 了window.setTimeout但没清理污染全局环境使用jasmine.clock().install()后必须在afterEach里uninstall()这张表不是银弹但绝大多数 Angular 项目的 flaky 测试都逃不出这几个框。你对照着查通常能省下半天无头苍蝇式的时间。5. 让“时好时坏”不再回来测试基建层面的几条建议这里我还想多说几句因为修这个 bug 的过程中我对 Angular 测试代码的“卫生习惯”有了新的认识。首先不要迷信“测试能跑过就行”。如果你在写测试时用了fakeAsync请把flush写在用例结尾哪怕你认为没有遗留定时器。如果你不 flush这个用例就像一颗定时炸弹不知道什么时候会炸到别人。其次强烈建议统一团队内对fixture.detectChanges()的调用节奏。我自己更倾向“显式调用”而非“自动变更检测”也就是在TestBed.configureTestingModule里不写autoDetectChanges: true而是自己控制detectChanges()的位置。这样虽然代码量多一点但行为可预测出了问题也好定位。另外给 Angular 项目引入eslint-plugin-jasmine或类似的规范插件可以自动检测一些明显的坏味道比如在beforeAll里创建 component 实例在afterEach里没有destroy在fakeAsync里缺少flush这些规则不贵但对团队代码质量的提升非常明显。我这次如果早一点有这个规范可能这个 bug 在 code review 阶段就被拦住了。最后再说一个关于“复现”的心得。遇到“时好时坏”的 CI 测试我的默认动作是改一条环境变量把 Jamine 的随机执行顺序打开。很多 flaky 测试在随机顺序下会更快现形。如果你顺手把--bail参数加上还能在第一个失败用例处停止日志更干净省得翻半天历史记录。我就是靠这套方法从一个“不知道什么时候冒出来的 timer 报错”一路跟踪到了debounceTime的定时器泄漏再从定时器泄漏牵扯出组件实例残留最后把整个测试文件重写了一遍。整个过程最贵的不是修复本身而是说服自己“不要重跑停下来看看日志”。多数人遇到 CI 变红本能反应是重跑重跑绿了就把问题丢到一边。但 flaky 测试这种东西你不根治它它就一直在那里而且大概率会在你发版前的那个晚上突然出现给你上一课。这次的经验让我养成了一个习惯CI 流水线变红之后第一件事永远是下载失败日志搜索几个关键错误关键词二十分钟内就能判断出问题大概在哪一层。如果你也被“时好时坏”折磨过希望这篇文章能让你省下一些本不该花的排查时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →