换了M1后,我的前端测试提速了10倍!

标题党,勿全信;
这是一篇关于前端单元测试的优化心得,讲述了一个中型团队坎坷的痛与泪的故事;
长文预警,建议当短篇小说看;
干货在末尾。

就在前几天,我拿到了心心念念的 MacBook M1 Pro,上手第一件事就是跑一下项目上的前端测试,期待着久仰大名的 M1 处理器一显神威。

为什么这么着急用 M1 跑测试?这个故事要从半年前说起。

1. 越来越痛的测试

小半年前,项目规模越来越大,随着十几轮冲刺后,我们有两个前端库的代码量和测试也越来越多,于是出现了上面的对话

不知从什么时候开始,项目代码里的测试成了我们的痛点,推代码时会触发全量的测试,而在这推代码的10多分钟里,我是什么都做不了的,电脑卡的要命,不得不站起来去接杯水或上个厕所。

从某种角度来说,也挺好,能时不时的提醒我站起来活动活动,放松一下肩颈和眼睛,看看博客摸摸鱼(不是)。

但问题是 Pipeline 上的速度也是稳定慢,从开始 Build 到 Deploy to Dev,更是达到了离谱的 30 分钟左右,Pipeline 甚至还触发了客户设定的 300 小时/月的限流。

2. 法外狂徒

git push --no-verify 成了我们项目的常态。

有时 QA 小伙伴急着验证某功能、或者我做了好几天的卡准备 Desk Check 却发现一个小问题时,我花了 10 分钟定位并解决了代码,然后推代码触发 git hooks 跑测试,保守计算10min,然后 pipeline 在不用排队的情况下 30min。一切顺利,在我推代码后的 40min 左右才能在环境上看到生效的代码。

而最坏情况下,我将在 10min 后发现某个看起来不相关的测试挂了或覆盖率不达标,修好后再来 10min,推上去后发现 Pipeline 被限流,在仅有 1 个 agent 可用的情况下需要排队(通常发生在月末),这又是 1-30min 左右的等待,排好队后是 30 分钟 build。这将会是 1 小时左右的反馈周期,哪怕我可能只用了几分钟来定位并修复问题,这效率低的简直让人抓狂。

而本地联调也有一些问题,在微服务的环境下,想要本地联调需要至少启动1个前端,1个BFF,3个后端服务,让我本就垃圾的电脑更是负重不堪,电脑经常卡的像是在播放 PPT。

聪明的我想到了一个办法,艺高人胆大,我可以在 --no-verify 后在本地同时跑测试(危险动作,请勿模仿),本地挂了就立马停掉 Pipeline,让本地测试和 Pipeline 并行起来,能节约个10多分钟。但人在河边走,哪有不湿鞋,偶尔会 block 其他小伙伴的部署,这使我无地自容。

于是我们在 Tech Huddle技术研讨会 上几次提出此问题,大家提出了一些解决方案:

  • 从代码层面找出跑得慢的问题
  • 等着换性能强劲的 M1
  • 申请云端机器来跑测试

当时,换 M1 是不可能换的,向 TechOps 同事询问,啥时候能换 M1 呀,啥时候,啥时候……我都觉得自己有点烦,但还是忍不住想问,谁能经得住 M1 的诱惑呢 😍

这条路是暂时行不通了,同时我们在分头寻找其他解决办法。组里一位大佬为我们申请了 AWS 经费,这给了我们实现选项 3 的一些可能。不过大家似乎觉得麻烦,机房在国外,有延迟,体验也不好,有人用了一下后就没怎么用了。

代码层面,项目上前来支援的一位前端大佬开始从代码层面分析问题,某一个测试文件从开始到跑完,竟然会花到50、60秒(大佬还贴心的用 Excel 来跟踪跑的慢的测试)

经过大佬的一番研究,竟然发现了……

3. 信仰崩塌了

项目上的测试一直选用的是 Jest + React Testing Library,初期我们对 Enzyme or Testing Library 的选型也有过一些研究,最终我们选择了偏向黑盒的 Testing Library。

而我则完全 Follow Testing Library 的思想,使用 fireEvent 模拟用户的点击、输入行为,好处不言而喻,为重构代码带来了很多便利。

而为了满足可访问性的要求,我还建议组员使用 getByRole 来获取元素,这样既可以增强可访问性,养成好习惯,也可以按照 Testing Library 的思想来模拟用户的寻找行为而不是使用 data-testid 来获取 DOM。

问题就出在这 getByRole 上。经过大佬的测试,把这些测试其中的 getByRole 换成了 getByTestId,瞬间快了不少。可以在上图看到,在她的电脑上提升比较大的已经从 92s 缩短到了 19s,也就出现了第一张图中提到的 “竟然能在 600s 内跑完”。而在我的电脑上也表现不错(图里从开始的 600s 到修改前的 377s 的提速用了一些不方便透露的黑魔法)

