今天我們要來為我們用 Reactive Forms 所撰寫的登入系統寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第三天的文章: Reactive Forms 實作 - 以登入為例。
前置作業基本上都跟第六天的文章:單元測試實作 - 登入系統 by Template Driven Forms 相同,今天就不會再贅述,大家如果忘記怎麼做可以先回去複習一下。
目前的程式碼:
export class AppComponent {
formGroup: FormGroup | undefined;
get accountControl(): FormControl {
return this.formGroup!.get('account') as FormControl;
}
get passwordControl(): FormControl {
return this.formGroup!.get('password') as FormControl;
}
constructor(private formBuilder: FormBuilder) {}
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: [
'',
[
Validators.required,
Validators.pattern(/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi)
]
],
password: [
'',
[Validators.required, Validators.minLength(8), Validators.maxLength(16)]
]
});
}
getErrorMessage(formControl: FormControl): string {
let errorMessage = '';
if (!formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.pattern) {
errorMessage = '格式有誤,請重新輸入';
} else if (formControl.errors.minlength) {
errorMessage = '密碼長度最短不得低於8碼';
} else if (formControl.errors.maxlength) {
errorMessage = '密碼長度最長不得超過16碼';
}
return errorMessage;
}
login(): void {
// do login...
}
}
以目前的程式碼來看,基本上我們只要驗 getErrorMessage
這個函式,不過我們其實也能驗 ngOnInit
這個 Angular Component Lifecycle Hook 的執行結果,畢竟它也是個函式,我們一樣可以寫測試去驗證這個函式的執行結果是否符合我們的預期。
關於 Angular Component Lifecycle Hook ,如果想知道更多可以閱讀官方文件: Component Lifecycle hooks
我們一樣先加一個 describe
,表明在這裡面的測試案例都是在測 getErrorMessage
這個函式:
describe('AppComponent', () => {
// ...
describe('getErrorMessage', () => {
// 這裡面的測試案例都是要測這個函式
});
});
接著統整一下這個 getErrorMessage
的函式裡會遇到的情況:
formControl
裡沒有任何 error
,則會取得空字串。formControl
的屬性 pristine
的值為 true
,則會取得空字串。formControl
裡有必填的錯誤: required
,則會取得錯誤訊息 此欄位必填
。formControl
裡有格式的錯誤: pattern
,則會取得錯誤訊息 格式有誤,請重新輸入
。formControl
裡有最小長度的錯誤: minlength
,則會取得錯誤訊息 密碼長度最短不得低於8碼
。formControl
裡有最大長度的錯誤: maxlength
,則會取得錯誤訊息 密碼長度最長不得超過16碼
。統整完之後,就可以將上述情況寫成測試案例:
describe('getErrorMessage', () => {
it('should get empty string when the value is correct', () => {
// Arrange
const formControl = new FormControl('');
const expectedMessage = '';
// Act
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
it('should get empty string when the value is empty string but the form control is pristine', () => {
// Arrange
const formControl = new FormControl('', [Validators.required]);
const expectedMessage = '';
// Act
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
it('should get "此欄位必填" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('', [Validators.required]);
const expectedMessage = '此欄位必填';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
it('should get "格式有誤,請重新輸入" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('whatever', [Validators.pattern('/^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi')]);
const expectedMessage = '格式有誤,請重新輸入';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
it('should get "密碼長度最短不得低於8碼" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('abc', [Validators.minLength(8)]);
const expectedMessage = '密碼長度最短不得低於8碼';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
it('should get "密碼長度最長不得超過16碼" when the value is empty string but the form control', () => {
// Arrange
const formControl = new FormControl('12345678901234567', [Validators.maxLength(16)]);
const expectedMessage = '密碼長度最長不得超過16碼';
// Act
formControl.markAsDirty();
const message = component.getErrorMessage(formControl);
// Assert
expect(message).toBe(expectedMessage);
});
});
從上面的程式碼中可以看出,我這次寫單元測試的策略是:讓每個案例自己配置足以驗證該案例的 formControl
與其必須的 Validators
即可。
也就是說,當我需要驗證 此欄位必填
的錯誤訊息時,我只需要配置 Validators.required
給 formControl
;當我需要驗證 密碼長度最短不得低於8碼
的錯誤訊息時,我只需要配置 Validators.minlength(8)
給 formControl
,依此類推。
會這樣寫是因為我們只需要專注在什麼樣子的 errors
會得到什麼樣子的錯誤訊息上面,當然大家也可以每次都幫 formControl
配置最完整的 Validators
,這兩個方法我覺得都可以。
此外,由於我們這次有判斷 formControl
的狀態: pristine
,因此在寫測試的時候要特別留意,記得要先 markAsDirty
之後才能測試噢!
上一次寫單元測試的文章: 單元測試實作 - 登入系統 by Template Driven Forms。
測試結果:
再來是 ngOnInit
的部份, ngOnInit
要驗證的項目跟 formGroup
滿相關,所以我打算用 formGroup
當測試集合的名稱,具體要驗證的項目有:
ngOnInit
執行之前, formGroup
是 undefined
的狀況。ngOnInit
執行之後,
formGroup
是類型為 FormGroup
的實體。formGroup
裡要有兩個 FormControl
。
accountFormControl
passwordFormControl
程式碼如下:
describe('formGroup', () => {
it('should be undefined before init', () => {
// Assert
expect(component.formGroup).toBeFalsy();
});
describe('after ngInit', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should be instance of FormGroup', () => {
// Assert
expect(component.formGroup).toBeInstanceOf(FormGroup);
});
it('should have 2 form controls', () => {
// Arrange
const formControls = component.formGroup!.controls;
const controlLength = Object.keys(formControls).length;
// Assert
expect(controlLength).toBe(2);
});
describe('accountFormControl', () => {
it('should have the required validator', () => {
// Arrange
const error = component.accountControl.errors!;
// Assert
expect(error.required).toBe(true);
});
it('should have the email pattern validator', () => {
// Arrange
component.accountControl.setValue('abc');
const error = component.accountControl.errors!;
const expectedPattern = '/^\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b$/gi';
// Assert
expect(error.pattern.requiredPattern).toBe(expectedPattern);
});
});
describe('passwordFormControl', () => {
it('should have the required validator', () => {
// Arrange
const error = component.accountControl.errors!;
// Assert
expect(error.required).toBe(true);
});
it('should have the min-length validator', () => {
// Arrange
component.passwordControl.setValue('abc');
const error = component.passwordControl.errors!;
// Assert
expect(error.minlength.requiredLength).toBe(8);
});
it('should have the max-length validator', () => {
// Arrange
component.passwordControl.setValue('12345678901234567');
const error = component.passwordControl.errors!;
// Assert
expect(error.maxlength.requiredLength).toBe(16);
});
});
});
});
此處比較特別的地方是,我在 after ngInit
的 beforeEach
裡是用 fixture.detectChanges()
來觸發 ngOnInit()
,而不是使用 component.ngOnInit()
的方式來觸發,這是因為我認為我們在寫的是 Angular ,而這個 Lifecycle Hook 又是 Angular 的東西,所以使用 Angular 的機制來觸發會比直接使用該函式觸發來的好。
當然也是可以直接使用 component.ngOnInit()
來觸發,在測試的驗證結果上其實不會有什麼不同,所以用哪個方式其實都可以。
測試結果:
已經寫了兩次的測試,相信大家對於測試的熟悉度已經有顯著地提昇,而今天的重點主要會是在使用 FormControl
markAsDirty
來改變欄位的狀態,以及了解 fixture.detectChanges
與 ngOnInit
的關係,未來在寫測試的時候,這兩點也是非常需要多加留意的。
今日的實作程式碼一樣會放在 Github - Branch: day8 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!
那時候同一個欄位是用樣的 formControl 個別去驗
用同樣的?
哈,語句不太順,我剛剛順了一下,應該比較好一些了
可以問一下 假設我那個密碼長度我想變成動態撈取maxlength的值 然後放到 expectedMessage中
如 '密碼長度最長不得超過$0碼'; 那個$0部分該怎麼改寫才能讓她變成動態直接撈取maxlength的值
Hi peter4405,
方法基本上有兩個:
感謝! 我再來試看看