至此,一个具有完整输入、输出的组件便已经被我们骄傲的开发完毕了。但请考虑以下问题: * ① 新增组件的输入功能后,是否对本组件的历史功能产生了影响 * ② 将新增组件应用于第三方组件中,是否对第三方组件的功能产生了影响 如果我们对其产生了影响那么你认为当前都产生了什么影响 ,产生的原因又是什么,同时又是计划如何修正的。 # 会说话的代码 如果你仅凭想像便给出了自己的答案,那么无论你的答案是什么,都将是苍白而无力的。在软件开发的领域里没有实践就没有发言权,在没有真实的实践以前,任何主观的错误预计都是耍流氓。而单元测试就不会说谎。在此,我们不防借助单元测试来回答一下前面的问题。 #### 测试本组件 找到klass/teacher-select/teacher-select.component.spec.ts并把`describe`修改为`fdescribe`,然后运行`ng test`来观察会发生什么。 ![](https://img.kancloud.cn/64/e8/64e8dce5526bc82fe69aa24f1e95bf27_826x192.png) ``` LOG: 'data emit', Teacher{id: 1, name: '潘杰', username: 'panjie', email: undefined, sex: undefined} Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 13 SUCCESS (0 secs / 0 secs) LOG: '潘杰' Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 13 SUCCESS (0 secs / 0.095 secs) LOG: '张喜硕' Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 13 SUCCESS (0 secs / 0.095 secs) Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 2 of 13 (skipped 11) SUCCESS (0.15 secs / 0.121 secs) TOTAL: 2 SUCCESS TOTAL: 2 SUCCESS ``` 这说明当前组件新功能的加入未对`组件弹出器`及`获取教师列表后选择教师`功能造成影响。随后我们打开[http://localhost:4200/klass/add](http://localhost:4200/klass/add)测试相关功能运行正常。 **测试完成后,将`fdescribe`恢复为`describe`** #### 测试班级添加组件 我们再打开klass/add/add.component.spec.ts,并把`describe`修改为`fdescribe`,然后运行`ng test`来观察会发生什么: ![](https://img.kancloud.cn/c0/12/c012d2ce849df89b146ba8cb3a0ceaa9_482x252.png) ``` Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 3 of 13 (3 FAILED) (skipped 10) ERROR (0.228 secs / 0.201 secs) ``` 这说明:当前组件已被变更,而变更后无法满足历史的单元测试要求或未动该组件的变更进行测试。事实也的确如此,我们需要在班级新增、班级编辑组件中引入了选择教师组件,但却没有对引入教师组件后原组件的功能是否正常进行测试。 ## 修正错误 当一个组件A依赖于其它组件B时,在进行测试的过程中也需要对B组件进行声明。打开klass/add/add.component.spec.ts ``` beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent, TeacherSelectComponent✚], imports: [ FormsModule, ReactiveFormsModule, HttpClientTestingModule ] }) .compileComponents(); })); ``` #### 测试 ![](https://img.kancloud.cn/39/df/39dfe2b9fbdf74e3caac39ed4e194efb_673x94.png) 提示找有找到`Router`的`provider`,则加入`RouterTestingModule` ``` beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent, TeacherSelectComponent], imports: [ FormsModule, ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule ✚ ] }) .compileComponents(); })); ``` 再测试 ![](https://img.kancloud.cn/ae/32/ae32b162213af4ad3a1143a2bce9a7b7_911x131.png) 此时基础的依赖错误提示已经完全消除,而上图得到的便是一个真真切切的错误了。此错误表示:对该组件进行变更(引入了选择教师组件)后,对原组件的正常功能产生了影响。 提示信息再说:我们预测应该得到2,但实际上却得到了null。我们此时可以访问[http://localhost:4200/klass/add](http://localhost:4200/klass/add)来验证单元测试抛出的错误信息是否是真真切切有帮助的。 ![](https://img.kancloud.cn/97/f9/97f91c533b60a3d0f40805c20102c777_595x214.png) 通过点击测试我们发现当点击`保存`按钮时并没有进行数据提交,而是在控制台中报了以上错误。 ### 错误 根据单元测试的提示,我们来到测试文件79行所在的测试方法: ``` it('测试V层向C层绑定', () => { expect(component).toBeTruthy(); fixture.whenStable().then(() => { const debugElement: DebugElement = fixture.debugElement; const nameElement = debugElement.query(By.css('#name')); const nameInput: HTMLInputElement = nameElement.nativeElement; nameInput.value = 'test2'; nameInput.dispatchEvent(new Event('input')); expect(component.name.value).toBe('test2'); const teacherIdElement = debugElement.query(By.css('#teacherId')); const teacherIdInput: HTMLInputElement = teacherIdElement.nativeElement; teacherIdInput.value = '2'; teacherIdInput.dispatchEvent(new Event('input')); expect(component.teacherId.value).toBe(2); }); }); ``` 该方法是通过设置input的值来达到改变teacherId的目的。但引入选择教师组件后,已经没有teacherId这个input了,所以后续的测试代码当然也就随着发生了问题了。 ### 修正组件功能 此时我们来到klass/add/add.component.ts中,发现原来我们在前面引入选择教师组件后并没有增加相应的功能。这也就是难怪该单元测试会报错了。 下面我们对功能进行修正,并重新修正单元测试。 ``` teacherId: FormControl; ✘ teacher: Teacher; ✚ ... ngOnInit() { this.name = new FormControl(''); this.teacherId = new FormControl(); ✘ } ... const klass = new Klass(undefined, this.name.value, new Teacher(parseInt(this.teacherId.value, 10), undefined, undefined) ✘ this.teacher ✚ ); ... /** * 当选择某个教师时触发 * @param {Teacher} teacher 教师 */ onTeacherSelected(teacher: Teacher) { console.log(teacher); ✘ this.teacher = teacher; ✚ } ``` ### 修正单元测试 ① 删除设置teacherId这个input的测试代码 ② 测试当选择教师组件数据变更后,点击保存按钮触发了正确的HTTP请求 > 该部分代码重构有一定的难度,第一次阅读时可忽略。 部分代码如下: ``` /** * 设置表单数据 * 点击按钮发起请求 * 断言:请求地址、请求方法、发送的数据 */ it('保存按钮点击后,提交相应的http请求', () => { httpTestingController = TestBed.get(HttpTestingController); expect(component).toBeTruthy(); component.name.setValue('test3'); component.teacher = new Teacher(2, null, null, null); ✚ fixture.whenStable().then(() => { const debugElement: DebugElement = fixture.debugElement; const submitButtonElement = debugElement.query(By.css('button')); const submitButton: HTMLButtonElement = submitButtonElement.nativeElement; submitButton.click(); const req = httpTestingController.expectOne('http://localhost:8080/Klass'); expect(req.request.method).toEqual('POST'); const klass: Klass = req.request.body.valueOf(); expect(klass.name).toEqual('test3'); expect(klass.teacher.id).toEqual(2); ✚ req.flush(null, {status: 201, statusText: 'Accepted'}); }); ``` 整体代码如下: ``` import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {AddComponent} from './add.component'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {DebugElement} from '@angular/core'; import {By} from '@angular/platform-browser'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {Klass} from '../../norm/entity/Klass'; import {TeacherSelectComponent} from '../teacher-select/teacher-select.component'; import {RouterTestingModule} from '@angular/router/testing'; import {Teacher} from '../../norm/entity/Teacher'; fdescribe('Klass/AddComponent', () => { let component: AddComponent; let fixture: ComponentFixture<AddComponent>; let httpTestingController: HttpTestingController; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [AddComponent, TeacherSelectComponent], imports: [ FormsModule, ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(AddComponent); component = fixture.componentInstance; fixture.detectChanges(); }); /** * 测试C层向V层数据绑定 * 在C层中使用setValue方法对表单项赋值 * 重新渲染V层后,使用CSS选择器来获取元素 * 获取元素的值并断言 */ it('测试C层向V层数据绑定', () => { expect(component).toBeTruthy(); component.name.setValue('test'); fixture.detectChanges(); fixture.whenStable().then(() => { const debugElement: DebugElement = fixture.debugElement; const nameElement = debugElement.query(By.css('#name')); const nameInput: HTMLInputElement = nameElement.nativeElement; expect(nameInput.value).toBe('test'); }); }); /** * 测试V层向C层绑定 * 获取V层的元素,并设置元素的值 * 断言在C层中获取到了元素的值 */ it('测试V层向C层绑定', () => { expect(component).toBeTruthy(); const debugElement: DebugElement = fixture.debugElement; const nameElement = debugElement.query(By.css('#name')); const nameInput: HTMLInputElement = nameElement.nativeElement; nameInput.value = 'test2'; nameInput.dispatchEvent(new Event('input')); expect(component.name.value).toBe('test2'); }); /** * 设置表单数据 * 点击按钮发起请求 * 断言:请求地址、请求方法、发送的数据 */ it('保存按钮点击后,提交相应的http请求', () => { httpTestingController = TestBed.get(HttpTestingController); expect(component).toBeTruthy(); component.name.setValue('test3'); component.teacher = new Teacher(2, null, null, null); fixture.whenStable().then(() => { const debugElement: DebugElement = fixture.debugElement; const submitButtonElement = debugElement.query(By.css('button')); const submitButton: HTMLButtonElement = submitButtonElement.nativeElement; submitButton.click(); const req = httpTestingController.expectOne('http://localhost:8080/Klass'); expect(req.request.method).toEqual('POST'); const klass: Klass = req.request.body.valueOf(); expect(klass.name).toEqual('test3'); expect(klass.teacher.id).toEqual(2); req.flush(null, {status: 201, statusText: 'Accepted'}); }); }); }); ``` ## 测试编辑组件并进行修正 参考新增班级的代码,我们修正编辑班级代码如下: klass/edit/edit.component.html ``` <h3>编辑班级</h3> <form (ngSubmit)="onSubmit()" [formGroup]="formGroup"> <label for="name">名称:<input id="name" type="text" formControlName="name"/></label> <label for="teacherId">教师:<app-teacher-select *ngIf="teacher" id="teacherId" [teacher]="teacher" (selected)="onSelected($event)"①></app-teacher-select></label> <button>更新</button> </form> ``` klass/edit/edit.component.ts ``` import {Component, OnInit} from '@angular/core'; import {FormControl, FormGroup} from '@angular/forms'; import {ActivatedRoute, Router} from '@angular/router'; import {HttpClient} from '@angular/common/http'; import {Klass} from '../../norm/entity/Klass'; import {Teacher} from '../../norm/entity/Teacher'; @Component({ selector: 'app-edit', templateUrl: './edit.component.html', styleUrls: ['./edit.component.sass'] }) export class EditComponent implements OnInit { formGroup: FormGroup; teacher: Teacher; private url: string; constructor(private route: ActivatedRoute, private router: Router, private httpClient: HttpClient) { } private getUrl(): string { return this.url; } /** * 加载要编辑的班级数据 */ loadData(): void { this.httpClient.get(this.getUrl()) .subscribe((klass: Klass) => { this.formGroup.setValue({name: klass.name}); this.teacher = klass.teacher; }, () => { console.error(`${this.getUrl()}请求发生错误`); }); } ngOnInit() { this.formGroup = new FormGroup({ name: new FormControl(), }); this.route.params.subscribe((param: { id: number }) => { this.setUrlById(param.id); this.loadData(); }); } /** * 用户提交时执行的操作 */ onSubmit(): void { const data = { name: this.formGroup.value.name, teacher: this.teacher }; this.httpClient.put(this.getUrl(), data) .subscribe(() => { this.router.navigateByUrl('/klass'); }, () => { console.error(`在${this.getUrl()}上的PUT请求发生错误`); }); } /** * 选中某个教师时 * @param teacher 教师 */ onSelected(teacher: Teacher): void { this.teacher = teacher; } private setUrlById(id: number): void { this.url = `http://localhost:8080/Klass/${id}`; } } ``` #### 单元测试 修正单元测试如下(后面好要讲相关知识,第一遍可略过): klass/edit/edit.component.spec.ts ``` import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {EditComponent} from './edit.component'; import {ReactiveFormsModule} from '@angular/forms'; import {RouterTestingModule} from '@angular/router/testing'; import {ActivatedRoute, Router} from '@angular/router'; import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; import {ActivatedRouteStub} from './activated-route-stub'; import {Klass} from '../../norm/entity/Klass'; import {Teacher} from '../../norm/entity/Teacher'; import {FormTest} from '../../testing/FormTest'; import SpyObj = jasmine.SpyObj; import {Test} from 'tslint'; import {TeacherSelectComponent} from '../teacher-select/teacher-select.component'; describe('klass EditComponent', () => { let component: EditComponent; let fixture: ComponentFixture<EditComponent>; beforeEach(async(() => { const routerSpy = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']); TestBed.configureTestingModule({ declarations: [EditComponent, TeacherSelectComponent], imports: [ ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule ], providers: [ {provide: ActivatedRoute, useClass: ActivatedRouteStub}, {provide: Router, useValue: routerSpy} ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(EditComponent); component = fixture.componentInstance; fixture.detectChanges(); }); /** * 组件初始化 * 发送路由参数 * 断言发起了HTTP请求 * 断言请求的方法为PUT */ it('should create', () => { expect(component).toBeTruthy(); let route: ActivatedRouteStub; route = TestBed.get(ActivatedRoute); route.subject.next({id: 1}); testGetHttp(1); }); /** * 测试组件发起的GET请求 * 断言请求地址及方法 * 返回数据后,断言input项成功绑定返回数据 * @param id 请求的班级ID */ const testGetHttp = (id: number) => { const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne(`http://localhost:8080/Klass/${id}`); expect(req.request.method).toEqual('GET'); req.flush(new Klass(id, '测试编辑班级', new Teacher(1, null, null))); fixture.whenStable().then(() => { expect(FormTest.getInputValueByFixtureAndCss(fixture, '#name')).toEqual('测试编辑班级'); onSubmitTest(1); }); }; /** * 数据更新测试,步骤: * 1. 设置路由参数 * 2. 输入input的值 * 3. 点击提交扭钮:断言向预期的地址以对应的方法提交了表单中的数据 * 4. 断言跳转到''路由地址 */ const onSubmitTest = (id: number) => { FormTest.setInputValue(fixture, '#name', '测试更新班级'); component.teacher = new Teacher(100, null, null, null); fixture.whenStable().then(() => { FormTest.clickButton(fixture, 'button'); const httpTestController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestController.expectOne(`http://localhost:8080/Klass/${id}`); expect(req.request.method).toEqual('PUT'); const klass: Klass = req.request.body.valueOf(); expect(klass.name).toEqual('测试更新班级'); expect(klass.teacher.id).toEqual(100); const routerSpy: SpyObj<Router> = TestBed.get(Router); expect(routerSpy.navigateByUrl.calls.any()).toBe(false); req.flush(null, {status: 204, statusText: 'No Content'}); expect(routerSpy.navigateByUrl.calls.any()).toBe(true); httpTestController.verify(); }); }; }); ``` # 总结 单元测试是会说话的代码,它能够自动判断在新增或修改一些功能后本组件是否按原预期正常运行。如果偏离了原预期,将会自动发出警告。单元测试是保障软件质量的重要手段,是软件开发中非常重要的一环。 # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.5.6](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.5.6) | - | | 带有输入输出参数组件的测试 | [https://www.angular.cn/guide/testing#component-with-inputs-and-outputs](https://www.angular.cn/guide/testing#component-with-inputs-and-outputs) | 15 | | 位于测试宿主中的组件| [https://www.angular.cn/guide/testing#component-inside-a-test-host](https://www.angular.cn/guide/testing#component-inside-a-test-host) | 10 |