Angular测试性能优化-慎用TestBed.get方法
前言
随着项目业务越来越大,单元测试的case也越来越多。疏于管理和维护的单元测试,渐渐地变成了开发效率的绊脚石。每当提交代码的时候,总要跑上一段时间的单元测试,在结束时,突然告诉你要重新来过,或许因为测试挂掉,或许因为远程分支有了新的提交。
天下苦单元测试久矣!
项目测试现状
项目中的单元测试,在每次执行时,都是按照模块执行,只跑被修改模块的测试,其他模块的测试不会被执行,对跑过的单元测试文件,我们也增加了缓存机制,并且每次执行都是4个文件一起跑,在基础配上,我们对单元测试的执行效率也是有一定优化的。
除此之外,并非所有的测试文件执行效率都低下,而只有部分文件执行时间过长。有些二十多个测试case的文件甚至比四十多个测试case的文件执行时间还要长。
是时候要看看这些执行效率慢的原因了,不能让我的电脑CPU做无用功,毕竟好钢不能用在刀把上。
Jest的大致流程
首先了解一下Jest执行的一个大概流程:
- 读取配置文件,创建测试环境
- 根据配置文件和命令参数收集需要运行的测试文件和测试用例,打包编译
- 对于每个测试文件,Jest会为每一个
describe
提供一个执行环境,并执行测试套件中的it
用例 - 在执行测试用例的过程中,捕获断言并记录结果
- 测试完成后,将记录的结果生成报告
图片引用来自《Jest实践指南》
项目存在的问题
基于项目现有的痛点,结合执行流程上看,我关注的优化重点就集中在了执行测试用例的过程中。
通过对执行效率慢的文件进行分析,发现存在以下两个问题:
- 随着业务的变更,在维护单元测试时,未能及时将不用的import或者注入在组件中的service移除
- 单元测试中经常存在引入了并非当前组件或者服务需要的依赖
首先,移除了不用的import或者service,保证单元测试文件的有效性。不过对于提升效率并没有显著的提高。
接着优化第二个问题,也是最让我感到困惑的地方。举个例子,在一个页面组件中,我注入了某个service,并调用了service的a方法。a方法是一个负责http请求的方法,它会调用http service中的b方法,而b方法则是向后端发送请求,进行数据交互逻辑的地方。但是在页面组件的单元测试中,我仍然需要提供httpClient模块,似乎不是那么的合理,毕竟我没有直接调用http service。
通过对比,我发现了一个注入依赖的方式,也是这些效率低下的测试文件都会用的方式,也是我之前比较喜欢用的方式---TestBed.get
方法注入service依赖。
TestBed.get方法
TestBed.get
方法是Angular测试工具中的一部分,用于获取通过依赖注入提供的服务实例。在测试中,我们通常模拟服务的行为,以便进行更精确的断言和验证, TestBed.get
方法允许我们直接获取一个服务的实例,而不需要在每个测试中都手动创建模拟。
利与弊
TestBed.get
方法减少了我们在测试时,引入依赖的麻烦。不需要手动去初始化依赖的实例,并且不需要去mock依赖中的方法,造成我们测试文件行数的变多。
但是事务是双面性的。 TestBed.get
方法引入不必要的依赖,也就是我一直困惑的,为什么组件没有直接引用http service,但是我在测试中还需要引入相关模块或者服务。
Angular在构建测试时,会对组件或服务进行依赖树的构建。我们使用 TestBed.get
获取完整的服务实例的同时,也需要在测试中引入该服务所依赖的相关内容。这就导致我们编写一个组件测试,需要引入组件中并未使用到的,但引入的服务有所依赖的相关内容,增加了组件引入内容的庞大。
其次,我们一般都在BeforeEach
周期函数中使用TestBed.get
方法,也就意味着,每执行一个单元测试case,我们都会重新实例化一个service依赖。假设这个service功能居多,体量巨大,那我们每运行一个单元测试case的时间就会变长。小河流汇聚成大汪洋,累积下来,我们整个文件的运行时间就会变长,效率也就低下了。
第三,因为项目中使用了TestBed.get
方法注入了完整的服务实例,在某些编程不规范的情况下,可能会将一个没有任何依赖的通用方法放在服务中,而非公共方法中,在跑单元测试时,我们也不容易发现这种不合理的存放。当然这种情况也不能怪在TestBed.get
方法头上。
性能优化方案
针对 TestBed.get
存在的问题,可以进行了如下优化:
在使用 TestBed.get
方法,需要在 provide
中注入这个依赖,并且通过 useValue
对用到的方法进行mock。由于我们在 provide
中注入了这个依赖, TestBed.get
方法在注入时,就不再使用完整的依赖实例,而是基于 provide
提供的方法,生成实例。这里也就需要我们能够保证,组件中用到的方法,都需要在 useValue
中提供,否则测试运行就会报错
import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';
describe('MyComponent', () => {
let dataService: DataService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{
provide: DataService,
useValue: {
fetchData: jest.fn()
}
}]
});
dataService = TestBed.get(DataService);
});
it('should fetch data', () => {
spyOn(dataService, 'fetchData').and.returnValue(['data']);
// 现在可以进行关于数据获取的测试
});
});
在使用 provide
方案后,我进行了更一步的升级。既然我们提供了服务的实现,那么是否可以直接弃用 TestBed.get
方法,手动提供一个新的服务?答案是可以的。所以我最终的实现如下:
import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';
describe('MyComponent', () => {
const fetchDataMock = jest.fn();
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{
provide: DataService,
useValue: {
fetchData: fetchDataMock
}
}]
});
});
it('should fetch data', () => {
fetchDataMock.and.returnValue(['data']);
// 现在可以进行关于数据获取的测试
});
});
优化结果
在清空jest缓存之后,我重新各执行了一遍优化前后,当前模块的单元测试。结果如下:
总结
当然除了慎用 TestBed.get
方法之外,还有一些小的优化点,虽然提升效率不是那么明显,但是也多少有点作用。可以当做一个checklist,我们对比参照:
- 减少不必要模块的导入
- 使用
TestBed.get
时,尽量采用provide
进行服务mock,除非你的依赖体量不大,并且依赖的依赖不多时,可以考虑直接使用TestBed.get
方法引入,而不mock - 可以全局实例化的内容,通过
beforeAll
进行声明,不要放在beforeEach
中 - 针对组件依赖比较简单,不需要构建依赖树,并且不需要校验模版的情况下,使用
NO_ERRORS_SCHEMA
替代CUSTOM_ELEMENTS_SCHEMA
- 对大测试文件进行拆分,增加worker,同时跑多个测试文件
快捷的方法固然让人快乐,但是也请不要贪杯!