有了公用的多擇組件后我們嘗試使用它打造班級選擇組件。來到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$);
});
```
單元測試通過,本節完成:

# 本節小測
請參考本節中的測試示例,請嘗試在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) | - |
- 序言
- 第一章: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
- 總結
- 開發規范
- 備用