
1. 症状初现先从CI日志判断问题值不值得深挖如果你是Angular项目的维护者一定经历过这种血压升高的瞬间CI流水线吭哧吭哧跑了二十分钟最后一阶段亮红灯点进去一看挂在一条跟你本次改动八竿子打不着的测试上。你本地跑一遍全绿。你重新触发一次CI又绿了。你关掉页面告诉自己这只是偶发。直到一周之内它红了三四次而且每次都是那么几个spec文件里的固定几条用例你才不得不承认这不是运气问题是有人在测试代码里埋了一颗雷现在开始响个不停。这次要聊的就是这么一颗雷。现象用四个字总结时好时坏。问题出在Angular项目的单元测试跑在GitHub Actions上Karma Jasmine HeadlessChrome的组合非常标准的Angular CLI脚手架配置。失败的时候报错信息极其朴素就是一行expect失败比如期望一个列表长度为3实际渲染出了4条。但无论怎么翻日志都找不到和这次改动相关的线索因为跑失败的这条用例涉及的功能最近一个月压根没人碰过。遇到这种情况第一反应是重跑。我见过太多团队的做法是CI加一个“失败自动重试一次”的配置然后假装问题不存在。但这里有个很现实的问题如果你不去查清楚这类失败只会越来越频繁。随着测试文件增多、测试之间的组合数变大状态污染和资源泄漏类问题是指数级恶化的今天一周红三次下个月可能一天红三次。与其天天陪它赌运气不如花半天时间把这个“时好时坏”变成“必现”然后一刀解决。1.1 别急着重跑先给“时好时坏”建档我做事有个习惯遇到偶发失败第一件事不是复现而是记录。把CI日志里每一次失败的关键信息摘出来整理成一个小档案。要记的东西不多但每一条都有用失败发生在哪个job、哪个spec文件、哪条it用例、报错信息原文、以及这次失败的提交编号。这里特别要提一个细节Angular CLI基于Jasmine跑测试时默认会随机打乱测试用例的执行顺序日志里会打印一行类似Randomized with seed 48123这样的信息。这个seed极其重要它是复现“偶发”的钥匙。固定住这个seed理论上可以还原出和CI一模一样的执行顺序。所以我每次记录失败时一定会把seed抄下来。另一个要观察的维度是失败用例是固定的还是散落的。如果这周红了五次五次都是A.spec.ts里的某一条用例这是好消息——问题高度集中在某个文件里大概率是状态污染或定时器泄漏。如果五次失败分布在完全不同的文件、不同的用例那可能要考虑CI机器本身的问题比如资源不足、内存吃紧、超时设置过短。这两种情况的排查思路完全不一样分不清就走弯路。我这次遇到的情况是前者。连续几次失败点开来看翻来覆去就是A.spec.ts和B.spec.ts这两个文件里的几条用例。这个规律一旦浮现我的判断就清晰了这和CI环境无关和提交内容无关纯粹是测试代码自身的问题。1.2 值不值得查两类偶发故障的判断逻辑很多人在“时好时坏”的测试面前纠结我到底要不要花时间查查吧可能查半天结果是虚惊一场不查吧它又老来烦你。我的判断标准很简单就两条。第一看失败用例是否集中在固定文件集合中。如果答案是需要那这个债迟早要还。为什么因为这类问题几乎都是测试之间的隐式耦合两个单独跑都正常的spec放在同一个进程里跑就会互相影响。Angular的TestBed单例机制加上Zone.js对定时器的管理让这种隐式耦合变得特别隐蔽。你今天两个文件互相污染明天再来两个文件加入战场整个测试套件就会变成一个随时可能爆的炸药桶。越早查成本越低越晚查组合爆炸之后基本没法查。第二看失败类型。资源型问题一般表现为超时、无响应、浏览器崩溃错误信息里经常是disconnected或有类似Timed out waiting for the WebDriver的提示。状态型问题则表现为断言失败、js错误错误信息看起来“平平无奇”。如果是前者优先考虑加超时、加内存、做分片如果是后者那就得动真格去排查测试代码了。这次的错误全部是断言失败没有任何超时和浏览器崩溃。所以我的判断非常明确这是状态型问题必须打开代码仔细查。2. 复现与隔离让偶发Bug在本地“现出原形”排查偶发问题最忌讳的就是“我猜大概是XX问题”。猜对了算运气好猜错就白忙一场。正确做法是先在本地稳定复现把一个低概率的随机事件变成高概率的必然事件然后再分析根因。Angular项目的单元测试跑在Karma里本地复现的环境和CI唯一的区别就是机器性能和显示器有无。CI用的是HeadlessChrome本地默认调试用的是Chrome。我建议复现时直接走HeadlessChrome最大限度贴近CI环境。2.1 循环跑测试把偶发概率量化成数据偶发问题的一大特点就是单跑一次大概率是绿的。你要做的第一件事是把它跑成必现或者至少把失败率从一个模糊的低概率变成一组具体数据。我的做法是用一个简单的for循环在本地连续跑N次测试每次把日志落盘最后统计失败次数。命令大致是这样for i in $(seq 1 50); do npx ng test --watchfalse --browsersChromeHeadless --source-mapfalse --progressfalse 21 | tee -a flaky_run_$i.log done几个参数说明一下。--watchfalse表示跑完就退出不在watch模式下挂起。--browsersChromeHeadless是为了贴近CI。--source-mapfalse很关键关闭source map可以显著降低单次测试的内存占用避免把“内存不足”这个变量搅进来。--progressfalse是让日志干净点方便看最终结果。循环跑完之后统计一下每个日志文件是PASS还是FAIL。我这里跑完50次红了4次失败率大概8%。这个概率和CI上观察到的“三四天红一次”是能对上的说明问题可以在本地复现不需要去扛着CI远程调试。还有一个小技巧如果循环跑了几十次一次都没挂不要急着换方向。可以把CI失败日志里那串seed拿到本地来固定跑npx ng test --watchfalse --browsersChromeHeadless --source-mapfalse --seed48123Jasmine支持通过参数指定seedAngular CLI会把--seed透传下去。固定seed能让测试按CI失败时的顺序执行很多靠顺序触发的偶发问题在固定seed下原形毕露。这个方法帮我省过不少时间值得记下来。2.2 用二分法锁定“问题组合”确认能在本地复现之后第二阶段就是缩小范围。Karma的isolate机制在Angular CLI里不太好用但CLI提供了--include参数可以指定只跑某一个或某几个spec文件。我先把怀疑范围缩小到了两个文件A.spec.ts和B.spec.ts。验证方式很简单单独跑A循环20次全绿单独跑B循环20次全绿。然后两个文件一起跑命令是这样npx ng test --include**/A.spec.ts --include**/B.spec.ts --watchfalse --browsersChromeHeadless --source-mapfalse结果很有意思循环10次红了8次。单独跑全绿合在一起跑就挂这基本上把问题锁定在“两个spec之间存在状态污染”上了。我再用二分法试了其他组合。把C.spec.ts、D.spec.ts加进来一起跑失败率没有显著变化去掉A只跑B和其他文件全绿去掉B只跑A和其他文件也全绿。结论越来越清晰就是A和B之间产生了某种化学反应。这里我多说一句排查心得锁定“问题组合”的过程不要靠猜要像做实验一样控制变量。一次只动一个变量记录结果再动下一个。我在这个阶段大概操作了不到四十分钟就把几十个spec文件的怀疑范围收敛到了两个文件之间的组合问题。有了明确的复现路径后面分析根因就只是时间问题。3. 深入根因Angular测试里的“幽灵状态”到底藏在哪问题锁定在A.spec.ts和B.spec.ts这对组合之后我开始冷静下来逐行读这两个文件的测试代码。刚看了一遍第一感觉是这俩文件八竿子打不着A测的是列表页的轮询逻辑B测的是详情页的数据展示业务上毫无关联。但Angular测试里的“幽灵状态”往往就是这么不讲道理——它不按业务耦合只按进程共享来搞事情。3.1 TestBed是全局单例不是每个文件独享的Angular单元测试里TestBed是一个在测试进程级别共享的单例对象。这个结论很多Angular开发者其实是知道的但知道归知道写测试的时候还是会忘。TestBed维护的是“当前测试环境”的模块定义。你在beforeEach里调TestBed.configureTestingModule是在往这个全局单例上设置当前测试需要的模块配置。如果一组测试跑完你不调用TestBed.resetTestingModule那么这个单例上遗留的providers、declarations、imports会残留在原地。下一组测试跑的时候configureTestingModule会在同一个对象上继续操作某些provider可能会被覆盖但有些复杂的、多级的provider配置可能就会叠加起来产生非常诡异的效果。举个最常见的错误写法describe(ListComponent, () { beforeEach(() { TestBed.configureTestingModule({ declarations: [ListComponent], providers: [ PollingService, { provide: OrderApiService, useClass: MockOrderApiService } ] }); }); it(should load items on init, () { const fixture TestBed.createComponent(ListComponent); fixture.detectChanges(); expect(fixture.componentInstance.items.length).toBe(3); }); // 注意这里没有 afterEach(() TestBed.resetTestingModule()) });看着没毛病但问题恰恰出在那行注释上。你忘了在afterEach里重置TestBed下一组测试跑的时候TestBed的模块配置里还残留着上一个文件的providers。如果下一个文件的configureTestingModule也注册了同名provider还可能侥幸覆盖掉但如果注册的是不同名的provider或者引用了同一个token的不同实现那么残留的状态就会渗透到下一组测试里去。不过A和B这两个文件都有一个共同点它们都在beforeEach里调用了TestBed.configureTestingModule而且都配置了provider。按道理说即使没有resetTestingModule后一个文件的配置也会覆盖前一个。所以仅仅是TestBed残留解释不了AB组合必挂的现象。真正的问题要比这深一层——出在定时器上。3.2 fakeAsync的定时器泄漏真正的元凶A.spec.ts里大量使用fakeAsync来测试轮询逻辑。fakeAsync是Angular测试库提供的一个工具它把测试包裹在一个特殊的Zone里在这个Zone内setTimeout、setInterval这些异步操作不再依赖真实时间而是由fakeAsync的虚拟时钟控制。你在测试里调tick(2000)虚拟时钟就往前走2秒所有排队的定时器都会在这个虚拟时间轴上执行。测定时器逻辑非常方便但它有一个特别容易踩的坑定时器泄漏。先看一段故障代码的简化版本// A.spec.ts describe(PollingService, () { beforeEach(() { TestBed.configureTestingModule({ providers: [PollingService] }); }); it(should poll every 2 seconds, fakeAsync(() { const service TestBed.inject(PollingService); service.startPolling(); tick(2000); expect(service.requestCount).toBe(1); tick(2000); expect(service.requestCount).toBe(2); service.stopPolling(); // 如果前面的断言失败stopPolling根本不会执行 // interval就永久残留在fake zone里 })); it(should stop polling, fakeAsync(() { const service TestBed.inject(PollingService); service.startPolling(); tick(2000); service.stopPolling(); tick(5000); expect(service.requestCount).toBe(1); // 这里同样没有discardPeriodicTasks })); });看起来每条用例都调用了stopPolling对吧但注意看第一条用例的注释如果断言失败函数直接抛错退出stopPolling就执行不到了。还有一种情况是测试中途因为别的原因提前return同样会跳过清理逻辑。更隐蔽的是fakeAsync的定时器即使被stopPolling清掉了如果这个fakeAsync zone结束时内部还有pending的定时器它的引用也不会被立刻回收。那残留的interval和B.spec.ts有什么关系呢问题就出在Zone.js的机制上。fakeAsync创建的zone在被销毁时如果里面有未清理的定时器这个定时器任务会被挂在一个全局的pending任务列表里。当下一次再创建一个fakeAsync zone时这些遗留的任务有可能被“带”进新的zone里。于是在B.spec.ts的某个fakeAsync用例里你调tick(2000)本来只想触发B自己的定时器结果A残留的那个interval回调也在这个时间片里被触发了。这个回调会调用A里注册的service方法往B的某个状态里写入了一条B压根没有预期的数据。等B的断言跑起来一看列表莫名其妙多了一项立刻红。这也是为什么报错信息永远看起来“莫名其妙”——因为多出来的那条数据在当前spec文件的代码里根本找不到来源。它来自另一个文件一个已经跑完、但“灵魂”还滞留在Zone里的幽灵定时器。3.3 证据链我们是怎么确认它就是interval的坦白说我一开始也没直接想到定时器泄漏。我是被逼着走到这一步的。整个过程有三条关键证据我认为比最后的结论本身更有参考价值。第一条证据在B.spec.ts的失败用例里我在断言前临时加了一行console.log打印了service的调用记录。结果发现代码里根本没调用过的一个API居然被调用了。这个API恰好是A.spec.ts里那个轮询service会调用的接口。这条线索直接指向了A。第二条证据我回到A.spec.ts做对照实验。把A里的fakeAsync用例全部临时注释掉再和B组合跑循环10次全绿。然后把A里非fakeAsync的用例注释掉只留fakeAsync的用例和B组合跑挂得干干净净。这个实验说明问题不是A的普通测试逻辑而是它在fakeAsync里搞出来的东西。第三条证据在A.spec.ts的afterEach里加上discardPeriodicTasks组合跑100次0失败。discardPeriodicTasks是Angular测试库专门用来清空fakeAsync里周期性定时器的API。这一加问题立刻消失整个因果关系就闭环了。这个证据链走下来我才敢拍板根因就是A.spec.ts里fakeAsync定时器泄漏到了B的fakeAsync zone里导致B的测试时间轴上出现了“不属于自己的回调”。至于为什么以前没爆发、最近才频繁——大概率是最近某个版本的Angular或Zone.js升级后对定时器回收的时机处理发生了变化把原来碰巧被掩盖的问题暴露出来了。4. 修复与加固从测试代码到CI流水线的三层方案根因找到了修复方案其实不难难的是怎么确保这个坑不再被踩第二次。所以我当时的处理思路不是只改一行代码就完事而是从测试代码、CI配置、团队规范三个层面各做一层防护。三层都做完了我才有信心说这个问题算是真正画上句号。4.1 修复spec代码让定时器生命周期可控第一个层面的修复是治本的直接改A.spec.ts。核心原则只有一条fakeAsync用例结束时必须保证没有残留的定时器。具体来说有三种收尾方式任选其一即可第一种在用例末尾显式调用discardPeriodicTasksit(should poll every 2 seconds, fakeAsync(() { const service TestBed.inject(PollingService); service.startPolling(); tick(2000); expect(service.requestCount).toBe(1); tick(2000); expect(service.requestCount).toBe(2); service.stopPolling(); discardPeriodicTasks(); // 兜底清理即使stopPolling没清干净也不残留 }));第二种如果测试涉及组件在用例末尾显式调用fixture.destroy()触发组件的ngOnDestroy由组件内部的清理逻辑来clearInterval。注意一定要在断言成功之后调用最好放在try/finally结构里防止断言失败导致清理逻辑被跳过。第三种如果测试逻辑复杂可以在afterEach里统一做兜底清理。Angular测试库允许在fakeAsync之外调用discardPeriodicTasks吗不建议必须确保它在fakeAsync zone内调用才有意义。所以更稳妥的做法是给所有可能有定时器的describe块统一加一个afterEach里面做reset和清理describe(PollingService, () { afterEach(() { TestBed.resetTestingModule(); }); // 每条fakeAsync用例内部该discardPeriodicTasks还是要discard });这里我要特别强调一个认知误区TestBed.resetTestingModule并不会清理定时器。它重置的是DI容器和模块配置但定时器是挂在Zone.js进程上的不归TestBed管。所以你不能指望重置TestBed就万事大吉定时器必须显式清理。另外A.spec.ts里还有一条“使用真实setInterval做轮询”的坏味道。测试里其实没必要跑真实轮询更好的做法是注入一个假的定时器服务或者用fakeAsync包一层。这样测试既快又可控也不容易泄漏。我在修复时顺手把轮询服务改成了可注入的抽象测试时传入一个受控的fake实现彻底规避了真实定时器带来的不确定性。4.2 CI侧加固分片、超时与有限重试第二个层面是CI配置的加固。根因修复后理论上问题已经消失但我依然给CI加了一层防护。原因很简单团队不止一个人提交代码未来的新测试可能还会引入类似问题。CI侧能做的是把这类问题的破坏面降到最低。Angular CLI 15及以上版本支持--shard参数可以把测试按文件分成多组到多个job上并行跑。分片的好处有两点一是单进程内测试文件数量变少跨文件污染的概率指数级下降二是跑得也更快。我的CI配置里加了一个分片维度把全部spec文件分成4个shard每个shard跑一份Karma实例。其次CI脚本里我加了一个“有限重试”逻辑。注意是有限的不是无限。我的做法是第一次失败后自动重跑一次如果第二次通过job标记为成功但在日志里输出一个警告提示这条测试“曾不稳定”如果第二次也失败job就是真正的红色。这样既不会因为偶发问题频繁阻塞发布又能把flaky测试暴露给团队。set e npx ng test --watchfalse --browsersChromeHeadless --source-mapfalse --shard0 --shard-size20 EXIT_CODE$? if [ $EXIT_CODE -ne 0 ]; then echo ::warning::Test failed on first attempt, rerunning once for flaky check npx ng test --watchfalse --browsersChromeHeadless --source-mapfalse --shard0 --shard-size20 EXIT_CODE$? fi exit $EXIT_CODE这个脚本里有一点设计考量重试只做一次再多就容易掩盖真问题了。如果你看到某条测试经常第一次挂、第二次过那说明它依然是flaky需要回到测试代码本身找原因而不是继续加次数。超时方面我也做了调整。Jasmine默认的超时时间是5秒对于单测来说够用但CI机器在高峰期可能load偏高导致异步请求5秒内没有返回。我把Karma配置文件里的超时放宽到了10秒同时对长时间没有响应的用例在日志里打点了一个明显标记方便后续观察。超时不是越大越好过大的超时会掩盖性能退化10秒是一个相对平衡的值。4.3 预防性检查把“测试卫生”写进规范第三个层面是长期的预防。我和团队对了一遍测试代码的现状顺手定了几条规则都写进了MR模板里新代码必须遵守。规则一每个spec文件的describe块里必须有afterEach至少调用TestBed.resetTestingModule()。这是一票否决项没有就不合代码。规则二所有fakeAsync用例结尾要么flush要么discardPeriodicTasks要么确保组件销毁并清理定时器。三选一一个都不能少。规则三不要在spec文件里定义模块级的可变变量。比如let data []这种写在外面、多个it共用的变量很容易形成隐式依赖。每个用例的数据应该在自己的it内独立创建。规则四涉及定时器、轮询的场景优先注入假实现不要创建真实定时器。真实定时器跑起来慢是一回事泄漏了又查不出来才是最大的坑。这几条规则看着简单但每一条都是这次排查里真实踩过的坑。写规范不难难的是执行。我们后来接了一个ESLint插件在MR检查阶段自动提示缺少afterEach reset的spec配合人工review效果还可以。把规则变成工具检查比每次人工提醒要可靠得多。5. 常见问题速查CI测试偶发失败的排查技巧全记录写到这里这次的debug过程基本讲完了。但我猜很多读者看完还是觉得“道理我都懂遇到问题还是不知道从哪下手”。所以我整理了这些年亲自踩坑和帮同事排查过的一批典型问题做成一个速查表。排查CI偶发失败的时候对着表看一圈大概率能帮你节省半天到一天的时间。5.1 八种典型“时好时坏”现象速查表现象可能原因快速验证方法解决方向同一个spec内某条用例偶发失败用例之间共享了模块级可变变量或定时器未清理固定Jasmine seed循环跑20次每个it内独立初始化数据fakeAsync收尾清理两个spec文件一起跑才失败跨文件TestBed状态污染或fakeAsync定时器泄漏单独跑全绿组合跑挂即锁定afterEach加resetTestingModule处理残留定时器CI上失败本地怎么也复现不了CI机器资源紧张导致超时或HeadlessChrome内存受限本地加--source-mapfalse跑观察是否复现增大超时做测试分片关闭source map降低内存占用改了几行无关代码突然一大片测试红了测试之间存在顺序依赖被Jasmine随机顺序暴露固定先前的seed复现再换seed观察差异消除测试间的隐式共享保持用例相互独立报错说某对象或属性为undefined但代码里明显存在上一个fixture未销毁DOM事件和组件引用泄漏在失败用例里console.log当前fixture数量显式调用fixture.destroy()确保组件销毁周期性任务相关的用例偶尔多出一次调用setInterval回调残留被下一个fakeAsync继承在用例里打印调用计数观察是否多出预期次数每个fakeAsync用例收尾调用discardPeriodicTasksCI日志提示浏览器disconnectedChrome进程崩溃或宿主机内存不足降低并发数或改小shard大小限制Karma并发浏览器数加资源或调超时测试全绿但CI总耗时越来越长存在真实定时器或未被清理的异步请求堆积看单文件测试耗时找异常文件把真实定时器改成fake实现用fakeAsync统一控制时间这张表不能覆盖所有情况但覆盖了我在Angular项目里遇到的绝大多数“时好时坏”。如果你遇到的情况不在表里建议按照第二条的思路走一遍先锁定必现再分析共享状态基本不会跑偏。5.2 我踩坑后养成的几个排查习惯说几个我已经刻进肌肉记忆的操作习惯吧。第一遇到flaky测试第一反应不是重跑而是去翻最近十次CI失败的日志看失败用例是否集中在固定的几个文件。这个信息决定了后续所有排查方向花五分钟整理能省半天冤枉路。第二本地循环跑测试起步就是30次以上。跑一次两次看不出概率跑个三五次全靠运气。30次跑下来如果一次都不挂要么问题真的没了要么复现条件还没构成这时候再去查环境差异才有意义。第三日志里的Jasmine seed一定要存下来。我见过太多人在CI日志里看到Randomized with seed xxx就直接忽略其实它就是你复现偶发问题的金钥匙。用--seed指定它等于拿到了那一次的“执行现场”比什么“小概率事件”靠谱多了。第四修完flaky测试之后不要只跑一遍确认绿了就完事。我的标准是修复后本地循环跑100次确认0失败才敢推到远端。这个习惯看起来费时其实一次循环也就十来分钟比发上去之后第二天又红回来体面得多。这个系列聊到第二十三期大部分bug都是能稳定复现的这次碰上个“时好时坏”的排查过程确实更磨人。但回过头看这种问题反而教给我的最多——它逼着你去理解TestBed的真实生命周期、Zone.js的定时器管理和fakeAsync的底层机制而不是停留在“会用API”的层面。下一次再遇到类似的诡异问题至少心里有底知道该从哪里入手了。