數據編輯前需要獲取路由的中參數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小節中對依賴注入的描述:

在我們當前的項目,就像這樣:

如果有了providers那么將是如下情景:

由上圖我們看到:在班級Module中,我們可以使用`providers`來為其指定一個`大客車`,此時當班級列表組件表示自己需要一個大客車時,便優先使用`providers`中的`大客車`了。
在進行路由模擬的時候,其實也是用的這個原理:

假設我們使用新的大客車來替換租車公司的大客車,那么具體使用語法為:
```
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`測試得到如下錯誤:

它說在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) | - |
- 序言
- 第一章:Hello World
- 第一節:Angular準備工作
- 1 Node.js
- 2 npm
- 3 WebStorm
- 第二節:Hello Angular
- 第三節:Spring Boot準備工作
- 1 JDK
- 2 MAVEN
- 3 IDEA
- 第四節:Hello Spring Boot
- 1 Spring Initializr
- 2 Hello Spring Boot!
- 3 maven國內源配置
- 4 package與import
- 第五節:Hello Spring Boot + Angular
- 1 依賴注入【前】
- 2 HttpClient獲取數據【前】
- 3 數據綁定【前】
- 4 回調函數【選學】
- 第二章 教師管理
- 第一節 數據庫初始化
- 第二節 CRUD之R查數據
- 1 原型初始化【前】
- 2 連接數據庫【后】
- 3 使用JDBC讀取數據【后】
- 4 前后臺對接
- 5 ng-if【前】
- 6 日期管道【前】
- 第三節 CRUD之C增數據
- 1 新建組件并映射路由【前】
- 2 模板驅動表單【前】
- 3 httpClient post請求【前】
- 4 保存數據【后】
- 5 組件間調用【前】
- 第四節 CRUD之U改數據
- 1 路由參數【前】
- 2 請求映射【后】
- 3 前后臺對接【前】
- 4 更新數據【前】
- 5 更新某個教師【后】
- 6 路由器鏈接【前】
- 7 觀察者模式【前】
- 第五節 CRUD之D刪數據
- 1 綁定到用戶輸入事件【前】
- 2 刪除某個教師【后】
- 第六節 代碼重構
- 1 文件夾化【前】
- 2 優化交互體驗【前】
- 3 相對與絕對地址【前】
- 第三章 班級管理
- 第一節 JPA初始化數據表
- 第二節 班級列表
- 1 新建模塊【前】
- 2 初識單元測試【前】
- 3 初始化原型【前】
- 4 面向對象【前】
- 5 測試HTTP請求【前】
- 6 測試INPUT【前】
- 7 測試BUTTON【前】
- 8 @RequestParam【后】
- 9 Repository【后】
- 10 前后臺對接【前】
- 第三節 新增班級
- 1 初始化【前】
- 2 響應式表單【前】
- 3 測試POST請求【前】
- 4 JPA插入數據【后】
- 5 單元測試【后】
- 6 惰性加載【前】
- 7 對接【前】
- 第四節 編輯班級
- 1 FormGroup【前】
- 2 x、[x]、{{x}}與(x)【前】
- 3 模擬路由服務【前】
- 4 測試間諜spy【前】
- 5 使用JPA更新數據【后】
- 6 分層開發【后】
- 7 前后臺對接
- 8 深入imports【前】
- 9 深入exports【前】
- 第五節 選擇教師組件
- 1 初始化【前】
- 2 動態數據綁定【前】
- 3 初識泛型
- 4 @Output()【前】
- 5 @Input()【前】
- 6 再識單元測試【前】
- 7 其它問題
- 第六節 刪除班級
- 1 TDD【前】
- 2 TDD【后】
- 3 前后臺對接
- 第四章 學生管理
- 第一節 引入Bootstrap【前】
- 第二節 NAV導航組件【前】
- 1 初始化
- 2 Bootstrap格式化
- 3 RouterLinkActive
- 第三節 footer組件【前】
- 第四節 歡迎界面【前】
- 第五節 新增學生
- 1 初始化【前】
- 2 選擇班級組件【前】
- 3 復用選擇組件【前】
- 4 完善功能【前】
- 5 MVC【前】
- 6 非NULL校驗【后】
- 7 唯一性校驗【后】
- 8 @PrePersist【后】
- 9 CM層開發【后】
- 10 集成測試
- 第六節 學生列表
- 1 分頁【后】
- 2 HashMap與LinkedHashMap
- 3 初識綜合查詢【后】
- 4 綜合查詢進階【后】
- 5 小試綜合查詢【后】
- 6 初始化【前】
- 7 M層【前】
- 8 單元測試與分頁【前】
- 9 單選與多選【前】
- 10 集成測試
- 第七節 編輯學生
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 功能開發【前】
- 4 JsonPath【后】
- 5 spyOn【后】
- 6 集成測試
- 7 @Input 異步傳值【前】
- 8 值傳遞與引入傳遞
- 9 @PreUpdate【后】
- 10 表單驗證【前】
- 第八節 刪除學生
- 1 CSS選擇器【前】
- 2 confirm【前】
- 3 功能開發與測試【后】
- 4 集成測試
- 5 定制提示框【前】
- 6 引入圖標庫【前】
- 第九節 集成測試
- 第五章 登錄與注銷
- 第一節:普通登錄
- 1 原型【前】
- 2 功能設計【前】
- 3 功能設計【后】
- 4 應用登錄組件【前】
- 5 注銷【前】
- 6 保留登錄狀態【前】
- 第二節:你是誰
- 1 過濾器【后】
- 2 令牌機制【后】
- 3 裝飾器模式【后】
- 4 攔截器【前】
- 5 RxJS操作符【前】
- 6 用戶登錄與注銷【后】
- 7 個人中心【前】
- 8 攔截器【后】
- 9 集成測試
- 10 單例模式
- 第六章 課程管理
- 第一節 新增課程
- 1 初始化【前】
- 2 嵌套組件測試【前】
- 3 async管道【前】
- 4 優雅的測試【前】
- 5 功能開發【前】
- 6 實體監聽器【后】
- 7 @ManyToMany【后】
- 8 集成測試【前】
- 9 異步驗證器【前】
- 10 詳解CORS【前】
- 第二節 課程列表
- 第三節 果斷
- 1 初始化【前】
- 2 分頁組件【前】
- 2 分頁組件【前】
- 3 綜合查詢【前】
- 4 綜合查詢【后】
- 4 綜合查詢【后】
- 第節 班級列表
- 第節 教師列表
- 第節 編輯課程
- TODO返回機制【前】
- 4 彈出框組件【前】
- 5 多路由出口【前】
- 第節 刪除課程
- 第七章 權限管理
- 第一節 AOP
- 總結
- 開發規范
- 備用