Ivy给单元测试带来的好处
今年的Angular AdoCare对于测试的讨论非常丰富。比如说Angular8单元测试有问题啊!还有在写第一次单元测试之前,为了避免在模拟实现中卡住手的相关知识也被介绍了出来。
我自己在3年前的AdoCare中也写过关于Zone.js对于Angular测试的贡献之类的东西,所以在Angular的各种功能中,测试部分是我特别喜欢的一部分之一。
嗯,说到最近Angular中的热门话题,当然不能不提Ivy。
Ivy本身基本上是对内部结构进行的更改,所以不会对应用程序代码本身产生影响,但它是将AoT(提前编译)编译设置为默认的铺垫,许多人可能已经听说过它可以减小包大小,简化构建过程等效果。
我今天想讨论的主题是,关于如何在Ivy上进行单体测试以达到极速!
事实上,Angular官方的博客中也提到了Ivy可以改善单元测试性能的问题(源自“现在是Ivy兼容性选择预览版的时候了!”)。
更好的测试性能– 在Angular中,我们看到框架单元测试快了1.5倍,材料单元测试快了2.7倍,而材料单元测试的内存使用量减少了81%。
Angular的單元測試非常非常慢。
在解释Ivy的效果之前,先说明一下Ivy之前的问题。
你可能能够感觉到,在一定规模上做过Angular项目,但总的来说,Angular的单元测试本身就很慢。也许会有人指出:“使用webpack构建并在Karma中启动Chrome并运行测试很慢,这是无可避免的吗?” 但这不是关键问题。
在总执行时间约为8秒的情况下,可以看到函数compileModuleAndAllComponentsAsync占用了近6秒的时间。大约占总时间的75%。根据名称可以猜测,这是一个用于编译TestBed所需模块和组件的函数。由于是组件的编译,所以大概的内容是解析.html和.css,生成相应的viewDef等等,总之是一个看起来比较慢的操作。
我们在验证中使用了下面的代码,TestBed.configureTestingModule(…).compileComponents对应于该函数的调用源。
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
AppModule,
],
}).compileComponents(); // これ
}));
[...new Array(100).keys()].forEach(i => {
it('should create the app' + i, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
});
});
});
如果像上面的代码一样,将Module完全导入TestBed,那么该Module中的所有Component的编译将在每个spec中都会执行。这意味着测试的执行时间将随着Component的数量呈非线性增长,即$O(n^2)$。
這次的例子只是創建了100個 ng g c 的組件和100個 Jasmine 的單元測試,但實際的應用中,組件的 HTML 和 CSS 會更加複雜,每個組件的測試數量也會更多(如果有寫好測試的話)。
只是進行了一個小修改,卻需要在 CI 中等待 Karma 數分鐘,這是我不想要的。
常常超乎想象
关于这一点,正如先前所述,在传统的Angular单元测试中,“随着组件数量的增加,编译时间将占用大量测试执行时间”。因此,削减编译时间是非常令人高兴的。
Ivy是非常好的。如下所述,以前要削减测试执行时间需要一些特殊的技巧,但是对于Ivy来说,只需要简单地启用它,甚至从Angular 9.x开始,Ivy默认就被启用了,所以我们终于摆脱了这个烦恼。
为什么会发生加速?
我想从这里开始深入一些神经质的内容。
这次的文章里,我已经提到了最想说的“Ivy太棒了!测试速度快了!太开心了!”但如果就这样收尾,有点像个傻子,所以既然如此,我想再详细追述一下为什么进行加速。
问:这与《进击的巨人》有关系吗? 答:实际上,那与此无关。
当我听到“Ivy有助于单元测试性能的提升”这句话时,我最初想到的是Ivy和默认的AoT之间的关系。
实际上,如果要手动启用Ivy,以下是相应的步骤,这里还提到了”在构建中启用AoT”。
-
- tsconfig.jsonに enableIvy: true を記述
- angular.jsonの build architectにて、 aot: true を記述
在Ivy测试瓶颈中,compileModuleAndAllComponentsAsync是一个执行JiT编译的函数,所以我想:“如果在Karma测试中激活AoT,那么不再进行运行时编译,速度会更快,对吧?”然而,结论是错误的。
仔细思考后,启用AOT设置” aot: true “只是对build命令提供的选项的一部分。
顺带一提,architect是指Angular CLI中与serve和test等构建命令相关的实体,它代表了一系列所谓的“任务”(而脚手架生成命令的实体则称为schematics)。
关于ng test的事情,有一个名为karma的构建体在运行。在angular.json文件中,它是在test键下进行配置的。aot: true对karma构建体的配置没有任何影响,因此即使启用了Ivy(包括Angular CLI 9.x),目前仍然会在Karma中继续使用JiT编译。
问:为什么仅仅设置enableIvy为true就无法加快速度?
答:因为JiT的实现完全不同。
所以,这次的加速切换只是在tsconfig.json中写入“enableIvy: true”的部分。这意味着切换从View Engine(Render 2)到Ivy(Render 3)。
这个名为 enableIvy 的选项不仅改变了 Angular Core 的 View Engine,还改变了 TestBed 实体以及与 TestBed 相关的 Testing Compiler 的实现,分别被 Ivy 所专用的实现 TestBedRender3 和 R3TestBedCompiler 所替代。尽管使用了 TestBed.configureTestingModule 这样的语法,实现却在不知不觉中被替换了。个人而言,这让我有一种「静态方法是什么呢……」的感觉,但是只要在 tsconfig.json 中写入 enableIvy,就可以在 ts 编译器 ngc 的阶段做任何事情,所以不能只因为是静态方法就一定有相同的实现,这就是这个问题的讨论内容。
闲话休题。现在,Ivy专用的测试编译器 R3TestBedCompiler 将实际的组件编译工作委托给 Angular Core 的 Ivy 用 JiT 编译器。
-
- Render 2の場合:TestBed.configureTestingModule(…).compileComponents() の時点でJiTコンパイルが動作し、specで必要となるModuleやComponentのコンパイルが実行される
- Ivy(Render 3)の場合:TestBed.configureTestingModule(…).compileComponents() はAngular Core Ivy JiTコンパイラへ、Moduleをキューイングするだけ。その後、キューに入れらたModule定義にしたがって、JiTコンパイラがModuleやComponentのコンパイルを実行する
无论是在Render 2还是在Ivy中,处理流程本质上几乎是一样的,但Ivy的关键之处在于它经历了一个叫做”模块定义信息排队”的步骤。Ivy的JiT编译器在从队列中取出并执行编译时,实现了”跳过已编译的模块”的功能。
因此,无论spec的数量是多少,只要在imports中列出的Module仅执行一次JiT编译,就会变得更快。这就是缓存或者飞行重量模式的原理。
缓存和测试编译器
与在浏览器中运行应用程序不同的是,测试编译器中的麻烦之处在于需要提供覆盖模块和覆盖组件等功能,以便为测试目的存根化或重写特定的依赖关系。使用这些功能将创建一个情况,即“即使是同名的模块,在每个规范中实体也是不同的”,因此与本次缓存优化非常不兼容。在Ivy的情况下,通过在R3TestBedCompiler中将JiT编译器入队时稍微更改键来避免这种情况。
在 Angular 2 或 4 版本时,我曾经做过类似的事情,即通过JiT编译器来缓存Karma的结果以提高性能。
由于Angular是为了允许在TestBed上进行编译器的依赖注入而设计的,所以我们通过使用Flyweight模式强行封装了Render 2的JiT编译器,并添加了缓存。
正如之前提到的,Ivy自带了JiT编译器自身的缓存管理功能,这很好。但是Render 2的JiT编译器本身并没有那么多功能,所以我记得当时自己写了一个非常危险的实现(从这个意义上说,对于此次通过Ivy加速测试的事情,我有些自以为上天的感觉,觉得终于赶上时代了)。
在Ivy上单元测试加速的工作量之前
即使没有启用Ivy,从Angular 5.x开始就提供了加速的方法。
一般情况下,使用Angular CLI生成的项目,src/test.ts应该如下所示。
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
实际上,函数 initTestEnvironment 现在可以接受一个名为aotSummaries的数组作为第3个参数。根据前缀aot可以推断出,可以通过将例如app.module.ngsummary.json等被AoT编译(ngc命令)的成果文件的内容传递给它来使其工作。
我方所述的内容
在Ivy中,compileModuleAndAllComponentsAsync是测试的瓶颈,它是执行JiT编译的函数,所以我认为:“如果在Karma测试中启用AoT,那么就不会进行运行时编译,会变快。”
这是一种加快速度的模式。
为什么需要 *.ngsummary.json?因为在Render 2中,通过AoT编译时,除了生成app.component.js之外,还会生成下面的文件。
-
- app.component.ngfactory.js: テンプレートの成れの果て
-
- app.component.css.shim.ngstyle.js: CSSの成れの果て
- app.component.metadata.json: デコレータ情報の成れの果て
问题是,app.component.js文件中缺少对上述AoT成果物的引用。
因此,即使设置了如下的TestBed,也无法知道负责DOM操作的ngfactory位于何处。
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
});
});
为了向TestBed传达AoT的结果,我们需要提供类似于“这里是AppComponent的ngfactory”的摘要信息。.ngsummary.json就承担了这个责任。
此外,由于AoT和Karma被以Angular CLI的形式控制,因此要在Karma中实际使用aotSummaries,只能选择自己努力解决或者更改architect,这是唯一的选择。
我认为,从Angular 9开始,aotSummaries选项可能会被废弃。因为在Ivy中,app.component.html和app.component.css的信息也会被完全编译成app.component.js,所以ngsummary.json文件本身不会被输出。
另外,就单元测试加速的角度来说,正如之前所述,Ivy即使使用JiT,速度也足够快,没有必要额外使用AoT来运行Karma。
事实上,我试着在Karma的架构中使用AoT进行改写,但在执行时间上并没有显著的差异。相反,改写架构在一秒内就完成了,并且让我更加体验到了Ivy的AoT编译流程的简洁性,这是最引人注目的亮点。
最后
尽管我写得有点啰嗦,但是这篇文章的结论是“无论是JIT还是AOT,只要启用Ivy,单元测试都会变得更快”。
明天是@nao_y先生!
从Angular 4.x开始,这个问题一直困扰着我。我在https://medium.com/@Quramy/performance-angular-unit-testing-9ce30ae83e7等地也写过相关内容。↩
在https://github.com/angular/angular/blob/8.2.14/packages/core/src/render3/jit/module.ts#L49-L66附近。↩
https://angular.io/api/core/testing/TestBed#inittestenvironment↩
顺便说一下,我曾经为Karma的architect提交了一个可以处理aotSummaries的PR,但由于“实验性太强,还有编译器的考虑”,这个PR被拒绝了。现在回想起来,可能已经在考虑Render 3了。↩