至此,一個具有完整輸入、輸出的組件便已經被我們驕傲的開發完畢了。但請考慮以下問題:
* ① 新增組件的輸入功能后,是否對本組件的歷史功能產生了影響
* ② 將新增組件應用于第三方組件中,是否對第三方組件的功能產生了影響
如果我們對其產生了影響那么你認為當前都產生了什么影響 ,產生的原因又是什么,同時又是計劃如何修正的。
# 會說話的代碼
如果你僅憑想像便給出了自己的答案,那么無論你的答案是什么,都將是蒼白而無力的。在軟件開發的領域里沒有實踐就沒有發言權,在沒有真實的實踐以前,任何主觀的錯誤預計都是耍流氓。而單元測試就不會說謊。在此,我們不防借助單元測試來回答一下前面的問題。
#### 測試本組件
找到klass/teacher-select/teacher-select.component.spec.ts并把`describe`修改為`fdescribe`,然后運行`ng test`來觀察會發生什么。

```
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`來觀察會發生什么:

```
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();
}));
```
#### 測試

提示找有找到`Router`的`provider`,則加入`RouterTestingModule`
```
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AddComponent, TeacherSelectComponent],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule,
RouterTestingModule ?
]
})
.compileComponents();
}));
```
再測試

此時基礎的依賴錯誤提示已經完全消除,而上圖得到的便是一個真真切切的錯誤了。此錯誤表示:對該組件進行變更(引入了選擇教師組件)后,對原組件的正常功能產生了影響。
提示信息再說:我們預測應該得到2,但實際上卻得到了null。我們此時可以訪問[http://localhost:4200/klass/add](http://localhost:4200/klass/add)來驗證單元測試拋出的錯誤信息是否是真真切切有幫助的。

通過點擊測試我們發現當點擊`保存`按鈕時并沒有進行數據提交,而是在控制臺中報了以上錯誤。
### 錯誤
根據單元測試的提示,我們來到測試文件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 |
- 序言
- 第一章: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
- 總結
- 開發規范
- 備用