有了公用的多择组件后我们尝试使用它打造班级选择组件。来到course模块中,并新建KlassMultipleSelect组件。 ``` panjiedeMac-Pro:course panjie$ ng g c KlassMultipleSelect CREATE src/app/course/klass-multiple-select/klass-multiple-select.component.sass (0 bytes) CREATE src/app/course/klass-multiple-select/klass-multiple-select.component.html (36 bytes) CREATE src/app/course/klass-multiple-select/klass-multiple-select.component.spec.ts (721 bytes) CREATE src/app/course/klass-multiple-select/klass-multiple-select.component.ts (328 bytes) UPDATE src/app/course/course.module.ts (478 bytes) ``` V层初始化如下: course/klass-multiple-select/klass-multiple-select.component.html ```html <app-multiple-select [list$]="klasses$" (changed)="onChange($event)"></app-multiple-select> ``` C层初始化如下: course/klass-multiple-select/klass-multiple-select.component.ts ```typescript import { Component, OnInit } from '@angular/core'; import {Observable} from 'rxjs'; import {Klass} from '../../norm/entity/Klass'; @Component({ selector: 'app-klass-multiple-select', templateUrl: './klass-multiple-select.component.html', styleUrls: ['./klass-multiple-select.component.sass'] }) export class KlassMultipleSelectComponent implements OnInit { klasses$: Observable<Klass[]>; constructor() { } ngOnInit() { } onChange($event: Array<Klass>) { } } ``` # 单元测试 我们已经掌握了对嵌套组件的测试的方法,本例中将展示一种更贴近于官方最佳实践的测试组织方法。以HttpClient为例,angular同时提供了可用于生产环境的HttpClientModule以及用于测试环境的HttpClientTestingModule来做为HttpClient的提供者。官方的这种做法使得在测试过程中引入HttpClient的替身变成一件非常轻松的事情。 反观我们当前的测试,将相关测试文件统一加入到TestModule快速的解决了测试过程中依赖问题,这本无可厚非,但却不是一个好的习惯。从简单的意义上来讲,由于并没有贴近于官言的最佳实践所以这种模式必然会存在问题,当前没有发现问题的原因只能是我们对angular理解的还不够深入,应用的还不够广泛;从复杂点的意义上来,在实际的前端开发中团队需要抽离出如用户登录、注销、权限验证、菜单生成、AppOnReady等众多公用服务做为单独的angular库在应用到不同的项目中,而在对应的模块中同步建立测试模块以提供测试替身则符合angular的规范及习惯,使得团队其它项目引入公用服务时的单元测试更加规范以提升整体的开发效率。 ## CoreTestingModule 在CoreMoudle中同步建立测试Module ---- CoreTestingModule ``` panjiedeMac-Pro:core panjie$ ng g m CoreTesting CREATE src/app/core/core-testing/core-testing.module.ts (197 bytes) ``` 建立对应的组件替身 ``` panjiedeMac-Pro:core panjie$ cd core-testing/ panjiedeMac-Pro:core-testing panjie$ ng g c MultipleSelect --skip-tests CREATE src/app/core/core-testing/multiple-select/multiple-select.component.sass (0 bytes) CREATE src/app/core/core-testing/multiple-select/multiple-select.component.html (30 bytes) CREATE src/app/core/core-testing/multiple-select/multiple-select.component.ts (305 bytes) UPDATE src/app/core/core-testing/core-testing.module.ts (307 bytes) panjiedeMac-Pro:core-testing panjie$ ``` 参考angular官方的HttpClientTestingModule提供HttpTestingController,提供CoreTestingController ``` panjiedeMac-Pro:core-testing panjie$ ng g class CoreTestingController --skip-tests CREATE src/app/core/core-testing/core-testing-controller.ts (39 bytes) ``` # 一种示例 如何整理CoreTestingModule以及CoreTestingController相信会有千万种方案,本例给出一种以共生产环境参考: >[warning] 本示例已超出本教程的解释范围,是生产环境下组织单元测试文件的一种方法,仅做参考。 在CoreTesting模块中声明提供CoreTestingController,以便在单元测试中使用Test.get(CoreTestingController)方法来获取CoreTestingController: core/core-testing/core-testing.module.ts ```typescript ], providers: [ CoreTestingController ➊ ] }) export class CoreTestingModule { } ``` * ➊ 声明模块提供CoreTestingController 在测试控制中提供加入、获取相关单元的功能。 src/app/core/core-testing/core-testing-controller.ts ```typescript /** * 该方案仅适用于在嵌套组件的数量为1. * 由于在get方法中直接以instanceof方法获取了相关组件 * 所以如果某个组件在被测试组件中多次被引用时 * 只能获取第一个被push进来的组件 */ export class CoreTestingController { /** * 存储组件、指令或管道 */ private units = new Array<any>(); constructor() { } /** * 添加单元(组件、指令或管道) * @param unit 单元 */ addUnit(unit: any): void { this.units.push(unit); } /** * 获取单元(组件、指令或管道) * @param clazz 类型 */ get(clazz: Clazz): any { let result: any = null; this.units.forEach((value) => { if (value.constructor.name === clazz.name) { result = value; } }); return result; } } /** * 定义一个Clazz类型,用于参数中接收 类、接口等 */ export type Clazz = new(...args: any[]) => any; ``` 组件替身声明与组件具有相同的输入与输出,同时将组件本身添加到测试控制器中。 core/core-testing/multiple-select/multiple-select.component.ts ```typescript import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {CoreTestingController} from '../core-testing-controller'; import {Observable} from 'rxjs'; @Component({ selector: 'app-multiple-select', templateUrl: './multiple-select.component.html', styleUrls: ['./multiple-select.component.sass'] }) export class MultipleSelectComponent implements OnInit { /** 数据列表 */ @Input() ➊ list$: Observable<Array<{ name: string }>>; /** 事件弹射器,用户点选后将最终的结点弹射出去 */ @Output() ➊ changed = new EventEmitter<Array<any>>(); constructor(private coreTestingController: CoreTestingController➋) { this.coreTestingController.addUnit(this); ➌ } ngOnInit() { } } ``` * ➊ 声明与被替组件具有相同的输入与输出 * ➋ 注入测试控制器 * ➌ 将组件本身加入到测试控制器中 # 小试牛刀 来到course模块的班级多选组件中进行嵌套组件测试如下: course/klass-multiple-select/klass-multiple-select.component.spec.ts ```typescript beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [KlassMultipleSelectComponent], imports: [ CoreTestingModule ➊ ] }) .compileComponents(); })); fit('嵌套组件MultipleSelectComponent测试', () => { const coreTestingController = TestBed.get(CoreTestingController); ➊ const multipleSelect = coreTestingController.get(MultipleSelectComponent)➌ as MultipleSelectComponent; ➍ // 断言input expect(multipleSelect.list$).toBe(component.klasses$); // 断言output spyOn(component, 'onChange'); const klasses = [new Klass(null, null, null)]; multipleSelect.changed.emit(klasses); expect(component.onChange).toHaveBeenCalledWith(klasses); }); ``` * ➊ 引入MultipleSelectComponent所在CoreModule对应的测试模块CoreTestingModule * ➋ 像angular官方一样优雅地获取测试控制器 * ➌ 像angular官方一样优雅地获取被嵌套组件 * ➍ 此处的MultipleSelectComponent无论是真实的组件还是与组件同名的替身均可正常工作 # 功能开发 班级多选组件中可供选择的班级来源于数据表klass,为此按MVC的开发理论,首先补充KlassService用于获取全部的班级列表。 ## service ```javascript panjiedeMac-Pro:service panjie$ ng g s klass CREATE src/app/service/klass.service.spec.ts (328 bytes) CREATE src/app/service/klass.service.ts (134 bytes) ``` 增加all方法来获取全部的班级数据。由于在前面的章节中并没有为klass建立单独的service,而是选择在klass模块的index组件中直接向后台发请的请求。所以此时需要去查看对应组件中获取全部班级的代码。最终确认获取全部方法的接口信息为:`GET http://localhost:8080/Klass?name=`,于是获取全部班级的代码如下: service/klass.service.ts ```typescript import {Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {Klass} from '../norm/entity/Klass'; import {HttpClient, HttpParams} from '@angular/common/http'; @Injectable({ providedIn: 'root' }) export class KlassService { private url = 'http://localhost:8080/Klass'; constructor(private httpClient: HttpClient) { } /** * 获取所有班级 */ all(): Observable<Klass[]> { const httpParams = new HttpParams().append('name', ''); return this.httpClient.get<Klass[]>(this.url, {params: httpParams}); } } ``` ### 单元测试 service/klass.service.spec.ts ```typescript import {TestBed} from '@angular/core/testing'; import {KlassService} from './klass.service'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {Klass} from '../norm/entity/Klass'; describe('KlassService', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ] })); it('should be created', () => { const service: KlassService = TestBed.get(KlassService); expect(service).toBeTruthy(); }); fit('all', () => { // 数据准备,调用被测方法 const service: KlassService = TestBed.get(KlassService); let result; service.all().subscribe((data) => { result = data; }); // 断言发起请求符合预期 const testingController: HttpTestingController = TestBed.get(HttpTestingController); const request = testingController.expectOne((req) => req.url === 'http://localhost:8080/Klass'); expect(request.request.headers.has('name')); expect(request.request.method).toEqual('GET'); // 断言成功的接收到返回值 const klasses = [new Klass(null, null, null)]; request.flush(klasses); expect(result).toBe(klasses); }); }); ``` ## C层 course/klass-multiple-select/klass-multiple-select.component.ts ```typescript import {Component, EventEmitter, OnInit, Output} from '@angular/core'; import {Observable} from 'rxjs'; import {Klass} from '../../norm/entity/Klass'; import {KlassService} from '../../service/klass.service'; @Component({ selector: 'app-klass-multiple-select', templateUrl: './klass-multiple-select.component.html', styleUrls: ['./klass-multiple-select.component.sass'] }) export class KlassMultipleSelectComponent implements OnInit { klasses$: Observable<Klass[]>; @Output() changed = new EventEmitter<Klass[]>(); constructor(private klassService: KlassService) { } ngOnInit() { this.klasses$ = this.klassService.all(); } onChange($event: Array<Klass>) { this.changed.emit($event); } } ``` ### 单元测试 该组件依赖于KlassService,为此在进行单元测试前先建立KlassService的测试替身KlassStubService ``` panjiedeMac-Pro:service panjie$ ng g s KlassStub --skip-tests CREATE src/app/service/klass-stub.service.ts (138 bytes) ``` 在替身中同样创建all方法。 service/klass-stub.service.ts ```typescript import {Observable} from 'rxjs'; import {Klass} from '../norm/entity/Klass'; export class KlassStubService { constructor() { } all(): Observable<Klass[]> { return null; } } ``` 补充班级选择组件ngOnInit方法及changed方法的测试: course/klass-multiple-select/klass-multiple-select.component.spec.ts ```typescript providers: [ {provide: KlassService, useClass: KlassStubService} ] fit('onChange', () => { let result; component.changed.subscribe((data) => { result = data; }); const klasses = [new Klass(null, null, null)]; component.onChange(klasses); expect(result).toBe(klasses); }); fit('ngOnInit', () => { const klassService: KlassService = TestBed.get(KlassService); const klasses$ = of([new Klass(null, null, null)]); spyOn(klassService, 'all').and.returnValue(klasses$); component.ngOnInit(); expect(component.klasses$).toBe(klasses$); }); ``` 单元测试通过,本节完成: ![](https://img.kancloud.cn/fe/29/fe296d7a079ea45df6500f79435df3ea_320x136.png) # 本节小测 请参考本节中的测试示例,请尝试在course文件夹中建立CourseModule对应的测试CourseTestingModule以及相关文件。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.4](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step6.1.4) | - |