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 --watch=false --browsers=ChromeHeadless --source-map=false --progress=false 2>&1 | tee -a flaky_run_$i.log done几个参数说明一下。--watch=false表示跑完就退出,不在watch模式下挂起。--browsers=ChromeHeadless是为了贴近CI。--source-map=false很关键,关闭source map可以显著降低单次测试的内存占用,避免把“内存不足”这个变量搅进来。--progress=false是让日志干净点,方便看最终结果。
循环跑完之后,统计一下每个日志文件是PASS还是FAIL。我这里跑完50次,红了4次,失败率大概8%。这个概率和CI上观察到的“三四天红一次”是能对上的,说明问题可以在本地复现,不需要去扛着CI远程调试。
还有一个小技巧:如果循环跑了几十次一次都没挂,不要急着换方向。可以把CI失败日志里那串seed拿到本地来固定跑:
npx ng test --watch=false --browsers=ChromeHeadless --source-map=false --seed=48123Jasmine支持通过参数指定seed,Angular 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' --watch=false --browsers=ChromeHeadless --source-map=false结果很有意思:循环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残留,解释不了A+B组合必挂的现象。真正的问题要比这深一层——出在定时器上。
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用例结束时,必须保证没有残留的定时器。具体来说有三种收尾方式,任选其一即可:
第一种,在用例末尾显式调用discardPeriodicTasks:
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(); 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 --watch=false --browsers=ChromeHeadless --source-map=false --shard=0 --shard-size=20 EXIT_CODE=$? if [ $EXIT_CODE -ne 0 ]; then echo "::warning::Test failed on first attempt, rerunning once for flaky check" npx ng test --watch=false --browsers=ChromeHeadless --source-map=false --shard=0 --shard-size=20 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-map=false跑,观察是否复现 | 增大超时,做测试分片,关闭source map降低内存占用 |
| 改了几行无关代码,突然一大片测试红了 | 测试之间存在顺序依赖,被Jasmine随机顺序暴露 | 固定先前的seed复现,再换seed观察差异 | 消除测试间的隐式共享,保持用例相互独立 |
| 报错说某对象或属性为undefined,但代码里明显存在 | 上一个fixture未销毁,DOM事件和组件引用泄漏 | 在失败用例里console.log当前fixture数量 | 显式调用fixture.destroy(),确保组件销毁 |
| 周期性任务相关的用例,偶尔多出一次调用 | setInterval回调残留,被下一个fakeAsync继承 | 在用例里打印调用计数,观察是否多出预期次数 | 每个fakeAsync用例收尾调用discardPeriodicTasks |
| CI日志提示浏览器disconnected | Chrome进程崩溃或宿主机内存不足 | 降低并发数,或改小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”的层面。下一次再遇到类似的诡异问题,至少心里有底,知道该从哪里入手了。