實踐是檢驗真理的唯一標準。無論什么樣的選手,單元測試做的再好也難免會有想不到的地方。此時便需要集成測試來補刀了。什么是集成測試呢?簡單來說就是把幾個小的模塊組裝到一起,或是把一些單元測試的小的粒度組合到一起進行測試。最簡單最不可靠的集成測試的方法便是本教程中采用的:人為驗證法。angular其實為我們提供了強大的集成測試工具,在angular中又被稱為`端對端的測試`,即` End to end `,由于英文的`2(two)`與`to`同音,所以又被稱為`e2e`。在angular的根目錄中為我們內置了e2e的測試樣例,我們可以通過`ng e2e`或`ng e`來啟動它們。
> 你可能由于網絡原因導該啟動不成功,具體可參考:[https://segmentfault.com/a/1190000021216402](https://segmentfault.com/a/1190000021216402)解決。
e2e測試和單元測試不同,單元測試需要關注代碼的執行情況,需要對變量的值,是否按我們預期的參數進行調用,函數的返回值等分別進行斷言。而e2e測試僅停留在界面上,通過模擬打開某個地址,模擬人為的操作最后斷言界面會產生什么樣的效果。受篇幅、教程難度設計及筆者水平的限制,在本教程中只求帶領大家接觸了解下更自動化的e2e測試,力求起到拋磚引玉的效果。
# 了解e2e測試
首次啟動e2e時,其將為我們下載最新的驅動,此過程的耗時取決于我們的網絡速度。e2e測試正式啟動后,將查找項目根目錄下的e2e文件夾下的src文件夾下的以`.e2e-sppec.ts`結尾的文件,比如angular在初始化時為我們自動生成的`app.e2e-spec.ts`

在angular為我們生成的示例中`app.po.ts`及`app.e2e-spec.ts`兩個文件相互配合使用:`app.po.ts`用于獲取(設置)應用的值,而`app.e2e-spec.ts`則負責斷言。
e2e/src/app.po.ts
```
import { browser, by, element } from 'protractor';
export class AppPage { ①
navigateTo() { ②
return browser.get(browser.baseUrl) as Promise<any>; ③
}
getTitleText() {
return element(by.css('app-root .content span')).getText() as Promise<string>; ④
}
}
```
* ① 定義類名并export(否則其它文件無法調用它,也就失去了配合`app.e2e-spec.ts`的作用
* ② 定義方法
* ③ 打開瀏覽器首頁
* ④ 通過css選擇器,找到`app-root .content span`的`text 文本值`
e2e/src/app.e2e-spec.ts
```
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App①', () => {
let page: AppPage;
beforeEach(() => { ②
page = new AppPage();
});
it('should display welcome message', () => { ③
page.navigateTo(); ④
expect(page.getTitleText()).toEqual('web-app app is running!'); ⑤
});
afterEach(async? () => { ?
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER); ?
expect(logs).not.toContain(jasmine.objectContaining({ ?
level: logging.Level.SEVERE,
} as logging.Entry));
});
});
```
* ① 與單元測試一樣,為此測試啟個好記的名字
* ② 每次執行測試用例前,執行1次本方法
* ③ 測試用例
* ④ 調用輔助類中的方法,打開應用首頁
* ⑤ 斷言輔助類獲取的頁面元素內容為`web-app app is running!`
* ? 在每個測試用例執行完畢后執行
* ? 聲明該測試用異步測試
* ? 獲取瀏覽器報的錯誤
* ? 斷言錯誤列表中的錯誤類型沒有`logging.Level.SEVERE`
由于我們的項目早已不是angular生成的原始項目,所以在運行此測試時會發生如下錯誤:

它在說通過相應的CSS選擇器未找到任何的元素,我們對應修正為:
e2e/src/app.po.ts
```
getTitleText() {
return element(by.css('app-root .content span')).getText() as Promise<string>; ?
return element(by.css('app-welcome h1')).getText() as Promise<string>; ?
}
```
e2e/src/app.e2e-spec.ts
```
import {browser, logging} from 'protractor';
...
it('should display welcome message', () => {
browser.sleep(1000); ?
page.navigateTo();
browser.sleep(2000); ?
expect(page.getTitleText()).toEqual('web-app app is running!'); ?
expect(page.getTitleText()).toEqual('歡迎使用河北工業大學教務管理系統'); ?
browser.sleep(2000); ?
});
```
* ? 為了更清晰的觀察測試執行的過程,每執行1步我們讓瀏覽器小睡一會
最后打開終端進入項目文件夾,執行`ng e2e`來啟動集成測試,網絡暢通的情況下我們將看到此測試自動打開chrome執行相應的測試程序,測試完成后主動的半閉瀏覽器以結束測試。

了解過程到此結束,如果你感覺到意猶未盡,可以來到protractortest的[官網](https://www.protractortest.org/#/)進一步地學習。
# 添加路由
與添加其它的路由相同,打開student模塊的路由文件student-routing.module.ts,并添加如下路由:
```
const routes: Routes = [
{
path: 'add',
component: AddComponent
},
{ ?
path: '',
component: IndexComponent
}
];
```
# 添加菜單項
繼續打開nav組件,在菜單中添加'學生管理'菜單項:
nav/nav.component.ts
```
ngOnInit() {
this.title = '教務管理系統';
this.menus.push({url: 'teacher', name: '教師管理'});
this.menus.push({url: 'klass', name: '班級管理'});
this.menus.push({url: 'student', name: '學生管理'}); ?
}
```
使用`ng serve --open`啟動應用,啟動瀏覽器控制臺,點擊`學生管理`菜單并查看報錯信息:

# 添加依賴
按錯誤提示增加student模擬的依賴:
student/student.module.ts
```
@NgModule({
declarations: [AddComponent, KlassSelectComponent, IndexComponent],
imports: [
CommonModule,
StudentRoutingModule,
ReactiveFormsModule,
FormsModule, ?
CoreModule
]
})
export class StudentModule {
}
```
點擊測試:

錯誤類型為網絡錯誤,此時便可以啟動后臺來進一步進行其它功能的驗證了。
# 啟動后臺
使用你最喜歡的方式來啟動后臺。
# 測試并修正其它內容
在分模塊開發的情況下,若想保障各個模塊間的有效聯通是比較困難的事情。由于在開發過程中每個模塊將分配給不同的團隊成員開發,所以學生列表組件與新增學生組件可能是同步開發的。而各個模塊間的正常跳轉的前提則是:預跳轉的模塊是存在的。
## index組件 -> 新增組件
student/index/index.component.html
```
...
</form>
<div class="row">
<div class="col text-right">
<a class="btn btn-primary" routerLink="./add">新增學生</a>
</div>
</div>
<table>
...
```
接下來按 新增教師 -> 新增班級 -> 新增學生的順序測試添加學生功能:

測試過程中我們發現以下問題:
* 標題應該由 編輯教師 修正為 新增學生
* 新增學生完成后,點擊保存按鈕,界面未跳轉
* 學生管理列表組件的 table 沒有添加bootstrap樣式
## 修正標題
請自行將新裝學生組件中 編輯老師 修正為 新增學生
## 新增組件 -> index組件
新增組件中點擊保存按鈕后,應該成功跳轉到index界面,請自動完成
## 增加bootstrap樣式
為index組件的table增加bootstrap樣式,請自行完成
# 功能測試
完成了基礎樣式修正后,開發進行功能測試。
## 綜合查詢
綜合查詢主要對姓名、學號、班級進行查詢,要使測試正常進行,則需要準備不同姓名、不同學號、不同班級的學生。

測試如下:

## 全選、單選

## 分頁
成功的測試分頁,則需要不少于5頁的學生數據,在開始測試前先新增多條測試學生:

當前共7頁數據,開始進行測試

發現兩個問題:
* 頁碼應該為1基,實際卻為0基
* 點擊其它頁碼時卻跳轉到了首頁
第一個問題修正相對簡單,請自行完成。
第二個問題是由于我們在頁碼中使用了`a`標簽,然后`a`標簽中定義了`href="#"`引起的。在一般的WEB應用中,我們習慣性的使用`href="#"`來表示當點擊該a標簽時不進行任何跳轉。但在`single page web application(SPA) 單頁面WEB應用`中就不一樣了。在單頁面WEB應用中,雖然瀏覽器的導航欄也會按照用戶的點擊進行變更,但卻并沒有重新發起頁面加載請求。這種變更是通過調用瀏覽器相關的API來實現的,而非用戶點擊了需要跳轉的a標簽。在學生管理中,雖然瀏覽器顯示的地址為`localhost/student`但angular很清晰的明了:當前系統的實際請求地址為:`localhost`,遇到`href="#"`實際對應的地址應該為:`localhost/#`。而我們所期待的卻是`localhost/student/#`。猜出了原因,那么解決方案也就隨著而來了。
即然不能使用`href="#"`,那么我們將其刪除好了:
student/index/index.component.html
```
<ul class="pagination">
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(0)">
<span class="page-link">首頁</span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)">
<span class="page-link">上一頁</span>
</li>
<li class="page-item" [ngClass]="{'active': params.page === page}" *ngFor="let page of pages" (click)="onPage(page)">
<a class="page-link" *ngIf="page !== params.page">{{page + 1}}</a>
<span class="page-link" *ngIf="page === params.page">{{page + 1}}<span class="sr-only">(current)</span></span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(params.page + 1)">
<a class="page-link">下一頁</a>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(pageStudent.totalPages - 1)">
<a class="page-link">尾頁</a>
</li>
</ul>
```

刪除`href="#"`后的確修正了前面的跳轉首頁問題,但分頁表現卻并不完美。三個新問題又被暴露了出來:
* 首頁 上一頁 的樣式是我們期望的,但其它頁碼的樣式卻不行。
* 以前將鼠標移到分頁按鈕上的時候,會有個 小手 出現,現在沒有了。
* 當前頁為第1頁時,點擊上一頁仍生效
* 當前頁為最后1頁時,點擊下一頁仍生效
下面分別對上述問題進行修正:
### 樣式問題
其它頁碼的樣式不同于首頁、上一頁是由于我們在首頁、上一頁中使用為`span`標簽,也在頁碼中使用的`a`標簽。使用`a`標簽有個默認的好處:當`a`標簽存在`href`屬性時,鼠標移上去將自動變成 小手 的樣子。為了使格式統一,首先將`a`標簽全部換成`span`標簽。
student/index/index.component.html
```
<ul class="pagination">
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(0)">
<span class="page-link">首頁</span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)">
<span class="page-link">上一頁</span>
</li>
<li class="page-item" [ngClass]="{'active': params.page === page}" *ngFor="let page of pages" (click)="onPage(page)">
<span class="page-link" *ngIf="page !== params.page">{{page + 1}}</span>
<span class="page-link" *ngIf="page === params.page">{{page + 1}}<span class="sr-only">(current)</span></span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(params.page + 1)">
<span class="page-link">下一頁</span>
</li>
<li class="page-item" [ngClass]="{'disabled': params.page === pageStudent.totalPages - 1}" (click)="onPage(pageStudent.totalPages - 1)">
<span class="page-link">尾頁</span>
</li>
</ul>
```

瀏覽器默認為有`href`屬性的`a`標簽的`hover`違類上添加了`cursor: pointer`屬性,以使得鼠標移動到元素上變成 小手 的樣子。如果想用`span`標簽添加此屬性,則為其`hover`違類添加對應的樣式即可:
student/index/index.component.sass
```
ul.pagination > li > span:hover
cursor: pointer
```

pointer常用于跳轉的鏈接,分頁功能中我們更習慣于使用default( 默認光標(通常是一個箭頭))。
student/index/index.component.sass
```
ul.pagination > li > span:hover
cursor: default
```
### 上一頁、下一頁
此時我有了一個疑問,明明下一頁、上一頁顯示為灰色,卻為什么還能點擊呢?這時候就需要看看angular為我們生成的頁面源碼了:

在代碼中使用`[ngClass]="{'disabled': params.page === 0}"`來控制`disabled`時,其實angular是為該元素在`params.page === 0`時添加了一個`disabled`樣式,使其看起來是不能夠點擊的。要使一個元素真正的不能夠被點擊,前提是該元素具有天然的`disabled`屬性,比如`button`元素就具有這個屬性。實驗如下:
student/index/index.component.html
```
<li class="page-item" [ngClass]="{'disabled': params.page === 0}" (click)="onPage(params.page - 1)">
<span class="page-link">上一頁</span>
</li>
<button [disabled]="params.page === 0" (click)="onPage(params.page - 1)">上一頁</button>
```

實驗得出:天然有`disabled`屬性的button,可以設置其`disabled`屬性,能起到禁止點擊的作用。而天然并沒有`disabled`屬性的li,則只能是看起來`disabled`不能被點擊了,而實際上點用戶點擊該元素時,仍然能夠觸發其綁定的onPage方法。對于C層的onPage方法而言,如果按正常的邏輯,是不應該接收到小于0或是大于等于總頁數的值的,但程序的魅力就是如此:它總能找點小樂子,讓本來應該的事情變得那么的不應該。既然V層解決不了(在V層中也可以考慮將li變為button標簽,但這將破壞了原bootstrap結構而使得該分頁樣式變得難以維護),那就在C層的onPage方法上下功夫吧。
student/index/index.component.ts
```
/**
* 點擊分頁按鈕
* @param page 要請求的頁碼
*/
onPage(page: number) {
if (page < 0 || page >= this.pageStudent.totalPages) { ?
return;
}
this.params.page = page;
this.loadData();
}
```
對應修正單元測試:
student/index/index.component.spec.ts
```
fit('onPage 功能測試', () => {
spyOn(component, 'loadData');
component.params.page = 4;
component.onPage(3);
expect(component.params.page).toEqual(3);
expect(component.loadData).toHaveBeenCalled();
/* 越界測試:期望不改變當前頁碼值,loadData僅被前面的代碼調用了1次(本次未調用)*/
component.onPage(-1);
expect(component.params.page).toEqual(3);
expect(component.loadData).toHaveBeenCalledTimes(1);
/* 越界測試:期望不改變當前頁碼值,loadData僅被前面的代碼調用了1次(本次未調用)*/
component.pageStudent.totalPages = 5;
component.onPage(5);
expect(component.params.page).toEqual(3);
expect(component.loadData).toHaveBeenCalledTimes(1);
});
```
此時當頁碼為首、尾頁時,再次點擊上一頁下一頁時便不會發生越界的情況了,至此集成測試完畢。
# 總結
基于**人是必然會犯錯誤的**的理論,在開發時引入單元測試來對自己的代碼進行功能性驗證,從而降低犯錯誤的概率。同樣基于該理論,當各個模塊分離完成后進行組合的測試來進一步降低犯錯的概率。所以的準備工作,都是為了最終減少生產環境中可能面臨的用戶各種無厘頭以及**非常正常**的操作。我們夢想并努力著:在應用上線的那一天,我們可以關上手機心無旁騖地躺在床上休息。這應該就是軟件工程的初心吧。
# 參考文檔
| 名稱 | 鏈接 | 預計學習時長(分) |
| --- | --- | --- |
| 源碼地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.10](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.6.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
- 總結
- 開發規范
- 備用