本節可能稍微有些難度,目的在于帶領大家更多的了解Angular。如果在學習第二遍后仍然心中沒有頭緒的話,建議學習完整個教程或對Angular有了更深入的認識或是以后在生產中碰到類似的需求時再閱讀。
當前項目我們執行`ng t`的時候,由于測試文件執行的過程是隨機的。在某些時候,將發生類似于如下錯誤:

或者在控制臺中報如下錯誤:

以及以下錯誤:

或以下錯誤:

或以下錯誤:

或者其它的類似錯誤。
但奇怪的是,無論我們單獨執行任意一個單元測試文件,都不會發生任何異常。這又是為何呢?
## 單例模式
在繼續學習之前,讓我們新建個測試文件復習一下前面講過的單例模式。在`src/app`目錄下新建`single-case.spec.ts`文件,加入一些前面我們在單例模式的學習中已經認知的代碼,其代碼初始化如下:
```typescript
import {TestBed} from '@angular/core/testing';
describe('單例模式相關測試', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({})
.compileComponents();
});
});
```
然后新建一個服務A:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -1,6 +1,18 @@
import {TestBed} from '@angular/core/testing';
+import {Injectable} from '@angular/core';
+import {randomNumber} from '@yunzhi/ng-mock-api';
describe('單例模式相關測試', () => {
+ @Injectable({providedIn: 'root'})
+ class A {
+ key: number;
+
+ constructor() {
+ console.log('a constructor be called');
+ this.key = randomNumber();
+ }
+ }
+
beforeEach(async () => {
await TestBed.configureTestingModule({})
.compileComponents();
```
再建立兩個依賴于A的服務BC:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -13,6 +13,18 @@ describe('單例模式相關測試', () => {
}
}
+ @Injectable({providedIn: 'root'})
+ class B {
+ constructor(private a: A) {
+ }
+ }
+
+ @Injectable({providedIn: 'root'})
+ class C {
+ constructor(private a: A) {
+ }
+ }
+
beforeEach(async () => {
await TestBed.configureTestingModule({})
.compileComponents();
```
然后新建一個測試用例,在該測試用例中,分別獲取Angular托管下B以及C的實例。
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -29,4 +29,11 @@ describe('單例模式相關測試', () => {
await TestBed.configureTestingModule({})
.compileComponents();
});
+
+ fit('驗證單例', () => {
+ const b = TestBed.inject(B);
+ console.log(b);
+ const c = TestBed.inject(C);
+ console.log(c);
+ });
});
```
控制臺如下:

如我們的預期一致,在Angular中服務A是個單例的,且在整個生命周期中僅被初始化一次。
我們說之所以能夠在BC中獲取到相同的A,是同于A使用了`@Injectable({providedIn: 'root'})`注解。該注解的作用是在根模塊中提供A,所以在Angular項目的任意模塊需要A的實例時,都會先查是否有了A的實例,如果有則直接注入,如果沒有則先實例化一個再注入。
## providers
預使模塊能提供A的能力,除了可以把A注入到根模塊外,還可以將其聲明在`providers`中。為此,我們先刪除A類上的`@Injectable({providedIn: 'root'})`注解:
```typescript
+++ b/first-app/src/app/use-class.spec.ts
@@ -3,7 +3,6 @@ import {Injectable} from '@angular/core';
import {randomNumber} from '@yunzhi/ng-mock-api';
describe('useClass相關測試', () => {
- @Injectable({providedIn: 'root'})
class A {
key: number;
```
此時將出現找不到A的提供者的錯誤:

接下來將A聲明在當前動態測試模塊的`providers`中:
```typescript
+++ b/first-app/src/app/use-class.spec.ts
@@ -25,7 +25,11 @@ describe('useClass相關測試', () => {
}
beforeEach(async () => {
- await TestBed.configureTestingModule({})
+ await TestBed.configureTestingModule({
+ providers: [
+ ??{provide: ①A, useClass: ②A}
+ ]
+ })
.compileComponents();
});
```
?? 如此當前模塊便擁有了投供A的能力。該行語句可理解為:當需要①A時,使用②A是否有可用的對象,有則直接使用,沒有則實例化一個。當然,像這種使用A提供A的語句,還可以簡寫為`{provide: A}`或者`A`。也就是說下面①②③三種寫法的是等價的:
```typescript
providers: [
A, ①
{provide: A}, ②
{provide: A, useClass: A} ③
]
```
## forRoot()
前面我們學習過:使用`@Injectable({providedIn: 'root'})`時該服務的注入范圍為根模塊。而Angular中所有的模塊都屬于根模塊的子模塊,所以如果一個服務聲明為`@Injectable({providedIn: 'root'})`,則無論在哪個模塊中注入它,最終都會得到相同的實例。
但有一些服務即需要將其注入范圍聲明為根模塊,又不能夠使用`@Injectable({providedIn: 'root'})`注解,比如說需要進行配置的路由模塊,或是需要進行配置的`MockApi`模塊,這時候便需要Angular建議的`forRoot()`方法來解決這個問題。
比如我們在提供A時,手動的設定key的值:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -6,9 +6,9 @@ describe('單例模式相關測試', () => {
class A {
key: number;
- constructor() {
+ constructor(key: number) {
console.log('a constructor be called');
- this.key = randomNumber();
+ this.key = key;
}
}
```
此時歷史的寫法則將報一個無沒初始化A的錯誤:

的確如此:我們在構造函數中聲明了一個參數key,但Angular并不清楚這個key具體應該賦給它什么值。此時可以創建一個擁有靜態的`forRoot()`模塊:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -1,5 +1,5 @@
import {TestBed} from '@angular/core/testing';
-import {Injectable, ModuleWithProviders} from '@angular/core';
+import {Injectable, ModuleWithProviders, NgModule} from '@angular/core';
describe('單例模式相關測試', () => {
@@ -12,6 +12,18 @@ describe('單例模式相關測試', () => {
}
}
+ @NgModule()
+ class AModule {
+ static ①forRoot(key: number): ②ModuleWithProviders<AModule> {
+ return {
+ ngModule: ③AModule,
+ providers: [
+ {provide: A, ④useValue: new ⑤A(key)}
+ ]
+ };
+ }
+ }
+
@Injectable({providedIn: 'root'})
class B {
constructor(private a: A) {
```
- ① `forRoot()`方法可以接收任意參數
- ② 返回值類型為`ModuleWithProviders`,在該類型上指名了提供的服務類型AModule
- ③ 返回值的ngModule字段對應`ModuleWithProviders`上的泛型
- ④ `useValue`用于返回一個對象。意為當Angular需要一個A實例時,如果有則直接返回;如果沒有,則使用`useValue`后的實例
- ⑤ 提供了一個根據參數設置了`key`的實例
如此以來便可以使用`forRoot()`方法來添加一個配置了參數的A服務了:
```typescript
+++ b/first-app/src/app/single-case.spec.ts
@@ -38,8 +38,8 @@ describe('單例模式相關測試', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- providers: [
- {provide: A, useClass: A.forRoot(123)}
+ imports: [
+ AModule.forRoot(123)
]
})
.compileComponents();
```
注意,此時將`AModule.forRoot()`方法聲明在`imports`中而不是`providers`中。如此以來,便可以在動態模塊中`imports`相應模塊的同時,加入特定的配置參數`123`了:

## 步入正題
鋪墊了這么多,終于可以步入正題了。之所以在單元測試中出現了隨機出現了那么多的錯誤,是由于`TestBed`在處理攔截器時使用了惰性加載。
我們在單元測試中存在以下代碼①:
```typescript
src/app/clazz/class-select.component.spec.ts
providers: [
{
provide: HTTP_INTERCEPTORS, multi: true,
useClass: MockApiTestingInterceptor.forRoot([
TeacherMockApi
])
}
```
以下代碼②:
```typescript
src/app/clazz/add/add.component.mock-api.sepc.ts
providers: [
{
provide: HTTP_INTERCEPTORS,
multi: true,
useClass: MockApiInterceptor.forRoot([ClazzMockApi, TeacherMockApi])
}
```
以及以下代碼③:
```typescript
src/app/mock-api/mock-api-testing.module.ts
providers: [
{
provide: HTTP_INTERCEPTORS, multi: true,
useClass: MockApiTestingInterceptor.forRoot([
ClazzMockApi,
TeacherMockApi,
StudentMockApi
])
}
]
```
Angular的惰性加載機制使得其在進行多文件的單元測試時,多個文件中的TestBed共享了`MockApiTestingInterceptor.forRoot()`方法中的返回值,該返回值是使用不同的MockApi進行配置的。
所以如果其共享的是①的返回值,該返回值則僅僅添加了`TeacherMockApi`對應的幾個模擬接口,此時依賴于另外兩個模擬接口的單元測試將報錯;如果其共享的是②的返回值,該返回值則僅僅添加了`ClazzMockApi, TeacherMockApi`兩個模擬接口, 此時依賴于另一個模擬接口的單元測試將報錯;如果其共享的是③的返回值,則將添加所有的模擬接口,此時所有的單元測試將正常執行。
問題猜測到了,解決問題的方案也就不難了。只需要在所有應用后臺模擬API的地方添加上所有的Mock文件即可。或是刪除在其它測試文件中加入Http攔截器的代碼,改為引用`MockApiTestingModule`從而達到引入所有API的目的。
具體修正文件列表如下:
```typescript
clazz/add/add.component.mock-api.spec.ts
clazz/clazz.component.spec.ts
clazz/klass-select/klass-select-form-control.component.spec.ts
clazz/klass-select/klass-select.component.spec.ts
```
請自行修正。
<hr>
模擬接口的錯誤修正后,最后再修正個邏輯上的錯誤。把鼠標放到出錯的文件名上,瀏覽器將顯示該文件所在的具體位置。

找到該文件的第29行代碼:
```typescript
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
?? fixture.detectChanges();
});
```
說明在組件初始化時發生了錯誤。繼續查看錯誤的堆棧信息發現是在組件的46行,調用loadbyId方法時觸發了MockApi,近而發生了異常:

```typescript
ngOnInit(): void {
34 const id = this.activatedRoute.snapshot.params.id;
35 ?? this.loadById(+id);
}
/**
* 由后臺加載預編輯的班級.
* @param id 班級id.
*/
loadById(id: number): void {
console.log('loadById');
this.formGroup.get('id')?.setValue(id);
this.httpClient.get<Clazz>('/clazz/' + id.toString())
46 ?? .subscribe(clazz => {
console.log('接收到了clazz', clazz);
this.nameFormControl.patchValue(clazz.name);
this.formGroup.get('teacherId')?.setValue(clazz.teacher.id);
}, error => console.log(error));
}
```
分析代碼可知,這是由于在組件初始化時,將34行代碼獲取到的ID值null再轉換為數字時發生了NaN錯誤造成的。預解決這個錯誤,則需要保證37行代碼接收的值非null。
為此,我們組件初始化以前為這個34的代碼設置一個id值:
```typescript
+++ b/first-app/src/app/clazz/edit/edit.component.spec.ts
@@ -6,6 +6,7 @@ import {MockApiTestingModule} from '../../mock-api/mock-api-testing.module';
import {ReactiveFormsModule} from '@angular/forms';
import {getTestScheduler} from 'jasmine-marbles';
import {RouterTestingModule} from '@angular/router/testing';
+import {ActivatedRoute} from '@angular/router';
describe('EditComponent', () => {
let component: EditComponent;
@@ -26,6 +27,8 @@ describe('EditComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(EditComponent);
component = fixture.componentInstance;
+ const activatedRoute = TestBed.inject(ActivatedRoute);
+ activatedRoute.snapshot.params.id = 123;
?? fixture.detectChanges();
});
```
這樣的話,當執行到??組件初始化的代碼時,獲取`activatedRoute.snapshot.params.id`便會得到數據123,而非null了。
至此,我們解決了單元測試中所有非預期錯誤。
| 名稱 | 鏈接 |
| -------- | ------------------------------------------------------------ |
| 單例服務 | [https://angular.cn/guide/singleton-services](https://angular.cn/guide/singleton-services) |
| 本節源碼 | [https://github.com/mengyunzhi/angular11-guild/archive/step7.3.zip](https://github.com/mengyunzhi/angular11-guild/archive/step7.3.zip) |
- 序言
- 第一章 Hello World
- 1.1 環境安裝
- 1.2 Hello Angular
- 1.3 Hello World!
- 第二章 教師管理
- 2.1 教師列表
- 2.1.1 初始化原型
- 2.1.2 組件生命周期之初始化
- 2.1.3 ngFor
- 2.1.4 ngIf、ngTemplate
- 2.1.5 引用 Bootstrap
- 2.2 請求后臺數據
- 2.2.1 HttpClient
- 2.2.2 請求數據
- 2.2.3 模塊與依賴注入
- 2.2.4 異步與回調函數
- 2.2.5 集成測試
- 2.2.6 本章小節
- 2.3 新增教師
- 2.3.1 組件初始化
- 2.3.2 [(ngModel)]
- 2.3.3 對接后臺
- 2.3.4 路由
- 2.4 編輯教師
- 2.4.1 組件初始化
- 2.4.2 獲取路由參數
- 2.4.3 插值與模板表達式
- 2.4.4 初識泛型
- 2.4.5 更新教師
- 2.4.6 測試中的路由
- 2.5 刪除教師
- 2.6 收尾工作
- 2.6.1 RouterLink
- 2.6.2 fontawesome圖標庫
- 2.6.3 firefox
- 2.7 總結
- 第三章 用戶登錄
- 3.1 初識單元測試
- 3.2 http概述
- 3.3 Basic access authentication
- 3.4 著陸組件
- 3.5 @Output
- 3.6 TypeScript 類
- 3.7 瀏覽器緩存
- 3.8 總結
- 第四章 個人中心
- 4.1 原型
- 4.2 管道
- 4.3 對接后臺
- 4.4 x-auth-token認證
- 4.5 攔截器
- 4.6 小結
- 第五章 系統菜單
- 5.1 延遲及測試
- 5.2 手動創建組件
- 5.3 隱藏測試信息
- 5.4 規劃路由
- 5.5 定義菜單
- 5.6 注銷
- 5.7 小結
- 第六章 班級管理
- 6.1 新增班級
- 6.1.1 組件初始化
- 6.1.2 MockApi 新建班級
- 6.1.3 ApiInterceptor
- 6.1.4 數據驗證
- 6.1.5 教師選擇列表
- 6.1.6 MockApi 教師列表
- 6.1.7 代碼重構
- 6.1.8 小結
- 6.2 教師列表組件
- 6.2.1 初始化
- 6.2.2 響應式表單
- 6.2.3 getTestScheduler()
- 6.2.4 應用組件
- 6.2.5 小結
- 6.3 班級列表
- 6.3.1 原型設計
- 6.3.2 初始化分頁
- 6.3.3 MockApi
- 6.3.4 靜態分頁
- 6.3.5 動態分頁
- 6.3.6 @Input()
- 6.4 編輯班級
- 6.4.1 測試模塊
- 6.4.2 響應式表單驗證
- 6.4.3 @Input()
- 6.4.4 FormGroup
- 6.4.5 自定義FormControl
- 6.4.6 代碼重構
- 6.4.7 小結
- 6.5 刪除班級
- 6.6 集成測試
- 6.6.1 惰性加載
- 6.6.2 API攔截器
- 6.6.3 路由與跳轉
- 6.6.4 ngStyle
- 6.7 初識Service
- 6.7.1 catchError
- 6.7.2 單例服務
- 6.7.3 單元測試
- 6.8 小結
- 第七章 學生管理
- 7.1 班級列表組件
- 7.2 新增學生
- 7.2.1 exports
- 7.2.2 自定義驗證器
- 7.2.3 異步驗證器
- 7.2.4 再識DI
- 7.2.5 屬性型指令
- 7.2.6 完成功能
- 7.2.7 小結
- 7.3 單元測試進階
- 7.4 學生列表
- 7.4.1 JSON對象與對象
- 7.4.2 單元測試
- 7.4.3 分頁模塊
- 7.4.4 子組件測試
- 7.4.5 重構分頁
- 7.5 刪除學生
- 7.5.1 第三方dialog
- 7.5.2 批量刪除
- 7.5.3 面向對象
- 7.6 集成測試
- 7.7 編輯學生
- 7.7.1 初始化
- 7.7.2 自定義provider
- 7.7.3 更新學生
- 7.7.4 集成測試
- 7.7.5 可訂閱的路由參數
- 7.7.6 小結
- 7.8 總結
- 第八章 其它
- 8.1 打包構建
- 8.2 發布部署
- 第九章 總結