啊!我的 getByRole,一直以来的信仰,崩塌了。

以后就都得回到给元素加 data-testid 的时代了吗?

不甘心的我又找到了一个叫做 getByLabelText 的方法,也可以根据 DOM 中声明的 aria-label 来查找元素,除了参数列表不一样,查找性能是比 getByRole 高一些。

我已经不知道前端项目的测试是不是都是这样了。于是我在公司前端社区和内部论坛向大家调查了一下,结果是大家项目的测试都不会这么慢,区别是有些测试跑得快的项目组,他们没有测 TSX。

那也就是说,测试时虚拟 DOM 的渲染会让效率降低?

于是我和项目组的小伙伴在很长的一段时间里都认为:“Testing Library 的思想很不错,但太慢了,如果有下个项目,我们还是会选择 Enzyme,测 shallow mount 一定会比 render dom tree 快”。

但,真的是这样吗?

4. 世上无难事,只要肯放弃

在正则+全局替换掉 getByRole 后,我们的测试快了一些。不过感觉还是不够,只能这样了吗?

随后我深入进行了一番研究,发现单个测试用例不慢,只有几十毫秒。它慢在从执行命令开始,到第一个测试用例开始执行的这段时间很久。于是我进行了一些简单的分析:

  • 项目用的语言是 TypeScript,要交给 Jest执行需要一个编译的步骤;
  • 启动测试前有一个初始化测试环境的过程,这个脚本位于 setupTest.ts 中;
  • 测 TSX 时需要一个虚拟 DOM 环境,启动这个环境需要耗时;

以上就是从按下按键到测试实际开始执行中大致执行的步骤,按照上面的清单,我:

  • 先是替换了 ts-jest 改用 swc-jest 来编译 TS,无果;
  • 然后在 setupTest.ts 的第一行和最后一行打了时间戳,发现第一行开始时已经是十几秒后了,不是这个原因;
  • 是虚拟 DOM 的原因吗?把 jest-environment-jsdom-sixteen 换成 jest-dom,结果测试都跑不起来了……

看来只有不测 TSX 了,遂放弃。

5. 解决了,但没有完全解决

就这样将就了几个月,团队里另一位前端大佬忍不了了,抽时间写了一份 Git hook 脚本,用于 push 前检查代码的改动影响到的相关文件,然后利用 Jest 的 findRelatedTests 功能来找出相关文件的相关测试,达到增量跑测试的目的。

我试用了一下,体验很棒,在我改一些纯文本的静态文件时,没有找到相关的测试文件(我们规定了静态内容不测),会自动帮我跳过测试。

而当我改动了某个 Hook 文件时,这位大佬的脚本会找到所有涉及到这个 Hook 的组件,和所有用到这个组件的其他组件,然后传递给 Jest,Jest 帮我找到这些文件的相关测试,最后只用跑十几个测试文件就可以推代码了。

这让痛苦万分的我们从以前每次都要跑 1300 多个 case 变为了只用跑几十个 case,算是巨大的进步了。

本地推代码的问题解决了,但没有完全解决。几十个测试也是要跑几分钟的,同样的,期间什么也做不了,CI 上也是一样的需要缓慢的跑全量测试。

不过本地推代码的问题是解决了一些,稍微减缓了一点我使用 --no-verify 的心理负担,感觉还是不错 😌

6. 心心念念的 M1

随着时间一步步推移,眼看就要到 4 月换 M1 的日子了。(公司电脑 3 年一换,4月份该换新款 M1 了)

4月,度日如年。

终于,一天下午收到了公司设备管理部门通知我领新电脑的邮件!我的将死之心重新被点燃!我要赶紧设置好环境,期待着在3分钟内跑完项目的测试。

经过一下午和晚上的忙碌,我终于在晚上 11 点下载好了项目代码,配置好了环境。拿出下午在旧电脑跑的测试截图,准备好测试,在新的 M1 上按下了 Enter 键。

然而,测试还没跑完,我的心已经凉了一半。时间已经过去了5分钟,400个测试文件才跑了一半。而测试跑完时,10分钟已经过去了。

快得吓人?

我在期待什么?

故事还没结束。

在第二天的工作中,我明显感觉到启动环境和索引代码的速度比以往快了不少,切换 APP 也如丝般顺滑。

组里的一位数据大佬拿着他的祖传脚本,说要与我的 M1 一较高下,结果同样的代码,在他的18款 Macbook Pro 上跑了 4 分半,而在我的 M1 上只花了 2 分多,整整有一倍的性能差距。

以上种种事实,让我觉得事情没有那么简单。

7. 水落石出

“有没有一种可能,是我们的代码写的太垃圾了?”

