如何測試 Angular 的 ngModel?
Two-way Binding 的 ngModel
非常方便,但因為這是 syntax sugar,該如何測試呢?
Version
Angular 4.3
Requirement
畫面有一個下拉選單。
下拉選單內有 AWS
、Azure
與 Aliyun
三個值。
當選擇 AWS
時,在下方顯示 0
。
當選擇 Azure
時,在下方顯示 1
。
當選擇 Aliyun
時,在下方顯示 2
。
Acceptance Test (紅燈)
測試案例 :
- 應該有
1
個下拉選單- 下拉選單應該有
3
個選項- 選擇
AWS
,下方應該出現0
- 選擇
Azure
時,下方應該出現1
- 選擇
Aliyun
時,下方應該出現2
e2e/app.e2e-spec.ts
1 | import { NG43ATDDngModelPage } from './app.po'; |
10 行
1 | it(`should have 1 select`, () => { |
測試案例 : 應該有
1
個下拉選單
若下拉選單存在,則 page.getSelect().isPresent()
回傳 true,否則為 false。
不是使用
expect(page.getSelect()).toBeTruthy()
,這樣會永遠綠燈
。
15 行
1 | it(`should have 3 options in select`, () => { |
測試案例 : 下拉選單應該有
3
個選項
測試下拉選單的資料是否為 AWS
、Azure
與 Aliyun
,實務上這些資料會從後端 API 來,可測試 API 是否正常接上,也可以測試 API 的 SQL 或 ORM 是否正確。
若要測試資料完全相同比較困難時,最少可以測試資料的筆數是否正確。
20 行
1 | it(`should show '0' when selecting 'AWS' `, () => { |
測試案例 : 選擇
AWS
,下方應該出現0
當選擇 AWS
時,下方應該顯示 0
。
剩下 Azure
與 Aliyun
的寫法類似。
- 編輯
e2e/app.e2e-spec.ts
- 加入驗收測試
e2e/app.po.ts
1 | import { browser, by, element } from 'protractor'; |
第 8 行
1 | getSelect(): any { |
回傳 id 為 TDDSelect
的 <select>
。
12 行
1 | getSelectCount(): any { |
先由 element(by.id('TDDSelect'))
先抓到 <select>
,再由 all()
取得 <select>
下所有 element,再將 by.tagName('option')
傳入 all()
,找出 tag 為 <option>
的 element 加以 count()
。
16 行
1 | select(text: string) { |
先由 element(by.id('TDDSelect'))
先抓到 <select>
,再由 all()
取得 <select>
下所有 element,再將 by.cssContainingText('option', text)
取得正確 option 加以 click()
。
使用 Protractor 在下拉選單選擇指定字串的寫法較為 tricky,需動用
by.cssContainingText()
。
20 行
1 | getSelectedId(): any { |
下拉選單所選的值,預期只會用 <p>
包起來而已,因此 by.css('p')
即可。
- 編輯
e2e/app.po.ts
- 加入 page object
因為我們還沒實作此功能,得到預期的驗收測試 紅燈
。
Integration Test (紅燈)
測試案例 :
<select>
應該使用*ngFor
產生<option>
ngModel
應該使用selectedId
field<p>
內應該使用selectedId
field
src/app/app.component.spec.ts
1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; |
34 行
1 | it(`should use '*ngFor' to list clouds`, () => { |
測試案例 :
<select>
應該使用*ngFor
產生<option>
整合測試除了測試 binding 是否正確外,另外一個目的就是要驅動出 directive 的使用。
<select>
的 <option>
有多筆資料,勢必會使用 *ngFor
顯示所 binding 的資料。
為了確認是否使用 *ngFor
,一樣使用整合測試慣用的手法 :
建立 stub,並測試 stub
1 | component.clouds = [ |
建立 component.clouds
的 stub,並加上 fixture.detectChanges()
將假資料反映到 HTML 上。
1 | const option = debugElement.query(By.css('#TDDSelect')).children[0].nativeElement; |
由 debugElement.query(By.css('#TDDSelect'))
先抓到 <select>
,由於目前 stub 只有一筆,故取其 children
陣列的第 0
筆資料即可,並取其 nativeElement
。
1 | expect(option.textContent).toBe('GCP'); |
期望其 textContent
為 stub 的 GCP
。
triggerEventHandler()
44 行
1 | it(`should have 'selectedId' field on 'ngModel' directive`, () => { |
測試案例 :
ngModel
應該使用selectedId
field
ngModel
就是要將 HTML element 直接綁定到 field,因此最重要的就是測試有沒有綁定到正確的 field。
Two-way binding 的重點在於當 HTML 改變時,class 的 field 會自動跟著改變,因此我們測試的手法就是改變 HTML,並測試 field 是否跟著 HTML 變化。
1 | debugElement.query(By.css('#TDDSelect')).triggerEventHandler('change', {target: {value: '2'}}); |
在整合測試時,我們慣用 triggerEventHandler()
去觸發 event,由於 DOM 的 change
event 會觸發 ngModelChange
event,因此我們使用 triggerEventHandler()
去觸發 change
event。
我們對 <select>
做任何選擇的 value 值,會由 change()
的第 2 個參數的 $event.target.value
代入。
因此我們要準備 {target: {value: '2'}}
這個 stub,並測試 field 值否為此 stub,若相等,則表示 ngModel
有綁定到正確的 field。
1 | expect(component.selectedId).toBe('2'); |
期望 component.selectedId
為 stub 的 2
。
dispatchEvent()
44 行
1 | it(`should have 'selectedId' field on 'ngModel' directive`, () => { |
測試案例 :
ngModel
應該使用selectedId
field
使用 triggerEventHandler()
方式雖然可行,但我們發現其第 2 個參數的 stub : {target: {value: '2'}}
有點小複雜,且必須很了解 $event
物件的結構。
1 | (<HTMLSelectElement>htmlElement).value = '2'; |
當 <select>
的選擇改變時,事實上就是改變其 value
值。
1 | htmlElement.dispatchEvent(new Event('change')); |
使用 dispatchEvent()
觸發 event,傳入 Event
物件,並以 evetn 名稱傳入 Event
。
triggerEventHandler()
與dispatchEvent()
都是觸發 event,只是triggerEventHandler()
是從 DOM 的方式去觸發 event,而dispatchEvent()
是從 Angular 的方式觸發 event,兩種方式都可以,實務上建議使用dispatchEvent()
,不用了解 DOM 的$event
物件的結構,也比較物件導向。
52 行
1 | it(`should use 'selectedId' field`, () => { |
測試案例 :
<p>
應該使用selectedId
field
因為目前是整合測試,而 AppComponent
的 selectedId
根本還沒實現,理論上也應該使用 spyOn()
,但可惜 Jasmine 的 spyOn()
並沒有支援 field,只能使用最基本的方式:建立 stub,並測試 stub
。
因為還沒實作,整合測試是預期的 紅燈
。
Unit Test (紅燈)
測試案例 :
- Class 應該有
clouds
field 且初始值為AWS
、Azure
與Aliyun
- Class 應該有
selectedId
field 且初始值為0
src/app/app.component.spec.ts
1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; |
34 行
1 | it(`should have 'clouds' field with ['AWS', 'Azure', 'Aliyun']`, () => { |
測試案例 : Class 應該有
clouds
field 且初始值為AWS
、Azure
與Aliyun
目前需求只有 3
筆資料,直接 expect().toEqual()
是否為預期陣列。
Q :
toBe()
與toEqual()
有什麼差別 ?
toBe()
: 比較 valuetoEqual()
: 比較 object
42 行
1 | it(`should have 'selectedId' field as '0'`, () => { |
測試案例 : Class 應該有
selectedId
field且初始值為0
期望 target.selectedId
為 0
。
因為還沒實作,單元測試是預期的 紅燈
。
Unit Test (綠燈)
測試案例 : Class 應該有
clouds
field 且初始值為AWS
、Azure
與Aliyun
src/app/app.component.ts
1 | import { Component } from '@angular/core'; |
10 行
1 | clouds: Cloud[] = [ |
建立 cloud
field 與設定初始陣列。
在 class 實作出 cloud
field 後,Wallaby 單元測試就 綠燈
了。
測試案例 : Class 應該有
selectedId
field 且初始值為0
src/app/app.component.ts
1 | import { Component } from '@angular/core'; |
16 行
1 | selectedId = '0'; |
建立 selected
field 與設定初始值為 0
。
在 class 實作出 selectedId
field 後,Wallaby 單元測試就 綠燈
了。
Integration Test (綠燈)
測試案例 :
<select>
應該使用*ngFor
產生<option>
src/app/app.component.html
1 | <select id="TDDSelect"> |
使用 *ngFor
根據 clouds
field 產生 <option>
。
使用 *ngFor
之後,Wallaby 整合測試就 綠燈
了。
測試案例 :
ngModel
應該使用selectedId
field
src/app/app.module.ts
1 | import { BrowserModule } from '@angular/platform-browser'; |
12 行
1 | imports: [ |
ngModel
directive 需要使用 FormsModule
,而 Angular CLI 預設沒有載入,必須手動加上。
src/app/app.component.html
1 | <select id="TDDSelect" [(ngModel)]="selectedId"> |
在 <select>
加上 [(ngModel)]="selectedId"
。
在 HTML 實作 ngModel
綁定到 selectedId
field 之後,Wallaby 整合測試就 綠燈
了。
src/app/app.component.html
測試案例 :
<p>
內應該使用selectedId
field
1 | <select id="TDDSelect" [(ngModel)]="selectedId"> |
在 <p>
內加上 selectedId
。
在 HTML 實作 <p>
內的 selectedId
綁定之後,Wallaby 的整合測試就全部 綠燈
了。
Acceptance Test (綠燈)
測試案例
- 應該有
1
個下拉選單- 下拉選單應該有
3
個選項- 選擇
AWS
,下方應該出現0
- 選擇
Azure
時,下方應該出現1
- 選擇
Aliyun
時,下方應該出現2
整合測試 綠燈
後,最後再跑一次驗收測試確認為 綠燈
。
重構
因為 class 沒有邏輯,所以不需要重構。
Conclusion
ngModel
整合測試的關鍵在於測試ngModel
有沒有綁定到正確的 field,因此建立 stub,並觸發 DOM 的change
event ,測試 field 值是否為 stub,則完成ngModel
的整合測試。- 在整合測試觸發 event 時,可以使用
triggerEventHandler()
或dispatchEvent()
,實務上建議使用dispatchEvent()
,較物件導向。
Reference
Andras Sevcsik, Testing ngModel in Angular 2
Sample Code
完整的範例可以在我的 GitHub 上找到。