本节可能稍微有些难度,目的在于带领大家更多的了解Angular。如果在学习第二遍后仍然心中没有头绪的话,建议学习完整个教程或对Angular有了更深入的认识或是以后在生产中碰到类似的需求时再阅读。 当前项目我们执行`ng t`的时候,由于测试文件执行的过程是随机的。在某些时候,将发生类似于如下错误: ![image-20210415104727722](https://img.kancloud.cn/c1/43/c1432b2cbbe532bedda51852726456a8_2988x186.png) 或者在控制台中报如下错误: ![image-20210415104748537](https://img.kancloud.cn/5e/1d/5e1d021743e3cf8c4afc5a84bc89c12e_2574x140.png) 以及以下错误: ![image-20210415104806534](https://img.kancloud.cn/ac/b8/acb865d4ae4e6be833213e68dd5558b6_2566x124.png) 或以下错误: ![image-20210415104819641](https://img.kancloud.cn/82/de/82deda7b99e07157f069251cd0808971_2582x138.png) 或以下错误: ![image-20210415104839087](https://img.kancloud.cn/7d/7b/7d7b429fb1497f74a53606f483a195fd_2552x138.png) 或者其它的类似错误。 但奇怪的是,无论我们单独执行任意一个单元测试文件,都不会发生任何异常。这又是为何呢? ## 单例模式 在继续学习之前,让我们新建个测试文件复习一下前面讲过的单例模式。在`src/app`目录下新建`single-case.spec.ts`文件,加入一些前面我们在单例模式的学习中已经认知的代码,其代码初始化如下: ```typescript import {TestBed} from '@angular/core/testing'; describe('单例模式相关测试', () => { beforeEach(async () => { await TestBed.configureTestingModule({}) .compileComponents(); }); }); ``` 然后新建一个服务A: ```typescript +++ b/first-app/src/app/single-case.spec.ts @@ -1,6 +1,18 @@ import {TestBed} from '@angular/core/testing'; +import {Injectable} from '@angular/core'; +import {randomNumber} from '@yunzhi/ng-mock-api'; describe('单例模式相关测试', () => { + @Injectable({providedIn: 'root'}) + class A { + key: number; + + constructor() { + console.log('a constructor be called'); + this.key = randomNumber(); + } + } + beforeEach(async () => { await TestBed.configureTestingModule({}) .compileComponents(); ``` 再建立两个依赖于A的服务BC: ```typescript +++ b/first-app/src/app/single-case.spec.ts @@ -13,6 +13,18 @@ describe('单例模式相关测试', () => { } } + @Injectable({providedIn: 'root'}) + class B { + constructor(private a: A) { + } + } + + @Injectable({providedIn: 'root'}) + class C { + constructor(private a: A) { + } + } + beforeEach(async () => { await TestBed.configureTestingModule({}) .compileComponents(); ``` 然后新建一个测试用例,在该测试用例中,分别获取Angular托管下B以及C的实例。 ```typescript +++ b/first-app/src/app/single-case.spec.ts @@ -29,4 +29,11 @@ describe('单例模式相关测试', () => { await TestBed.configureTestingModule({}) .compileComponents(); }); + + fit('验证单例', () => { + const b = TestBed.inject(B); + console.log(b); + const c = TestBed.inject(C); + console.log(c); + }); }); ``` 控制台如下: ![image-20210415120922802](https://img.kancloud.cn/12/84/1284e5fbfa54d44428f89e6234ab2db0_1398x322.png) 如我们的预期一致,在Angular中服务A是个单例的,且在整个生命周期中仅被初始化一次。 我们说之所以能够在BC中获取到相同的A,是同于A使用了`@Injectable({providedIn: 'root'})`注解。该注解的作用是在根模块中提供A,所以在Angular项目的任意模块需要A的实例时,都会先查是否有了A的实例,如果有则直接注入,如果没有则先实例化一个再注入。 ## providers 预使模块能提供A的能力,除了可以把A注入到根模块外,还可以将其声明在`providers`中。为此,我们先删除A类上的`@Injectable({providedIn: 'root'})`注解: ```typescript +++ b/first-app/src/app/use-class.spec.ts @@ -3,7 +3,6 @@ import {Injectable} from '@angular/core'; import {randomNumber} from '@yunzhi/ng-mock-api'; describe('useClass相关测试', () => { - @Injectable({providedIn: 'root'}) class A { key: number; ``` 此时将出现找不到A的提供者的错误: ![image-20210415135249554](https://img.kancloud.cn/2a/be/2abe99c597e4c711bbd09ecf0c1cb024_1666x168.png) 接下来将A声明在当前动态测试模块的`providers`中: ```typescript +++ b/first-app/src/app/use-class.spec.ts @@ -25,7 +25,11 @@ describe('useClass相关测试', () => { } beforeEach(async () => { - await TestBed.configureTestingModule({}) + await TestBed.configureTestingModule({ + providers: [ + 👉{provide: ①A, useClass: ②A} + ] + }) .compileComponents(); }); ``` 👉 如此当前模块便拥有了投供A的能力。该行语句可理解为:当需要①A时,使用②A是否有可用的对象,有则直接使用,没有则实例化一个。当然,像这种使用A提供A的语句,还可以简写为`{provide: A}`或者`A`。也就是说下面①②③三种写法的是等价的: ```typescript providers: [ A, ① {provide: A}, ② {provide: A, useClass: A} ③ ] ``` ## forRoot() 前面我们学习过:使用`@Injectable({providedIn: 'root'})`时该服务的注入范围为根模块。而Angular中所有的模块都属于根模块的子模块,所以如果一个服务声明为`@Injectable({providedIn: 'root'})`,则无论在哪个模块中注入它,最终都会得到相同的实例。 但有一些服务即需要将其注入范围声明为根模块,又不能够使用`@Injectable({providedIn: 'root'})`注解,比如说需要进行配置的路由模块,或是需要进行配置的`MockApi`模块,这时候便需要Angular建议的`forRoot()`方法来解决这个问题。 比如我们在提供A时,手动的设定key的值: ```typescript +++ b/first-app/src/app/single-case.spec.ts @@ -6,9 +6,9 @@ describe('单例模式相关测试', () => { class A { key: number; - constructor() { + constructor(key: number) { console.log('a constructor be called'); - this.key = randomNumber(); + this.key = key; } } ``` 此时历史的写法则将报一个无没初始化A的错误: ![image-20210415150836227](https://img.kancloud.cn/08/79/087935e4d289669aff520223ec0c82fb_1516x108.png) 的确如此:我们在构造函数中声明了一个参数key,但Angular并不清楚这个key具体应该赋给它什么值。此时可以创建一个拥有静态的`forRoot()`模块: ```typescript +++ b/first-app/src/app/single-case.spec.ts @@ -1,5 +1,5 @@ import {TestBed} from '@angular/core/testing'; -import {Injectable, ModuleWithProviders} from '@angular/core'; +import {Injectable, ModuleWithProviders, NgModule} from '@angular/core'; describe('单例模式相关测试', () => { @@ -12,6 +12,18 @@ describe('单例模式相关测试', () => { } } + @NgModule() + class AModule { + static ①forRoot(key: number): ②ModuleWithProviders<AModule> { + return { + ngModule: ③AModule, + providers: [ + {provide: A, ④useValue: new ⑤A(key)} + ] + }; + } + } + @Injectable({providedIn: 'root'}) class B { constructor(private a: A) { ``` - ① `forRoot()`方法可以接收任意参数 - ② 返回值类型为`ModuleWithProviders`,在该类型上指名了提供的服务类型AModule - ③ 返回值的ngModule字段对应`ModuleWithProviders`上的泛型 - ④ `useValue`用于返回一个对象。意为当Angular需要一个A实例时,如果有则直接返回;如果没有,则使用`useValue`后的实例 - ⑤ 提供了一个根据参数设置了`key`的实例 如此以来便可以使用`forRoot()`方法来添加一个配置了参数的A服务了: ```typescript +++ b/first-app/src/app/single-case.spec.ts @@ -38,8 +38,8 @@ describe('单例模式相关测试', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - providers: [ - {provide: A, useClass: A.forRoot(123)} + imports: [ + AModule.forRoot(123) ] }) .compileComponents(); ``` 注意,此时将`AModule.forRoot()`方法声明在`imports`中而不是`providers`中。如此以来,便可以在动态模块中`imports`相应模块的同时,加入特定的配置参数`123`了: ![image-20210415155002300](https://img.kancloud.cn/66/5f/665f9025920d8a3180da5c113fd112df_810x194.png) ## 步入正题 铺垫了这么多,终于可以步入正题了。之所以在单元测试中出现了随机出现了那么多的错误,是由于`TestBed`在处理拦截器时使用了惰性加载。 我们在单元测试中存在以下代码①: ```typescript src/app/clazz/class-select.component.spec.ts providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiTestingInterceptor.forRoot([ TeacherMockApi ]) } ``` 以下代码②: ```typescript src/app/clazz/add/add.component.mock-api.sepc.ts providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiInterceptor.forRoot([ClazzMockApi, TeacherMockApi]) } ``` 以及以下代码③: ```typescript src/app/mock-api/mock-api-testing.module.ts providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiTestingInterceptor.forRoot([ ClazzMockApi, TeacherMockApi, StudentMockApi ]) } ] ``` Angular的惰性加载机制使得其在进行多文件的单元测试时,多个文件中的TestBed共享了`MockApiTestingInterceptor.forRoot()`方法中的返回值,该返回值是使用不同的MockApi进行配置的。 所以如果其共享的是①的返回值,该返回值则仅仅添加了`TeacherMockApi`对应的几个模拟接口,此时依赖于另外两个模拟接口的单元测试将报错;如果其共享的是②的返回值,该返回值则仅仅添加了`ClazzMockApi, TeacherMockApi`两个模拟接口, 此时依赖于另一个模拟接口的单元测试将报错;如果其共享的是③的返回值,则将添加所有的模拟接口,此时所有的单元测试将正常执行。 问题猜测到了,解决问题的方案也就不难了。只需要在所有应用后台模拟API的地方添加上所有的Mock文件即可。或是删除在其它测试文件中加入Http拦截器的代码,改为引用`MockApiTestingModule`从而达到引入所有API的目的。 具体修正文件列表如下: ```typescript clazz/add/add.component.mock-api.spec.ts clazz/clazz.component.spec.ts clazz/klass-select/klass-select-form-control.component.spec.ts clazz/klass-select/klass-select.component.spec.ts ``` 请自行修正。 <hr> 模拟接口的错误修正后,最后再修正个逻辑上的错误。把鼠标放到出错的文件名上,浏览器将显示该文件所在的具体位置。 ![image-20210415161608176](https://img.kancloud.cn/95/ee/95ee97f9fcd84e096abbd14d6308ff7e_2092x414.png) 找到该文件的第29行代码: ```typescript beforeEach(() => { fixture = TestBed.createComponent(EditComponent); component = fixture.componentInstance; 👉 fixture.detectChanges(); }); ``` 说明在组件初始化时发生了错误。继续查看错误的堆栈信息发现是在组件的46行,调用loadbyId方法时触发了MockApi,近而发生了异常: ![image-20210415161848961](https://img.kancloud.cn/ea/70/ea706a6291baa412fb40cda71486a728_1012x272.png) ```typescript ngOnInit(): void { 34 const id = this.activatedRoute.snapshot.params.id; 35 👉 this.loadById(+id); } /** * 由后台加载预编辑的班级. * @param id 班级id. */ loadById(id: number): void { console.log('loadById'); this.formGroup.get('id')?.setValue(id); this.httpClient.get<Clazz>('/clazz/' + id.toString()) 46 👉 .subscribe(clazz => { console.log('接收到了clazz', clazz); this.nameFormControl.patchValue(clazz.name); this.formGroup.get('teacherId')?.setValue(clazz.teacher.id); }, error => console.log(error)); } ``` 分析代码可知,这是由于在组件初始化时,将34行代码获取到的ID值null再转换为数字时发生了NaN错误造成的。预解决这个错误,则需要保证37行代码接收的值非null。 为此,我们组件初始化以前为这个34的代码设置一个id值: ```typescript +++ b/first-app/src/app/clazz/edit/edit.component.spec.ts @@ -6,6 +6,7 @@ import {MockApiTestingModule} from '../../mock-api/mock-api-testing.module'; import {ReactiveFormsModule} from '@angular/forms'; import {getTestScheduler} from 'jasmine-marbles'; import {RouterTestingModule} from '@angular/router/testing'; +import {ActivatedRoute} from '@angular/router'; describe('EditComponent', () => { let component: EditComponent; @@ -26,6 +27,8 @@ describe('EditComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EditComponent); component = fixture.componentInstance; + const activatedRoute = TestBed.inject(ActivatedRoute); + activatedRoute.snapshot.params.id = 123; 👉 fixture.detectChanges(); }); ``` 这样的话,当执行到👉组件初始化的代码时,获取`activatedRoute.snapshot.params.id`便会得到数据123,而非null了。 至此,我们解决了单元测试中所有非预期错误。 | 名称 | 链接 | | -------- | ------------------------------------------------------------ | | 单例服务 | [https://angular.cn/guide/singleton-services](https://angular.cn/guide/singleton-services) | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step7.3.zip](https://github.com/mengyunzhi/angular11-guild/archive/step7.3.zip) |