昨天幫我們用 Reactive Forms 所撰寫的被保人表單寫完單元測試之後,今天則是要來為它寫整合測試。
大家還記得整合測試的目標是要測什麼嗎?我幫大家複習一下:
整合測試的測試目標是要測是兩個或是兩個以上的類別之間的互動是否符合我們的預期。
首先我們先增加一個 Integration testing
的區塊,有關於整合測試的程式碼接下來都會放在這裡面,至於昨天的就放在 Unit testing
的區塊:
describe('TemplateDrivenFormsAsyncInsuredComponent', () => {
// 其他省略...
describe('Unit testing', () => {
// 昨天寫的單元測試...
});
describe('Integration testing', () => {
// 今天要寫的整合測試
});
});
跟之前樣先打開 .html
來看一下目前的程式碼:
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
<legend>被保人</legend>
<p>
<label [for]="'name-' + index">姓名:</label>
<input type="text" [id]="'name-' + index" formControlName="name" />
<span class="error-message">{{ getErrorMessage("name", index) }}</span>
</p>
<p>
性別:
<input
type="radio"
[id]="'male-' + index"
value="male"
formControlName="gender"
/>
<label [for]="'male-' + index">男</label>
<input
type="radio"
[id]="'female-' + index"
value="female"
formControlName="gender"
/>
<label [for]="'female-' + index">女</label>
</p>
<p>
<label [for]="'age-' + index">年齡:</label>
<select name="age" [id]="'age-' + index" formControlName="age">
<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">{{ getErrorMessage("age", index) }}</span>
</p>
<p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
</fieldset>
</ng-container>
<p>
<button type="button" (click)="addInsured()">新增被保險人</button>
<button type="submit" [disabled]="isFormInvalid">送出</button>
</p>
</form>
大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:
type
的值要是 text
formControlName
的值要是 name
pristine
時,則不會有錯誤訊息pristine
且欄位的值為空字串時,則顯示 此欄位必填
的錯誤訊息pristine
且欄位的值只有一個字時,則顯示 姓名至少需兩個字以上
的錯誤訊息pristine
且欄位的值超過十個字時,則顯示 姓名至多只能輸入十個字
的錯誤訊息type
的值要是 radio
value
的值要是 male
formControlName
的值要是 gender
type
的值要是 radio
value
的值要是 male
formControlName
的值要是 gender
formControlName
的值要是 age
pristine
時,則不會有錯誤訊息pristine
且欄位的值為空字串時,則顯示 此欄位必填
的錯誤訊息addInsured
deleteInsured
type
的值要是 submit
submit
把要測的項目都列出來之後,有沒有覺得要測的項目很多阿?哈哈!
再次跟大家說明,雖然上面這些項目有些其實並不真的屬於整合測試的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼。
大家應該還記得怎麼測吧?忘記的趕快回去看一下之前的文章!
此外,開始之前也別忘記先做以下程式碼所展示的前置作業,後面將不再贅述:
describe('Integration testing', () => {
let compiledComponent: HTMLElement;
beforeEach(() => {
compiledComponent = fixture.nativeElement;
});
// 案例寫在這邊
});
複習一下姓名欄位的驗證項目:
type
的值要是 text
formControlName
的值要是 name
pristine
時,則不會有錯誤訊息pristine
且欄位的值為空字串時,則顯示 此欄位必填
的錯誤訊息pristine
且欄位的值只有一個字時,則顯示 `姓名至少程式碼如下:
describe('the insured fields', () => {
let formGroup: FormGroup;
beforeEach(() => {
const nameControl = new FormControl('', [
Validators.required,
Validators.minLength(2),
Validators.maxLength(10)
]);
const genderControl = new FormControl('', Validators.required);
const ageControl = new FormControl('', Validators.required);
formGroup = new FormGroup({
name: nameControl,
gender: genderControl,
age: ageControl
});
component.formArray.push(formGroup);
fixture.detectChanges();
});
describe('the name input field', () => {
let nameInputElement: HTMLInputElement;
beforeEach(() => {
nameInputElement = compiledComponent.querySelector('#name-0')!;
});
it('should have attribute "type" and the value is "text"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'text';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});
it('should have attribute "formControlName" and the value is "name"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'name';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});
describe('Error Messages', () => {
let nameFormControl: FormControl;
beforeEach(() => {
nameFormControl = formGroup.get('name') as FormControl;
});
it('should be empty string when property "pristine" of the "formControl" is `true`', () => {
// Arrange
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe('');
});
describe('when the field is dirty', () => {
beforeEach(() => {
nameFormControl.markAsDirty();
fixture.detectChanges();
});
it('should be "此欄位必填" when the value is empty string', () => {
// Arrange
const errorMessage = '此欄位必填';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
it('should be "姓名至少需兩個字以上" when the value\'s length less than 2', () => {
// Arrange
nameFormControl.setValue('A')
const errorMessage = '姓名至少需兩個字以上';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
it('should be "姓名至多只能輸入十個字" when the value\'s length greater than 10', () => {
// Arrange
nameFormControl.setValue('ABCDE123456')
const errorMessage = '姓名至多只能輸入十個字';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
it('should be empty string when there are not any errors', () => {
// Arrange
nameFormControl.setValue('ABCDE123456')
const errorMessage = '姓名至多只能輸入十個字';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});
});
});
});
測試結果:
這段程式碼中有兩個重點:
為了之後測其他欄位,我多新增了一個 test insured fields
的 describe
。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 beforeEach
來做。
切記不要使用 component.addInsured()
來新增被保人。
性別欄位要驗證的部份非常簡單,項目如下:
type
的值要是 radio
value
的值要是 male
formControlName
的值要是 gender
type
的值要是 radio
value
的值要是 male
formControlName
的值要是 gender
測試程式碼如下:
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"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'radio';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});
it('should have attribute "formControlName" and the value is "gender"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'gender';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});
it('should have attribute "value" and the value is "male"', () => {
// Arrange
const attributeName = 'value';
const attributeValue = 'male';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});
});
describe('female', () => {
beforeEach(() => {
radioButtonElement = compiledComponent.querySelector(`#female-0`)!;
});
it('should have attribute "type" and the value is "radio"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'radio';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});
it('should have attribute "formControlName" and the value is "gender"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'gender';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});
it('should have attribute "value" and the value is "female"', () => {
// Arrange
const attributeName = 'value';
const attributeValue = 'female';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});
});
});
測試結果:
年齡欄位要驗證的項目如下:
formControlName
的值要是 age
pristine
時,則不會有錯誤訊息pristine
且欄位的值為空字串時,則顯示 此欄位必填
的錯誤訊息程式碼如下:
describe('the age field', () => {
const key = 'age-0'
let ageSelectElement: HTMLSelectElement;
beforeEach(() => {
ageSelectElement = compiledComponent.querySelector(`#${key}`)!;
});
it('should have attribute "formControlName" and the value is "age"', () => {
// Arrange
const attributeName = 'formControlName';
const attributeValue = 'age';
// Assert
expect(ageSelectElement.getAttribute(attributeName)).toBe(attributeValue);
});
describe('Error Messages', () => {
let ageFormControl: FormControl;
beforeEach(() => {
ageFormControl = formGroup.get('age') as FormControl;
});
it('should be empty string when property "pristine" of the "formControl" is `true`', () => {
// Arrange
const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe('');
});
describe('when the field is dirty', () => {
beforeEach(() => {
ageFormControl.markAsDirty();
fixture.detectChanges();
});
it('should be "此欄位必填" when the value is empty string', () => {
// Arrange
const errorMessage = '此欄位必填';
const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});
});
});
年齡欄位的驗證跟姓名的驗證有 87% 像,複製過來再稍微調整一下即可。
測試結果:
刪除被保人按鈕要驗證的是:按下按鈕要能觸發函式 deleteInsured
。這部份大家只要使用 Spy
的技巧來驗證即可,也是頗為簡單。
程式碼如下:
describe('Delete insured button', () => {
it('should trigger function `deleteInsured` after being clicked', () => {
// Arrange
const index = 0;
const deleteButtonElement = compiledComponent.querySelector('fieldset button[type="button"]') as HTMLElement;
spyOn(component, 'deleteInsured');
// Act
deleteButtonElement.click();
// Assert
expect(component.deleteInsured).toHaveBeenCalledWith(index);
});
});
測試結果:
新增被保人按鈕要驗證的是:按下按鈕要能觸發函式 addInsured
,跟刪除被保人的按鈕要驗證的項目幾乎是一模一樣,複製過來稍微修改一下即可。
程式碼如下:
describe('add insured button', () => {
it('should trigger function `addInsured` after being clicked', () => {
// Arrange
const addButtonElement = compiledComponent.querySelector('p:last-child button[type="button"]') as HTMLElement;
spyOn(component, 'addInsured');
// Act
addButtonElement.click();
// Assert
expect(component.addInsured).toHaveBeenCalled();
});
});
測試結果:
最後,送出按鈕要驗證的項目是:
type
的值要是 submit
submit
程式碼如下:
describe('submit button', () => {
let buttonElement: HTMLButtonElement;
beforeEach(() => {
buttonElement = compiledComponent.querySelector('button[type="submit"]') as HTMLButtonElement;
});
it('should be existing', () => {
// Assert
expect(buttonElement).toBeTruthy();
});
it('should be disabled when there are not any insureds', () => {
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(true);
});
describe('When there is a insured', () => {
let formGroup: FormGroup;
beforeEach(() => {
const nameControl = new FormControl('', [
Validators.required,
Validators.minLength(2),
Validators.maxLength(10)
]);
const genderControl = new FormControl('', Validators.required);
const ageControl = new FormControl('', Validators.required);
formGroup = new FormGroup({
name: nameControl,
gender: genderControl,
age: ageControl
});
component.formArray.push(formGroup);
fixture.detectChanges();
});
it('should be disabled when there ara any verifying errors that insured\'s data', () => {
// Arrange
compiledComponent.querySelector('button[type="submit"]')
// Act
fixture.detectChanges();
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(true);
})
it('should be enabled when there ara any verifying errors that insured\'s data', () => {
// Arrange
formGroup.patchValue({
name: 'Leo',
gender: 'male',
age: '18',
});
// Act
fixture.detectChanges();
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(false);
})
});
});
測試結果:
至此,我們就完成了整合測試的部份囉!
今天所有的測試結果:
今天一樣主要是讓大家練習,提昇撰寫測試的熟悉度,該講的重點應該在之前的文章都有提到。
不過我相信大家應該寫差不多類型的測試寫到有點索然無味了,所以我明天不會讓大家寫測試,而是會總結一下 Template Driven Forms 與 Reactive Forms 這兩種開發方式的優缺點,敬請期待。
今天的實作程式碼會放在 Github - Branch: day15 供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!
Hi Leo,
我發現name的驗證應該可以調整成像age那樣的結構會整潔許多XD
Hi ryan851109, 請問是什麼意思呢?
仔細看一下應該也不是像age那樣,因為我有稍微調整一下the name input field > Error Messages
的驗證程式碼,想說每個it裡面都有targetElement,就把他提出來放在宣告nameFormControl的地方,並在第一個beforeEach時去抓取內容,再把nameFormControl取值的部分移到第二個beforeEach,因為'should be empty string when property "pristine" of the "formControl" is true'
這個it用不掉nameFormControl,如此一來程式碼就少了蠻多行的,應該不會出甚麼問題吧XD
示意圖 :
不拉出來的考量就像我們在其他篇討論的那樣,不過如果跑測試可以過的話可以先這樣XD
好的~如果後面有出現問題,我應該就會印象深刻了XD
沒錯,這也是我自己的經驗分享,有痛過就會懂了XDD