“不可能,绝对不可能![狗头] ”

于是我拿出了我年前写的一份开源代码,技术栈差不太多,跑了一次测试,那测试快的如丝般顺滑。

我不由自主的开始吐槽,念叨着“我们这代码,乔布斯活过来都拯救不了。” 这发言引来了同桌的围观,我们就对那份代码开始了一步步的探索之旅。

首先观察了依赖的版本,不一致。遂调整成一致的版本,依旧龟速。然后观察了 Jest 的配置,不一致。遂调整成一致,依旧龟速。

于是我将项目中的一个测试文件复制到了那个项目中,把那个组件用到的依赖给新项目装上,发现新项目的测试也变慢了。

哼,事情开始变得有趣了起来 😏

我和同桌都觉得很有意思,于是我们决定将那个示例组件中的疑点代码逐步移除,看看到底是我们的什么垃圾代码导致的问题。

这个文件的测试总共花费了 20s。我们先是移除了一个第三方幻灯片组件,无果。随后移除了一个看起来内部很复杂的 Hook,依旧无果,这时代码里只剩一些 if else 渲染分支了。

我们索性删除所有代码,直接 return 了一个空 div,这次测试在 3s 就完成了。情理之中,意料之外。

这让我们的将死之心泛起了一大片浪花,我们有救了!!!

撤销代码! 最后没有删掉的那个代码是 Material UI 的 ButtonButton 内还有一个 Material UI 的 Icon。删掉 Icon,再跑。

3s!

随后我们将项目中的图标库使用 Jest 代理掉(moduleNameMapper),所有图标返回一个空的 svg 元素。

于是

此刻,伴随着颤抖的双手,激动的心情,我想哭得心都有了,太久没有见到过项目如此丝滑的测试了!

358 个文件,2106 个测试,61.98 秒!

这才是单元测试应该有的速度!

Pipeline 的速度也由 27 分钟降到了 15 分钟左右!

信仰捡起来!Testing Library 吹起来!说什么不测 TSX 抽 Hook?TSX 就该测!getByRole 什么的影响不大,用起来!

所以 M1 到底快不快?

代码推上去后,同桌和其他前端小伙伴也迫不及待的也跑起了测试,在他们的电脑上却要花费 120s 左右。

快得吓人。

8. 反思

困扰了项目组半年之久的问题,竟然是因为一个图标库。

我不知道。不知道它是什么时候被引入的。 或许是 1 年前,或许是更久。但这不重要,重要的是代码少的时候你感觉不出,代码量变大时才会越来越痛。

问题的原因和推导的过程并不复杂,也不难找到原因,但问题是我为什么没有早点想到这个办法?

我不知道。不知道为什么没有早点想到这个办法。 或许是我时常在想 ”磨刀不误砍柴工“,我又在想 “想不通的就不要去想了,明天就想通了“,到底是后者胜利了。

又或许是温水煮青蛙,期间我甚至已经没有去想,正常的测试应该是怎样的。我尝试去找原因,找到了但又没有找到,最后只将希望寄托在 M1 上。最后即使是快得吓人的 M1 也于事无补,才回归到了代码本身正视代码问题。

我不知道。不知道如果不换 M1,我会不会带着崩塌的信仰,到下个项目鼓吹 Enzyme 贬低 Testing Library。

完。


总结

如果发现项目前端测试慢(参考速度 2100 个测试 60 ~ 120 秒左右),可以从以下几个方面着手优化

  • 找出一个慢的测试,连带组件放到新建的项目里试试,然后逐步的排查;
  • 编写脚本,找到 git origin/masterHEAD 间的文件变动,然后利用 Jest 的 findRelatedTests 功能,增量测试;
  • 如果用了 Testing Library 的 getByRole,可以尝试替换为 getByLabelText,不丢失可访问性的同时,可能会有奇效;
  • setupTest 中打一些时间戳,排查初始化测试环境的问题;
  • 更换 ts-jestswc-jest,SWC 是一个基于 Rust 的高性能 TypeScript 编译器套件,生产环境没试过;
  • 放弃测 UI 组件,严格控制 UI 组件内不能有复杂逻辑,这点可以通过设置 Coverage 针对文件类型生效;
  • 修改测试策略,不测不重要或代价大收益低的东西,比如纯文本、绘制逻辑、第三方库;
  • 打开 Activity Monitor (或 windows 下的任务管理器) 然后跑测试,看看机器里什么进程在大量占用内存和 CPU,可能会有新发现;
  • 在别的机器上跑测试,比如项目的 Monitor 或客户的云机器,注意安全要求;
  • 向领导申请换 M1,快得吓人。

最后感谢项目里外为此作出贡献的大佬们:Yue、Xin、Yuexie、Yi、Qirong