数据编辑前需要获取路由的中参数id。但由于在非测试环境中:只有将组件应用到具体的模块中并定义相应的路由规则时,才可能使用url来触发这个组件并获取相关的路由值。而测试环境的URL是类似于:[http://localhost:9876/?id=99225629](http://localhost:9876/?id=99225629)这样的一串值,所以想按传统的使用URL来触发此操作就力不从心了。此时,我们就需要借助模块的配置项`providers`来提供一个模拟路由的`服务`来协助进行模拟测试了。 # providers 我们再次来到项目的根模块:AppMoudle app.module.ts ``` import {BrowserModule} from '@angular/platform-browser'; import {NgModule} from '@angular/core'; import {AppRoutingModule} from './app-routing.module'; import {AppComponent} from './app.component'; import {HttpClientModule} from '@angular/common/http'; import {TeacherAddComponent} from './teacher/teacher-add.component'; import {FormsModule} from '@angular/forms'; import {TeacherEditComponent} from './teacher/teacher-edit.component'; import {TeacherIndexComponent} from './teacher/teacher-index.component'; import {KlassModule} from './klass/klass.module'; @NgModule({ declarations: [ AppComponent, TeacherAddComponent, TeacherEditComponent, TeacherIndexComponent ], imports: [ BrowserModule, AppRoutingModule, HttpClientModule, FormsModule, KlassModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { } ``` 在前面的章节中,我们已经学习了`declarations`、`imports`以及`bootstrap`的作用,下面我们共同学习下`providers` 的作用。在正式开始学习之前,我们回想一下1.5.1小节中对依赖注入的描述: ![](https://img.kancloud.cn/19/9b/199b63ebdaee93cc83239062e3ca7ecd_814x637.png) 在我们当前的项目,就像这样: ![](https://img.kancloud.cn/7e/a7/7ea74fe0697cb3dda4f2d65af4f059b7_626x537.png) 如果有了providers那么将是如下情景: ![](https://img.kancloud.cn/a8/ea/a8eafe1de18902a073585b94db73290f_599x533.png) 由上图我们看到:在班级Module中,我们可以使用`providers`来为其指定一个`大客车`,此时当班级列表组件表示自己需要一个大客车时,便优先使用`providers`中的`大客车`了。 在进行路由模拟的时候,其实也是用的这个原理: ![](https://img.kancloud.cn/2b/08/2b084ca5fd3e4ac0cf8ec01c48b7384a_824x384.png) 假设我们使用新的大客车来替换租车公司的大客车,那么具体使用语法为: ``` providers: [ {provide: 大客车, useClass: 大客车} ] ``` 这样使用会有一个小问题,由于两个名字都是大客车,所以angular不知道哪个大客车是哪个大客车。所以在进行这种替换时,我们一般这样命名(这并不是唯一的解决方法,但却是最佳实践): ``` providers: [ {provide: 大客车, useClass: 大客车Stub} ] ``` 此代码表示当需要`大客车`时,把`大客车Stub`拿过去使用就好了。 ## Coding 按上述的思路,我们新建一个`ActivatedRouteStub`,并在测试模块中使用providers来设置其替换`ActivatedRoute`。 klass/edit/edit.component.ts ``` import {async, ComponentFixture, TestBed} 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} from '@angular/common/http/testing'; import {By} from '@angular/platform-browser'; import {DebugElement} from '@angular/core'; describe('klass EditComponent', () => { let component: EditComponent; let fixture: ComponentFixture<EditComponent>; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [EditComponent], imports: [ ReactiveFormsModule, HttpClientTestingModule, RouterTestingModule ], providers: [ ➋ {provide: ActivatedRoute➌, useClass: ActivatedRouteStub➍} ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(EditComponent); component = fixture.componentInstance; fixture.detectChanges(); }); fit('should create', () => { expect(component).toBeTruthy(); }); }); class ActivatedRouteStub { ➊ } ``` * ➊ 自定义一个ActivatedRouteStub(推荐如此命名,但不必须) * ➋ 使用providers自定义供应商 * 当组件需要➌时,new一个➍出来提供给该组件 ### 测试 使用`ng test`测试得到如下错误: ![](https://img.kancloud.cn/1f/c9/1fc92e5195e005e2852b115111907ab4_898x134.png) 它说在klass/edit/edit.component.ts中的42行发生了:不能够在undefined上面读取`subscribe`属性。 ``` this.route.params.subscribe((param: { id: number }) => { ``` 也就是说this.route.params为undefined。 产生此错语的原因是这样:我们在测试模块中使用了自定义的ActivatedRouteStub来替换原ActivatedRoute。也就是说第42行代码中的this.route对象的为我们的ActivatedRouteStub,但我们当前的ActivatedRouteStub中没有任何属性,所以在获取params当然为undefined。我们的思想在回到依赖注入上,既然我们指定的特定的对象来做为本模块的协作者,那么此协作者原则上就必须提供与原对象一模一样的功能才不会发生调用错误。否则某组件声明需要一辆汽车,但我们提供的汽车没有行驶的功能,那么组件在使用该汽车时当然就会出错了。不过虽然组件声明需要一辆汽车,但其未并使用该汽车的所有功能,比如汽车除了有行驶功能以外,还有救援的功能。那么我们在构建一个专门用来测试的汽车时,只需要把行驶功能模拟出来就可以满足该组件的功能需求了,而救援功能即使不模拟,也不会影响组件的正常运行。 ## 剥离ActivatedRouteStub 为了更加清晰的来描述该在测试中模拟路由的类,我们删除klass/edit/edit.component.spec.ts中的ActivatedRouteStub并在同级目录新建activated-route-stub.ts klass/edit/activated-route-stub.ts ``` import {ReplaySubject} from 'rxjs'; import {ParamMap} from '@angular/router'; export class ActivatedRouteStub { } ``` 然后在klass/edit/edit.component.spec.ts中来引用它: ``` import {ActivatedRouteStub} from './activated-route-stub'; ``` ## 模拟属性及功能 以模拟ActivatedRoute为例,我们在ActivatedRouteStub中模拟其属性与功能有两种方法:第一种是观察组件调用的代码,观察其调用了ActivatedRoute的什么属性或是功能,然后在ActivatedRouteStub中添加对应的属性或功能;第二种是借助于单元测试,看单元测试报什么错误,进而在ActivatedRouteStub添加对应的属性和功能。在此,我们使用第二种借助于单元测试的方法: 单元测试报错误:TypeError: Cannot read property 'subscribe' of undefined。则我们如下修正: klass/edit/activated-route-stub.ts ``` import {Subject} from 'rxjs'; import {Params} from '@angular/router'; export class ActivatedRouteStub { subject = new Subject<Params➋>(); ➊ readonly params = this.subject.asObservable();➌ } ``` * ➊ 声明一个Subject,该Subject可以订阅(观察)别人,也可以做为可被观察者被别人订阅(观察)。 * ➋ 该Subject发送的数据类型为`Params 键值对` * ➌ 设置属性`params`的值:经subject转换得到的可观察者 **小窍门:**先创建一个Subject,然后使用`asObservable()`转换为需要的可观察者 ## 模拟发送数据 klass/edit/edit.component.spec.ts ``` fit('should create', () => { expect(component).toBeTruthy(); let route: ActivatedRouteStub; ➊ route = TestBed.get(ActivatedRoute); ➋ route.subject.next({id: 1});➌ }); ``` * ➊ 声明变量类型 * ➋ 由测试机床中获取具有ActivatedRoute功能的服务对象,由于我们在providers中重写了ActivatedRoute,所以最终获取到的将是我们重写对的基于ActivatedRouteStub创建的服务对象。 * ➌ 调用ActivatedRouteStub中的subject的next方法,向组件发送数据 我们在组件的对应位置上打印下获的值 ,看是否发送成功了 klass/edit/edit.component.spec.ts ``` ngOnInit() { this.formGroup = new FormGroup({ name: new FormControl(), teacherId: new FormControl() }); this.route.params.subscribe((param: { id: number }) => { console.log(param); ✚ this.setUrlById(param.id); this.loadData(); }); } ``` 控制台结果: ``` LOG: Object{id: 1} ① Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 11 SUCCESS (0 secs / 0 secs) Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 11 (skipped 10) SUCCESS (0.122 secs / 0.103 secs) TOTAL: 1 SUCCESS TOTAL: 1 SUCCESS ``` ## 写断言 当路由发生变更时,我们最终期待该组件发起http请求,并在请求完成后,在V层中显示对应的数据,由此我们的测试代码如下: klass/edit/edit.component.spec.ts ``` /** * 组件初始化 * 发送路由参数 * 断言发起了HTTP请求 * 断言请求的方法为PUT */ fit('should create', () => { expect(component).toBeTruthy(); let route: ActivatedRouteStub; route = TestBed.get(ActivatedRoute); route.subject.next({id: 1}); /*断言http请求*/ ➊ const httpTestingController: HttpTestingController = TestBed.get(HttpTestingController); const req = httpTestingController.expectOne('http://localhost:8080/Klass/1'); expect(req.request.method).toEqual('GET'); req.flush(new Klass(1, '测试编辑班级', new Teacher(1, null, null))); fixture.whenStable().then(() => { // todo: 获取input的值,被与预期值做比较 }); }); ``` * ➊ 一个优秀的项目,离不开良好的注释;一个优秀的项目,离不开良好的注释规范。该注释是一个良好的注释,但却违背了良好的注释习惯。 因为在良好的注释习惯中有一个原则是:尽可能的规避在方法中添加注释。所以当前我们面临了一个两难的问题。如果我们不添加此行注释,我们则是在书写不负责的代码;如果我们添加注释,就违背了注释的原则。当遇到此问题时,我们使用剥离新方法来解决。 ### 剥离新方法 klass/edit/edit.component.spec.ts ``` /** * 组件初始化 * 发送路由参数 * 断言发起了HTTP请求 * 断言请求的方法为PUT */ fit('should create', () => { expect(component).toBeTruthy(); let route: ActivatedRouteStub; route = TestBed.get(ActivatedRoute); route.subject.next({id: 1}); testGetHttp(1); }); /** * 测试组件发起的GET请求 * @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(() => { // todo: 获取input的值,被与预期值做比较 }); }; ``` 此时,我们就可以名正言顺的为新方法写注释了。这样便即达到了有良好的注释,又符合我们的良好的注释习惯。 ### 不造重复的轮子 在刚刚的代码中我们增加了`todo`,这是因为我们实在不想再写一些重复的获取input的值的语句了,我们的的确确已经写过够多的这样的语句了。何苦不在这此稍微的费点力气,把一些我们常的方法给剥离出来以达到不造重复的轮子且有效的提升生产力的目的呢?为此,我们在app目录中新建testing文件夹,并在此文件夹中书写一些在测试中可能被多个测试类使用的方法,比如我们新建一个FormTest类,并在该类中增加一个获取input值的方法: testing/FormTest.ts ``` import {DebugElement} from '@angular/core'; import {ComponentFixture} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {isNull} from 'util'; /** * 表单测试 */ export class FormTest { /** * 获取input输入框的值 * 首先获取整个V层元素 * 然后根据CSS选择器,获取指定的元素 * 最后将获取的元素转换为HTMLInput元素并返回该元素的值 * @param fixture 组件夹具 * @param cssSelector CSS选择器 */ static① getInputValueByFixtureAndCss(fixture: ComponentFixture<any>➊, cssSelector: string): string { const debugElement: DebugElement = fixture.debugElement; const nameElement = debugElement.query(By.css(cssSelector)); if (isNull(nameElement)) { return null; } const nameInput: HTMLInputElement = nameElement.nativeElement; return nameInput.value; } } ``` * ① 声明为静态方法以表现属于`类`而非`对象`。 * ➊ 暂时不要管这个变量类型是怎么来的。 当前目录树结构大体如下: ``` panjiedeMac-Pro:app panjie$ tree -L 2 . ├── app-routing.module.ts ├── app.component.html ├── app.component.sass ├── app.component.spec.ts ├── app.component.ts ├── app.module.ts ├── klass │   ├── add │   ├── edit │   ├── index │   └── klass.module.ts ├── norm │   └── entity ├── teacher │   ├── teacher-add.component.html │   ├── teacher-add.component.ts │   ├── teacher-edit.component.html │   ├── teacher-edit.component.ts │   ├── teacher-index.component.html │   └── teacher-index.component.ts └── testing └── FormTest.ts ``` 接下来我们回来班级班级组件测试中引用刚刚写的方法来获取input的值。 ``` import {FormTest} from '../../testing/FormTest'; /** * 测试组件发起的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('测试编辑班级'); expect(FormTest.getInputValueByFixtureAndCss(fixture, '#teacherId')).toEqual('1'); }); }; ``` 测试结果: ``` LOG: Object{id: 1} Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 0 of 11 SUCCESS (0 secs / 0 secs) Chrome 78.0.3904 (Mac OS X 10.13.6): Executed 1 of 11 (skipped 10) SUCCESS (0.071 secs / 0.051 secs) TOTAL: 1 SUCCESS TOTAL: 1 SUCCESS ``` # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.4.3](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step3.4.3) | - | | 依赖提供商 | [https://www.angular.cn/guide/dependency-injection-providers](https://www.angular.cn/guide/dependency-injection-providers) | 15 | | ActivatedRouteStub | [https://www.angular.cn/guide/testing#activatedroutestub](https://www.angular.cn/guide/testing#activatedroutestub) | 10 | | Subject = Observable + Observer | [https://wiki.jikexueyuan.com/project/rxjava//chapter2/subject\_observable\_observer.html](https://wiki.jikexueyuan.com/project/rxjava//chapter2/subject_observable_observer.html) | 10 | | 依赖注入 | 参阅教程1.5.1 | - | | 观察者模式 | 参阅教程2.4.7 | - | | Angular 中的观察者 | [https://www.angular.cn/guide/observables-in-angular#observables-in-angular](https://www.angular.cn/guide/observables-in-angular#observables-in-angular) | - | | 可观察对象 | [https://www.angular.cn/guide/observables](https://www.angular.cn/guide/observables) | - |