昨天幫我們用 Template Driven Forms 所撰寫的被保人表單寫完單元測試之後,今天則是要來為它寫整合測試。
大家還記得整合測試的目標是要測什麼嗎?我幫大家複習一下:
整合測試的測試目標是要測是兩個 或是兩個以上 的類別之間的互動 是否符合我們的預期。
實作開始 首先我們先增加一個 Integration testing
的區塊,有關於整合測試的程式碼接下來都會放在這裡面,至於昨天的就放在 Unit testing
的區塊:
1 2 3 4 5 6 7 8 9 10 11 describe('TemplateDrivenFormsAsyncInsuredComponent' , () => { describe('Unit testing' , () => { }); describe('Integration testing' , () => { }); });
跟之前樣先打開 .html
來看一下目前的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 <form > <fieldset *ngFor ="let insured of insuredList; let index = index; trackBy: trackByIndex" > <legend > 被保人</legend > <p > <label [for ]="'name-' + index" > 姓名:</label > <input type ="text" [name ]="'name-' + index" [id ]="'name-' + index" required maxlength ="10" minlength ="2" #nameNgModel ="ngModel" [ngModel ]="insured.name" (ngModelChange )="insuredNameChange(nameNgModel.control, insured)" /> <span class ="error-message" > {{ insured.nameErrorMessage }}</span > </p > <p > 性別: <input type ="radio" [name ]="'gender-' + index" [id ]="'male-' + index" value ="male" required [(ngModel )]="insured.gender" > <label [for ]="'male-' + index" > 男</label > <input type ="radio" [name ]="'gender-' + index" [id ]="'female-' + index" value ="female" required [(ngModel )]="insured.gender" > <label [for ]="'female-' + index" > 女</label > </p > <p > <label [for ]="'age-' + index" > 年齡:</label > <select [name ]="'age-' + index" [id ]="'age-' + index" required #ageNgModel ="ngModel" [ngModel ]="insured.age" (ngModelChange )="insuredAgeChange(ageNgModel.control, insured)" > <option value ="" > 請選擇</option > <option value ="18" > 18歲</option > <option value ="20" > 20歲</option > <option value ="70" > 70歲</option > <option value ="75" > 75歲</option > </select > <span class ="error-message" > {{ insured.ageErrorMessage }}</span > </p > <p > <button type ="button" (click )="deleteInsured(index)" > 刪除</button > </p > </fieldset > <p > <button type ="button" (click )="addInsured()" > 新增被保險人</button > <button type ="submit" [disabled ]="isFormInvalid" > 送出</button > </p > </form >
大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:
姓名欄位
屬性 type
的值要是 text
屬性 name
的值要是 name-N
屬性 minlength
的值要是 2
屬性 maxlength
的值要是 10
要有屬性 required
要將被保人的屬性 name
的值綁定到此欄位上
此欄位的值如果有變動,要能觸發函式 insuredNameChange
性別欄位
屬性 type
的值要是 radio
屬性 name
的值要是 gender-N
要有屬性 required
要將被保人的屬性 gender
的值綁定到此欄位上
年齡欄位
屬性 name
的值要是 age-N
要有屬性 required
要將被保人的屬性 age
的值綁定到此欄位上
此欄位的值如果有變動,要能觸發函式 insuredAgeChange
錯誤訊息
要將被保人的的屬性 nameErrorMessage
的值綁定到畫面上
要將被保人的的屬性 ageErrorMessage
的值綁定到畫面上
新增被保人按鈕
刪除被保人按鈕
送出按鈕
屬性 type
的值要是 submit
沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit
把要測的項目都列出來之後,有沒有覺得要測的項目很多阿?哈哈!
再次跟大家說明,雖然上面這些項目有些其實並不真的屬於整合測試 的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼。
此外,開始之前也別忘記先做以下程式碼所展示的前置作業:
1 2 3 4 5 6 7 8 9 10 describe('Integration testing' , () => { let compiledComponent: HTMLElement; beforeEach(() => { fixture.detectChanges(); compiledComponent = fixture.nativeElement; }); });
姓名欄位的驗證 複習一下姓名欄位的驗證項目:
屬性 type
的值要是 text
屬性 name
的值要是 name-N
屬性 minlength
的值要是 2
屬性 maxlength
的值要是 10
要有屬性 required
要將被保人的屬性 name
的值綁定到此欄位上
此欄位的值如果有變動,要能觸發函式 insuredNameChange
接下來就把姓名欄位要驗證的項目寫成測試案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 describe('the insured fields' , () => { beforeEach(() => { component.insuredList = [{ name: '' , gender: '' , age: '' , nameErrorMessage: '' , ageErrorMessage: '' }]; fixture.detectChanges(); }); describe('the name input field' , () => { const key = 'name-0' let nameInputElement: HTMLInputElement; beforeEach(() => { nameInputElement = compiledComponent.querySelector(`#${key} ` )!; }); it('should have attribute "type" and the value is "text"' , () => { const attributeName = 'type' ; const attributeValue = 'text' ; expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "name" and the value is "name-0"' , () => { const attributeName = 'ng-reflect-name' ; const attributeValue = key; expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "minlength" and the value is "2"' , () => { const attributeName = 'minlength' ; const attributeValue = '2' ; expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "maxlength" and the value is "10"' , () => { const attributeName = 'maxlength' ; const attributeValue = '10' ; expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "required"' , () => { const attributeName = 'required' ; expect(nameInputElement.hasAttribute(attributeName)).toBe(true ); }); it('should binding the value of the insured\'s property "name"' , () => { const name = 'whatever' ; component.insuredList[0 ].name = name; fixture.detectChanges(); expect(nameInputElement.getAttribute('ng-reflect-model' )).toBe(name); }); it('should trigger function "insuredNameChange" when the value be changed' , () => { spyOn(component, 'insuredNameChange' ); const nameFormControl = component.nameNgModelRefList.get(0 )!.control; nameInputElement.value = 'whatever' ; nameInputElement.dispatchEvent(new Event('ngModelChange' )); expect(component.insuredNameChange).toHaveBeenCalledWith(nameFormControl, component.insuredList[0 ]); }); }); });
測試結果:
這段程式碼中有幾個重點:
為了之後測其他欄位,我多新增了一個 test insured fields
的 describe
。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 beforeEach
來做。
should have attribute "name" and the value is "name-0"
這個測試案例要記得我們在 Template 綁定時是用 [name]
的方式綁定,所以在驗證的時候是抓 ng-reflect-name
,如果單純抓 name
來驗是會報錯的噢!
should trigger function "insuredNameChange" when the value be changed
最後這個測試案例比較特別,不知道大家還記不記得上次寫這裡的時候,我有介紹過關於 Spy 的事情與怎麼用 @ViewChild
抓 Template 中的 nameFormControl
?
如果不記得的話,趕快回去第七天的文章 複習一下!
上次用的 @ViewChild
是抓取單一的元素,但這次是複數的怎辦?
答案是 ─ @ViewChildren
。
有沒有一種寫 Angular 還可以學英文的感覺?
只要我們像這樣在程式碼中加上這個 Angular 的裝飾器:
1 2 3 4 export class TemplateDrivenFormsAsyncInsuredComponent { @ViewChildren ('nameNgModel' ) nameNgModelRefList!: QueryList<NgModel>; }
Angular 就會在每次渲染完畫面之後,幫我們抓取有在 HTML 的屬性中加上 #nameNgModel
的所有元素,而抓出來的元素會用 Angular 所包裝的類別 ─ QueryList
包起來,以利我們使用。
性別欄位的驗證 性別欄位的驗證項目如下:
男生
屬性 type
的值要是 radio
屬性 name
的值要是 male-N
屬性 value
的值要是 male
要有屬性 required
要將被保人的屬性 gender
的值綁定到此欄位上
女生
屬性 type
的值要是 radio
屬性 name
的值要是 female-N
屬性 value
的值要是 female
要有屬性 required
要將被保人的屬性 gender
的值綁定到此欄位上
測試程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 describe('the gender radio buttons' , () => { let radioButtonElement: HTMLInputElement; describe('male' , () => { beforeEach(() => { radioButtonElement = compiledComponent.querySelector(`#male-0` )!; }); it('should have attribute "type" and the value is "radio"' , () => { const attributeName = 'type' ; const attributeValue = 'radio' ; expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "name" and the value is "gender-0"' , () => { const attributeName = 'ng-reflect-name' ; const attributeValue = 'gender-0' ; expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "value" and the value is "male"' , () => { const attributeName = 'value' ; const attributeValue = 'male' ; expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "required"' , () => { const attributeName = 'required' ; expect(radioButtonElement.hasAttribute(attributeName)).toBe(true ); }); it('should binding the value of the insured\'s property "gender"' , () => { const gender = 'male' ; component.insuredList[0 ].gender = gender; fixture.detectChanges(); expect(radioButtonElement.getAttribute('ng-reflect-model' )).toBe(gender); }); }); describe('female' , () => { beforeEach(() => { radioButtonElement = compiledComponent.querySelector(`#female-0` )!; }); it('should have attribute "type" and the value is "radio"' , () => { const attributeName = 'type' ; const attributeValue = 'radio' ; expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "name" and the value is "gender-0"' , () => { const attributeName = 'ng-reflect-name' ; const attributeValue = 'gender-0' ; expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "value" and the value is "female"' , () => { const attributeName = 'value' ; const attributeValue = 'female' ; expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "required"' , () => { const attributeName = 'required' ; expect(radioButtonElement.hasAttribute(attributeName)).toBe(true ); }); it('should binding the value of the insured\'s property "gender"' , () => { const gender = 'female' ; component.insuredList[0 ].gender = gender; fixture.detectChanges(); expect(radioButtonElement.getAttribute('ng-reflect-model' )).toBe(gender); }); }); });
這邊的測試雖然簡單,但我還是遇到了一個問題:「怎麼驗雙向綁定裡,關於 ngModelChange
的部份」。
我的預期是我點擊了某個性別的單選鈕之後,它會把值指定給被保人的 gender
欄位。
但我試了好幾種驗法,也查了老半天資料,就是沒辦法成功(攤手),如果有朋友成功驗出來,請麻煩在下方留言分享一下,感謝!
測試結果:
年齡欄位的驗證 年齡欄位的驗證項目如下:
屬性 name
的值要是 age-N
要有屬性 required
要將被保人的屬性 age
的值綁定到此欄位上
此欄位的值如果有變動,要能觸發函式 insuredAgeChange
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 describe('the age field' , () => { const key = 'age-0' let ageSelectElement: HTMLSelectElement; beforeEach(() => { ageSelectElement = compiledComponent.querySelector(`#${key} ` )!; }); it('should have attribute "name" and the value is "age-0"' , () => { const attributeName = 'ng-reflect-name' ; const attributeValue = key; expect(ageSelectElement.getAttribute(attributeName)).toBe(attributeValue); }); it('should have attribute "required"' , () => { const attributeName = 'required' ; expect(ageSelectElement.hasAttribute(attributeName)).toBe(true ); }); it('should binding the value of the insured\'s property "age"' , () => { const age = '18' ; component.insuredList[0 ].age = age; fixture.detectChanges(); expect(ageSelectElement.getAttribute('ng-reflect-model' )).toBe(age); }); it('should trigger function "insuredAgeChange" when the value be changed' , () => { spyOn(component, 'insuredAgeChange' ); const ageNgModel = component.ageNgModelRefList.get(0 )!; ageSelectElement.value = '18' ; ageSelectElement.dispatchEvent(new Event('ngModelChange' )); expect(component.insuredAgeChange).toHaveBeenCalledWith(ageNgModel.value, ageNgModel.errors, component.insuredList[0 ]); }); });
年齡欄位的驗證跟姓名的驗證有 87% 像,複製過來再稍微調整一下即可。
測試結果:
錯誤訊息的驗證 錯誤訊息要驗證的項目是:
要將被保人的屬性 nameErrorMessage
的值綁定到畫面上
要將被保人的屬性 ageErrorMessage
的值綁定到畫面上
測試程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 describe('Error Messages' , () => { it('should binding the value of the insured\'s property "nameErrorMessage" in the template' , () => { const insured = component.insuredList[0 ]; const errorMessage = 'account error' ; const targetElement = compiledComponent.querySelector('#name-0 + .error-message' ); insured.nameErrorMessage = errorMessage; fixture.detectChanges(); expect(targetElement?.textContent).toBe(errorMessage); }); it('should binding the value of the insured\'s property "ageErrorMessage" in the template' , () => { const insured = component.insuredList[0 ]; const errorMessage = 'password error' ; const targetElement = compiledComponent.querySelector('#age-0 + .error-message' ); insured.ageErrorMessage = errorMessage; fixture.detectChanges(); expect(targetElement?.textContent).toBe(errorMessage); }); });
錯誤訊息的驗證也非常簡單,大家應該都能輕鬆驗證!
測試結果:
刪除按鈕的驗證 刪除被保人按鈕要驗證的是:按下按鈕要能觸發函式 deleteInsured
。這部份大家只要使用 Spy
的技巧來驗證即可,也是頗為簡單。
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 describe('Delete insured button' , () => { it('should trigger function `deleteInsured` after being clicked' , () => { const index = 0 ; const deleteButtonElement = compiledComponent.querySelector('fieldset button[type="button"]' ) as HTMLElement; spyOn(component, 'deleteInsured' ); deleteButtonElement.click(); expect(component.deleteInsured).toHaveBeenCalledWith(index); }); });
測試結果:
新增被保人按鈕的驗證 新增被保人按鈕要驗證的是:按下按鈕要能觸發函式 addInsured
,跟刪除被保人的按鈕要驗證的項目幾乎是一模一樣,複製過來稍微修改一下即可。
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 describe('add insured button' , () => { it('should trigger function `addInsured` after being clicked' , () => { const addButtonElement = compiledComponent.querySelector('p:last-child button[type="button"]' ) as HTMLElement; spyOn(component, 'addInsured' ); addButtonElement.click(); expect(component.addInsured).toHaveBeenCalled(); }); });
測試結果:
送出按鈕的驗證 最後,送出按鈕要驗證的項目是:
屬性 type
的值要是 submit
沒有任何被保人時,送出按鈕皆呈現不可被點選之狀態
任一個被保人的驗證有誤時,送出按鈕皆呈現不可被點選之狀態
當所有的被保人資料皆正確時,按下送出按鈕要能觸發函式 submit
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 describe('submit button' , () => { let buttonElement: HTMLButtonElement; beforeEach(() => { buttonElement = compiledComponent.querySelector('button[type="submit"]' ) as HTMLButtonElement; }); it('should be existing' , () => { expect(buttonElement).toBeTruthy(); }); it('should be disabled when "insuredList" is empty array' , () => { expect(buttonElement.hasAttribute('disabled' )).toBe(true ); }); it('should be disabled when there ara any verifying errors that insured\'s data' , () => { component.insuredList = [{ name: 'A' , gender: '' , age: '' , nameErrorMessage: '' , ageErrorMessage: '' }]; compiledComponent.querySelector('button[type="submit"]' ) fixture.detectChanges(); expect(buttonElement.hasAttribute('disabled' )).toBe(true ); }) it('should be enabled when there ara any verifying errors that insured\'s data' , () => { component.insuredList = [{ name: 'Leo' , gender: 'male' , age: '18' , nameErrorMessage: '' , ageErrorMessage: '' }]; fixture.detectChanges(); expect(buttonElement.hasAttribute('disabled' )).toBe(false ); }) });
測試結果:
咦?怎麼會有 Error 咧?原來這個問題跟上次我們寫登入表單的整合測試所遇到的情況一樣。
所以我們目前先在這個案例的 it
的前面加上一個 x
,代表我們要 ignore
這個案例的意思,像這樣:
1 2 3 xit('should be disabled when there ara any verifying errors that insured\'s data' , () => { })
測試結果:
至此,我們就完成了整合測試的部份囉!
今天所有的測試結果:
本日小結 其實今天用所有用到的測試手法與概念都在之前的的文章就已經分享過了,今天主要是讓大家練習,提昇撰寫測試的熟悉度。
明天我們要為用 Reactive Forms 所撰寫的被保人表單來撰寫單元測試,我覺得大家可以在看我的文章之前先自己寫寫看,之後再參考我的文章,一定會有更多的收穫!
今天的實作程式碼會放在 Github - Branch: day13 供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!