同其它组件一样,首先我们初始化原型: ```bash panjiedeMacBook-Pro:clazz panjie$ ng g c edit CREATE src/app/clazz/edit/edit.component.css (0 bytes) CREATE src/app/clazz/edit/edit.component.html (19 bytes) CREATE src/app/clazz/edit/edit.component.spec.ts (612 bytes) CREATE src/app/clazz/edit/edit.component.ts (267 bytes) UPDATE src/app/clazz/clazz.module.ts (663 bytes) ``` ## V层 班级新建组件中引入了教师选择列表组件,此时在原型初始化中我们仍然引用该组件以达到快速初始化原型的目的: ```html <form class="container-sm"> <div class="mb-3 row"> <label class="col-sm-2 col-form-label">名称</label> <div class="col-sm-10"> <input type="text" class="form-control"> <small class="text-danger"> 班级名称不能为空 </small> </div> </div> <div class="mb-3 row"> <label class="col-sm-2 col-form-label">班主任</label> <div class="col-sm-10"> <app-klass-select></app-klass-select> <small class="text-danger"> 必须指定一个班主任 </small> </div> </div> <div class="mb-3 row"> <div class="col-sm-10 offset-2"> <button class="btn btn-primary">保存 </button> </div> </div> </form> ``` 然后在`src/app/clazz/edit/edit.component.spec.ts`下的动态测试模块中加入`app-klass-select`对应的组件`KlassSelectComponent`: ```typescript await TestBed.configureTestingModule({ declarations: [EditComponent, KlassSelectComponent] }) ``` 最后在单元测试的测试用例上加入`fit`,启用`ng t`来查看原型: ![image-20210401150902012](https://img.kancloud.cn/a0/8f/a08fe878473e4f0f5b2e8e97b735f8da_1608x154.png) 由于`KlassSelectComponent`依赖于后台的数据,在未引入相关的模块和MockApi拦截器前,进行单元测试时将触发上述错误。该错误在教师新增组件的测试中已经出现过一次,请尝试自行解决。 ## 图解原理 随着项目的增大、组件间的关系将会逐渐加强。这将会使我们的测试变得越发不可控制。假设A组件当前仅仅依赖于`AMockApi`,接着我们又开发了100个依赖于组件A的新组件,在测试时我们分别在100个动态测试模块的拦截器上加入了``AMockApi``: ```typescript providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiInterceptor.forRoot([AMockApi]) } ] ``` 然后正常的事情发生了:由于需求的变更,A组件增加了新功能,新功能添加完成后,不但要依赖于`AMockApi`,还依赖于`BMockApi`。 想像一下当A组件的功能功能后,使用`ng t`来测试其它100个组件时,每个组件都需要加入到`BMockApi`的依赖: ```typescript providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiInterceptor.forRoot([AMockApi, BMockApi]) } ] ``` 在Angular中,可以建立一个专门用于模拟后台的模块,在该模块中加入所有的`MockApi`来模拟整个后台。然后再其它需要使用模拟后台的测试模块中,仅仅引入这个专门的模块即可。以前测试模块为例,按照传统的解决办法,若想成功创建教师编辑组件,则应该在`imports`中加入`HttpClientModule`: ```typescript beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [EditComponent, KlassSelectComponent], imports: [ HttpClientModule ] }) .compileComponents(); }); ``` 原理图对应如下: ![image-20210401154227192](https://img.kancloud.cn/e0/ce/e0cea73195649747bcc6800ebf4a8b87_1690x546.png) 而如果想使用`MockApi`来替代真实的后台Api,则需要在`provider`中提供`HTTP_INTERCEPTORS`: ```typescript await TestBed.configureTestingModule({ declarations: [EditComponent, KlassSelectComponent], imports: [ HttpClientModule ], providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiInterceptor.forRoot([]) } ] }) .compileComponents(); ``` 此时`MockApi`请作用的原因是由于`HttpClient`在发起数据请求以前,会查看当前模块中提供的`HTTP_INTERCEPTORS`,如果当前模块提供了`HTTP_INTERCEPTORS`则会调用第一个`HTTP_INTERCEPTOR`,然后由第一个调用第二个,依次累推。 ![image-20210401155231459](https://img.kancloud.cn/3c/3b/3c3baf656b03d0fd4320453fb7b77ff8_1212x612.png) 但`MockApi`不有按常规出牌,当调用到它时,它没有继续调用下一个拦截器,而是直接自己用模拟数据回应了: ![image-20210401155426811](https://img.kancloud.cn/7b/b1/7bb107384234a13913ab95e64d929abf_1126x594.png) 这就是在加入`MockApiInterceptor.forRoot()`拦截器后模拟API起作用的原因。 而`MockApiInterceptor`是根据`forRoot()`方法中传入的数组来返回对应的模拟数据的,所以若想某些模拟数据生效,是需要加相应的类加入到`forRoot()`方法中: ```typescript beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [EditComponent, KlassSelectComponent], imports: [ HttpClientModule ], providers: [ { provide: HTTP_INTERCEPTORS, multi: true, useClass: MockApiInterceptor.forRoot([TeacherMockApi]) } ] }) .compileComponents(); ``` 以上我们再一次解释了引用`HttpModule`以及`MockApi`的原理。 ## 公共测试模块 我们添加一个公共测试模块的话,原理图如下: ![image-20210401160603834](https://img.kancloud.cn/c7/6c/c76c2dffd822b5f4e738ed241cb581e1_1672x1084.png) 我们将提供`HttpClient`的`HttpModule`以及提供拦截器的`MockApiInterceptor`添加到新的模块中,然后在动态测试模块中引用这个新模块。这样以来,将动态测试模块需要`httpClient`时,则会由其`imports`的**专用用于模拟全部后台Api的模块**中的`HttpModule`中引入;当`httpClient`发起请求时,也会由**专用用于模拟全部后台Api的模块**中查找`HTTP_INTERCEPTORS`。 下面,让我们共同来实现上述原理图。 ### 建立模块 该模块的作用是为单元测试中的动态测试模块提供MockApi功能,将其命名为`MockApiTestingModule`,并将其建立在`mock-api`下是个不错的选择: ```bash panjie@panjie-de-Mac-Pro mock-api % pwd /Users/panjie/github/mengyunzhi/angular11-guild/first-app/src/app/mock-api panjie@panjie-de-Mac-Pro mock-api % ng g m MockApiTesting --flat CREATE src/app/mock-api/mock-api-testing.module.ts (200 bytes) ``` **注意:** 上述命令使用了`--flat` 参数,表示把文件创建到当前文件夹,如果不加该参数,`ng`将会当前文件夹下创建一个`mock-api-testing`文件夹。 此时`mock-api`文件夹下共存在3个文件: ```bash CREATE src/app/mock-api/mock-api-testing.module.ts (200 bytes) panjie@panjie-de-Mac-Pro mock-api % tree . ├── clazz.mock.api.ts ├── mock-api-testing.module.ts └── teacher.mock.api.ts 0 directories, 3 files ``` 然后打开`mock-api-testing.module.ts`,为其添加对`HttpModule`的依赖,以及提供MockApi拦截器: ```typescript +++ b/first-app/src/app/mock-api/mock-api-testing.module.ts @@ -1,11 +1,20 @@ import {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; +import {HTTP_INTERCEPTORS, HttpClientModule} from '@angular/common/http'; +import {MockApiTestingInterceptor} from '@yunzhi/ng-mock-api/testing'; @NgModule({ declarations: [], imports: [ + HttpClientModule, CommonModule + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, multi: true, + useClass: MockApiTestingInterceptor.forRoot([]) + } ] }) export class MockApiTestingModule { ``` 接下来将所有的MockApi文件,添加到`forRoot([])`方法中的数组中: ```typescript - useClass: MockApiTestingInterceptor.forRoot([]) + useClass: MockApiTestingInterceptor.forRoot([ + ClazzMockApi, + TeacherMockApi + ]) ``` ## 应用模块 现在可以在相关的测试中引用`MockApiTestingModule`来快速的满足组件的各种后台请求了,打开班级编辑组件对应的测试文件,如此配置动态测试模块: ```typescript +++ b/first-app/src/app/clazz/edit/edit.component.spec.ts import {MockApiTestingModule} from '../../mock-api/mock-api-testing.module'; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [EditComponent, KlassSelectComponent], imports: [ MockApiTestingModule ] }) .compileComponents(); }); ``` 此时再查看`ng t`,在控制台出现了找不到`formControl`的异常。 ![image-20210402092204041](https://img.kancloud.cn/7f/bc/7fbcf4e29250f4d28e0100a27ef45554_1206x92.png) 这是由于教师选择组件中使用了响应式表单,而当前动态测试模块并没有引入响应式表单模块,所以提示无法识别`formControl`的错误。解决的方法是在当前动态测试模块中引用响应式表单模块: ```typescript - MockApiTestingModule + MockApiTestingModule, + ReactiveFormsModule ``` 此时控制台错误消息。错误虽然消失了,但当前方案仍存在一定的问题。这是由于我们在解决该问题时,仍然需要考虑子组件对`ReactiveFormsModule`的依赖问题,如果子组件依赖于多个模块,我们同样还需要在当前的单元测试中引入其它多个模块。所以从本质上来,我们仍然面临着某个子组件进行更新后,引用它的父组件需要全部在`imports`中增加一遍对应模块的问题。带着这个问题继续学习,我们将在后面的章节中同样使用抽离模块的方法来解决该问题。 最后,由于在`MockApiTestingModule`中使用的拦截器是`MockApiTestingInterceptor`而非`MockApiInterceptor`,所以我们需要在测试代码中手动触发数据返回,同时启用变更检测。 ```typescript fit('should create', () => { expect(component).toBeTruthy(); + getTestScheduler().flush(); + fixture.autoDetectChanges(); }); ``` 最终效果如下: ![image-20210402093456159](https://img.kancloud.cn/48/cd/48cdff073cc4185dc865b053300ea27e_1116x414.png) 至此,基于教师选择组件的班级编辑组件原型初始化完毕。 | 名称 | 链接 | | ---------- | ------------------------------------------------------------ | | 定义提供者 | [https://angular.cn/guide/dependency-injection-providers#defining-providers](https://angular.cn/guide/dependency-injection-providers#defining-providers) | | 本节源码 | [https://github.com/mengyunzhi/angular11-guild/archive/step6.4.1.zip](https://github.com/mengyunzhi/angular11-guild/archive/step6.4.1.zip) |