Angular 深入淺出三十天:表單與測試 Day04 - 開始撰寫測試之前必須要知道的二三事

Day4

在開始撰寫測試之前,先帶大家來了解一下 Angular 預設使用的測試框架 ─ Karma

Karma 的 logo

Karma 的原名是 Testacular , Google 在 2012 年的時候將其開源, 2013 年時將其改名為 Karma ,它是基於 JasmineSelenium 所開發出來的 JavaScript 測試執行過程管理工具(Test Runner)。

一般我們會使用它來撰寫單元測試整合測試,測試的檔案名稱通常會命名為 xxx.spec.ts ,而只要是使用 Angular CLI 所建立的檔案,在預設的情況下都會連帶產生該檔案,像是: xxx.component.spec.tsxxx.service.spec.ts

當我們想要執行測試程式時,只要使用指令 npm test or yarn test or ng test ,就可以看到它的執行結果:

Karma's log

Karma's view

當 Karma 執行起來後,只要我們不停掉它的 server 且不關掉它的視窗,只要我們有修改我們的測試並存檔後,它就會偵測到我們的變動並再重新跑一次測試,是個很方便且強大的功能。

關於執行測試時的更多參數,請參考Angular 官方 API 文件

想了解更多的話,可參考網路文章:JavaScript 測試工具之 Karma-Jasmine 的安裝和使用詳解Karma 官方文件

測試的檔案內容

上述提到,在 Angular 裡的測試檔案一般我們會將其命名為 xxx.spec.ts ,而檔案內容大致上會長這樣:

Testing Sample

或是這樣:

Testing Sample

從中我們可以發現,它是一種巢狀式的結構,外層會是一個名字叫 describe 的函式,內層則有許多名為 it 的函式,這些函式各是什麼意思呢?

it

it 指的是 測試案例(Test case),通常會在 describe 函式的裡面,使用方式如下所示:

1
2
3
it('說明文字', () => {
// test content
});

第一個參數是該測試案例的說明文字,讓我們在閱讀時可以很清楚、直接地知道這個測試案例會有什麼結果,通常建議以 should 做開頭,整體閱讀起來較為順暢,例如:

1
2
3
it('should be created', () => {
// test content
});

或者像是:

1
2
3
it('should have as title "Angular"', () => {
// test content
});

第二個參數是一個函式,裡面就是該測試案例所要執行的程式碼,也就是我們實際上要測試的內容。

describe

describe 指的是 測試集合(Test suite),主要是用於將測試案例分組、分類,類似資料夾的概念,這樣我們在閱讀程式碼的時候與其測試結果時,才會比較好閱讀

使用方式如下所示:

1
2
3
describe('說明文字', () => {
// test cases
});

it 一樣,第一個參數是該測試集合的說明文字,讓我們在閱讀時可以很清楚、直接地知道這個測試集合的主要測試目標,例如:

1
2
3
4
5
6
7
8
9
10
11
12
describe('LoginComponent', () => {
describe('Component logic', () => {
describe('login', () => {
// test cases
});
});
describe('Template logic', () => {
describe('When login button be clicked', () => {
// test cases
});
});
});

第二個參數是一個函式,裡面是該測試集合所要執行的測試案例

describe 除了分類、分組的功能外,他還有一個很重要的特性 ─ 作用域(Scoping)

作用域(Scoping)

在寫測試案例的時候,我們可能會遇到某些情況是在需要事先做一些配置,又或者是驗證完之後需要把某些狀態還原,如果將這些事情寫在每一個 it 裡又覺得很囉嗦且不好維護,這時候我們就會使用以下這些函式來幫我們:

  • beforeAll ─ 在執行所有的測試案例之前,會先執行這裡面的程式碼。
  • beforeEach ─ 在執行每一個測試案例之前,會先執行這裡面的程式碼。
  • afterAll ─ 在執行完所有的測試案例之後,會再執行這裡面的程式碼。
  • afterEach ─ 在執行完每一個測試案例之後,會再執行這裡面的程式碼。

舉個例子,如果我們有個測試集合長這樣:

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('Test Suite', () => {
beforeAll(() => {
console.log('beforeAll');
});

beforeEach(() => {
console.log('beforeEach');
});

it('test case - 1', () => {
console.log('test case - 1');
});

it('test case - 2', () => {
console.log('test case - 2');
});

afterEach(() => {
console.log('afterEach');
});

afterAll(() => {
console.log('afterAll');
});
});

它的執行結果會是這樣:

1
2
3
4
5
6
7
8
// beforeAll
// beforeEach
// test case - 1
// afterEach
// beforeEach
// test case - 2
// afterEach
// afterAll

從上述結果中可以看出,在一個測試集合裡會先執行的是 beforeAll 裡的程式,接著會是 beforeEach ,然後才會是測試案例;而在測試案例之後,則會先執行 afterEach 才會輪到下一個測試案例之前的 beforeEach,再接著下一個測試案例,之後一樣會是那個測試案例之後的 afterEach 。直到最後沒有測試案例時,就執行 afterAll 裡面的程式,結束這個測試集合。

有比較理解了嗎?如果有的話,我們來試試比較複雜一點的巢狀結構:

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
describe('Test Suite - 1', () => {
beforeAll(() => {
console.log('beforeAll - 1');
});

beforeEach(() => {
console.log('beforeEach - 1');
});

it('test case - 1', () => {
console.log('test case - 1');
});

it('test case - 2', () => {
console.log('test case - 2');
});

describe('Test Suite - 2', () => {
beforeAll(() => {
console.log('beforeAll - 2');
});

beforeEach(() => {
console.log('beforeEach - 2');
});

it('test case - 3', () => {
console.log('test case - 3');
});

it('test case - 4', () => {
console.log('test case - 4');
});

afterEach(() => {
console.log('afterEach - 2');
});

afterAll(() => {
console.log('afterAll - 2');
});
});

afterEach(() => {
console.log('afterEach - 1');
});

afterAll(() => {
console.log('afterAll - 1');
});
});

它的執行結果會是這樣:

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
// beforeAll - 1

// beforeEach - 1
// test case - 1
// afterEach - 1

// beforeEach - 1
// test case - 2
// afterEach - 1

// beforeAll - 2
// beforeEach - 1
// beforeEach - 2
// test case - 3
// afterEach - 2
// afterEach - 1

// beforeEach - 1
// beforeEach - 2
// test case - 4
// afterEach - 2
// afterEach - 1
// afterAll - 2

// afterAll - 1

為讓大家比較好閱讀,我將每個測試案例稍微隔開方便大家觀察其中規律。

雖然這個例子比較複雜,但邏輯上來說跟上一個例子一樣:在開始測試某測試集合裡面的測試案例之前,會先執行該測試集合的 beforeAll ,接著是每一個測試案例的 beforeEach ,然後執行測試案例,執行完測試案例後就是 afterEach

比較特別需要注意的就是當要開始執行 test case - 3 之前,會先執行的是 Test Suite - 2beforeAll 。原因就像上面提過的:「在開始測試某測試集合裡面的測試案例之前,會先執行該測試集合的 beforeAll 」, test case - 3Test Suite - 2 裡面的測試案例,所以在開始測試 test case - 3 之前,自然會先執行該測試集合裡的 beforeAll ,接著是父層測試集合裡的 beforeEach ,才會輪到 Test Suite - 2 裡面的 beforeEach

這個概念在大多數的前端測試框架裡是差不多的,學一次基本適用在大多數的測試框架裡, CP 值非常之高。

雖然上述的測試執行過程看似有序,但實際上我們不能依賴這種有序,原因跟如何撰寫出優秀的測試有關,不過相信今天的內容應該已經夠燒腦了,所以明天再跟大家分享如何撰寫出優秀的測試吧!

本日小結

今天的文章內容主要是要讓大家在開始撰寫測試之前,先對 Angular 的測試框架、測試檔案的內容結構有個初步的理解,如此一來有兩個好處:

  1. 後續不用再解釋,文章內容可以比較精簡
  2. 有需要時可以回來複習

此外,今天的重點主要是以下三點:

  1. 認識 Angular 預設所使用的測試框架。
  2. 了解測試檔案的內容結構。
  3. 理解作用域(Scoping) 的邏輯。

尤其是關於作用域(Scoping) 的部份,這在後續撰寫測試時,會非常常使用,所以如果有任何的問題或是回饋,請務必留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day03 - Reactive Forms 實作 - 以登入為例

Day3

今天要來用 Reactive Forms 的方式實作一個簡單的登入系統,撇開 UI 不談,具體的功能需求規格跟昨天差不多,如下所示:

  • 帳號
    • 格式為 Email Address,相關規則請參考維基百科,此處則直接使用正規表示法 /^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi 來檢驗,驗證有誤時需在欄位後方顯示錯誤訊息:格式有誤,請重新輸入
    • 此欄位必填,驗證有誤時需需在欄位後方顯示錯誤訊息:此欄位必填
  • 密碼
    • 長度最短不得低於 8 碼,驗證有誤時需需在欄位後方顯示錯誤訊息:密碼長度最短不得低於8碼
    • 長度最長不得超過 16碼,驗證有誤時需需在欄位後方顯示錯誤訊息:密碼長度最長不得超過16碼
    • 此欄位必填,驗證有誤時需需在欄位後方顯示錯誤訊息:此欄位必填
  • 以上驗證皆需在使用者輸入時動態檢查
  • 任一驗證有誤時,登入按鈕皆呈現不可被點選之狀態。

規格需求看清楚之後,我們就來開始實作吧!

實作時大家可以自己開一個專案來練習,抑或是用 Stackblitz 開一個 Angular 的專案來練習,我就不再贅述囉!

如果正在閱讀此篇文章的你還不知道要怎麼開始一個 Angular 專案的話,請先閱讀我的 Angular 深入淺出三十天後再來閱讀此系列文章會比較恰當噢!

實作開始

首先我們先準備好基本的 HTML :

1
2
3
4
5
6
7
8
9
10
11
12
13
<form>
<p>
<label for="account">帳號:</label>
<input type="email" id="account">
</p>
<p>
<label for="password">密碼:</label>
<input type="password" id="password">
</p>
<p>
<button type="submit">登入</button>
</p>
</form>

未經美化的畫面應該會長這樣:

Template view

接著到 app.module.ts 裡 import FormsModuleReactiveFormsModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
imports: [
BrowserModule,
FormsModule,
ReactiveFormsModule
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule { }

然後將要綁在 Template 的屬性跟方法都準備好:

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
export class LoginComponent implements OnInit {

// 綁定在表單上
formGroup: FormGroup;

/**
* 用以取得帳號欄位的表單控制項
*/
get accountControl(): FormControl {
return this.formGroup.get('account') as FormControl;
}

/**
* 用以取得密碼欄位的表單控制項
*/
get passwordControl(): FormControl {
return this.formGroup.get('password') as FormControl;
}

/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*/
constructor(private formBuilder: FormBuilder) {}

/**
* 當 Component 初始化的時候初始化表單
*/
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)
]
]
});
}

// 綁定在表單上,當使用者按下登入按鈕時會觸發此函式
login(): void {
// do login...
}

/**
* 透過該欄位的表單控制項來取得該欄位的錯誤訊息
*
* @param {FormControl} formControl 欲取得錯誤訊息的欄位的表單控制項 (by Angular)
*/
getErrorMessage(formControl: FormControl): string {
let errorMessage: string;
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;
}

}

就可以將這些屬性和方法跟 Template 綁定在一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<form [formGroup]="formGroup" (ngSubmit)="login()">
<p>
<label for="account">帳號:</label>
<input
type="email"
id="account"
[formControl]="accountControl"
/>
<span class="error-message">{{ getErrorMessage(accountControl) }}</span>
</p>
<p>
<label for="password">密碼:</label>
<input
type="password"
id="password"
[formControl]="passwordControl"
/>
<span class="error-message">{{ getErrorMessage(passwordControl) }}</span>
</p>
<p>
<button type="submit" [disabled]="formGroup.invalid">登入</button>
</p>
</form>

到目前為止的程式碼你看懂了多少呢?對於剛接觸 Angular 的表單的朋友來說,今天的資訊量可能會比較大,容我稍微說明一下:

Reactive Forms 的概念是將表單程式的方式產生。以這個需求來說,這個表單底下會有兩個欄位 accountpassword ,如果將其用 JSON 來表示的話,應該會長這樣:

1
2
3
4
{
"account": "",
"password": ""
}

從資料面來看, {} 代表表單, "account": """password": "" 則是裡面的兩個欄位。

而再將其轉換成 Reactive Forms 的概念的話, {} 代表的是 FormGroup"account": """password": "" 則代表的是 FormControl

所以在程式碼中我們可以看到我們宣告 formGroup: FromGroup; 並且在 template 中將其綁定在表單上:

1
2
3
<form [formGroup]="formGroup">
<!-- ... -->
</form>

並且把表單控制項綁定在對應的 input 欄位上:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 帳號欄位 -->
<input
type="email"
id="account"
[formControl]="accountControl"
/>

<!-- 密碼欄位 -->
<input
type="password"
id="password"
[formControl]="passwordControl"
/>

然後在 ngOnInit 裡透過 FormBuilder 來初始化表單:

1
2
3
4
5
6
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: '我是該欄位的初始值',
password: '我是該欄位的初始值'
});
}

如此一來,就可以在初始化過後,跟我們的 template 正確綁定了。

而如果當該欄位需要驗證時,就要在初始化時將格式調整成:

1
2
3
4
5
6
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: ['我是該欄位的初始值', /* 驗證器的擺放位置 */],
password: ['我是該欄位的初始值', /* 驗證器的擺放位置 */],
});
}

如果只有一個要驗證的項目則可以直接放入:

1
2
3
4
5
6
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
account: ['我是該欄位的初始值', Validators.required],
password: ['我是該欄位的初始值', Validators.required],
});
}

如果有多個要驗證的項目,就用 [] 將多個驗證項包起來再放入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)
]
],
});
}

在這裡我們可以發現,上一篇使用 Template Driven Forms 實作時,是用 HTML 原生的屬性來驗證,而今天使用 Reactive Forms 實作時,則是用程式來驗證,如此一來,可以降低表單與 template 之間的依賴性,使得其更易於維護、重用與測試。

Validators 是 Angular 幫我們製作的驗證器,裡面有很多常用驗證器,詳細請參考官方文件

當然我們也可以自己客製驗證器,只要符合 ValidatorFn 的類型即可

關於錯誤訊息基本上可以沿用上一篇的程式,只不過原本是傳入 FormControlerrors 來判斷,但現在是傳入整個 FormControl ,為什麼呢?

因為如果只有傳入 FormControlerrors 的話,你會發現表單初始化完之後,就會有錯誤訊息顯示在畫面上:

img

這是因為當我們的表單初始化完之後,驗證器就會開始運作,所以的確那個兩個欄位是有那個錯誤沒錯,但其實這不是我們想要的行為,因為使用者根本就還沒有開始填表單,我們想要的是當使用者開始填表單之後,才會顯示對應的錯誤訊息,所以我們改傳入整個 FormControl ,它其中有幾個很好用的屬性可以使用:

  • pristine ─ 如果此屬性為 true ,代表該欄位是乾淨,沒有被輸入過值;反之則代表有被輸入過值,與 dirty 成反比。
  • touched ─ 如果此屬性為 true,代表該欄位曾經被碰(該欄位曾經被使用滑鼠 focus 過);反之則代表該欄位完全沒被碰過。
  • dirty ─ 如果此屬性為 true ,代表該欄位曾經被輸入過值,已經髒掉了;反之則代表該欄位是乾淨,沒有被輸入過值,與 pristine 成反比。

想知道更多可以參考官方文件: FormControl 與其抽象類別 AbstractControl

所以我們只要加上當該欄位是乾淨的,就不回傳錯誤訊息的判斷就可以了,像是這樣:

1
2
3
4
5
6
7
getErrorMessage(formControl: FormControl): string {
let errorMessage: string;
if (!formControl.errors || formControl.pristine) {
errorMessage = '';
}
// 其他省略...
}

最終結果:

complete gif

本日小結

對於第一次接觸 Reactive Forms 的朋友們,今天的資訊量會比較多,但重點大致上可歸納成以下四點:

  1. 學習如何將表單程式的方式寫出來,心法:「資料即表單,表單即資料」
  2. 學習如何使用表單物件 FormBuilderFormGroupFormControl
  3. 學習如何使用 Validators 來驗證使用者所輸入的值。
  4. 學習如何將表單物件與 Template 綁定。

此外,千萬記得要 import FormsModuleReactiveFormsModule 才可以使用噢!

我一樣會將今日的實作程式碼放在 Stackblitz 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

Angular 深入淺出三十天:表單與測試 Day02 - Template Driven Forms 實作 - 以登入為例

Day2

今天要來用 Template Driven Forms 的方式實作一個簡單的登入系統,撇開 UI 不談,具體的功能需求規格如下:

  • 帳號
    • 格式為 Email Address,相關規則請參考維基百科,此處則直接使用正規表示法 /^\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b$/gi 來檢驗,驗證有誤時需在欄位後方顯示錯誤訊息:格式有誤,請重新輸入
    • 此欄位必填,驗證有誤時需需在欄位後方顯示錯誤訊息:此欄位必填
  • 密碼
    • 長度最短不得低於 8 碼,驗證有誤時需需在欄位後方顯示錯誤訊息:密碼長度最短不得低於8碼
    • 長度最長不得超過 16碼
    • 此欄位必填,驗證有誤時需需在欄位後方顯示錯誤訊息:此欄位必填
  • 以上驗證皆需在使用者輸入時動態檢查
  • 任一驗證有誤時,登入按鈕皆呈現不可被點選之狀態。

規格需求看清楚之後,我們就來開始實作吧!

實作時大家可以自己開一個專案來練習,抑或是用 Stackblitz 開一個 Angular 的專案來練習,我就不再贅述囉!

如果正在閱讀此篇文章的你還不知道要怎麼開始一個 Angular 專案的話,請先閱讀我的 Angular 深入淺出三十天後再來閱讀此系列文章會比較恰當噢!

實作開始

首先我們先準備好基本的 HTML :

1
2
3
4
5
6
7
8
9
10
11
12
13
<form>
<p>
<label for="account">帳號:</label>
<input type="email" name="account" id="account">
</p>
<p>
<label for="password">密碼:</label>
<input type="password" name="password" id="password">
</p>
<p>
<button type="submit">登入</button>
</p>
</form>

未經美化的畫面應該會長這樣:

Template view

接著到 app.module.ts 裡 import FormsModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
imports: [
BrowserModule,
FormsModule
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule { }

然後將要綁在 Template 的屬性跟方法都準備好:

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
export class AppComponent {

// 綁定在帳號欄位上
account = '';

// 綁定在密碼欄位上
password = '';

// 帳號欄位的錯誤訊息
accountErrorMessage = '';

// 密碼欄位的錯誤訊息
passwordErrorMessage = '';

/**
* 綁定在帳號欄位上,當使用者改變登入帳號時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} account
* @param {ValidationErrors} errors
*/
accountValueChange(account: string, errors: ValidationErrors): void {
this.account = account;
this.validationCheck(errors, 'account');
}


/**
* 綁定在密碼欄位上,當使用者改變密碼時會觸發此函式
*
* @param {string} password
* @param {ValidationErrors} errors
*/
passwordValueChange(password: string, errors: ValidationErrors): void {
this.password = password;
this.validationCheck(errors, 'password');
}

// 綁定在表單上,當使用者按下登入按鈕時會觸發此函式
login(): void {
// do login...
}

/**
* 透過欄位裡的 ValidationErrors 來設定該欄位的錯誤訊息
*
* @param {ValidationErrors} errors 欲驗證的欄位的錯誤 (by Angular)
* @param {'account' | 'password'} fieldName 欄位名稱
*/
private validationCheck(
errors: ValidationErrors,
fieldName: 'account' | 'password'
): void {
let errorMessage: string;
if (!errors) {
errorMessage = '';
} else if (errors.required) {
errorMessage = '此欄位必填';
} else if (errors.pattern) {
errorMessage = '格式有誤,請重新輸入';
} else if (errors.minlength) {
errorMessage = '密碼長度最短不得低於8碼';
}
this.setErrorMessage(fieldName, errorMessage);
}

/**
* 設定指定欄位的錯誤訊息
*
* @param {'account' | 'password'} fieldName 欲設定錯誤訊息的欄位名稱
* @param {string} errorMessage 欲設定的錯誤訊息
*/
private setErrorMessage(
fieldName: 'account' | 'password',
errorMessage: string
): void {
if (fieldName === 'account') {
this.accountErrorMessage = errorMessage;
} else {
this.passwordErrorMessage = errorMessage;
}
}

}

就可以將這些屬性和方法跟 Template 綁定在一起:

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
<form #form="ngForm" (ngSubmit)="login()">
<p>
<label for="account">帳號:</label>
<input
type="email"
name="account"
id="account"
required
pattern="\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b"
#accountNgModel="ngModel"
[ngModel]="account"
(ngModelChange)="accountValueChange(accountNgModel.value, accountNgModel.errors)"
/>
<span class="error-message">{{ accountErrorMessage }}</span>
</p>
<p>
<label for="password">密碼:</label>
<input
type="password"
name="password"
id="password"
required
#passwordNgModel="ngModel"
[minlength]="8"
[maxlength]="16"
[ngModel]="password"
(ngModelChange)="passwordValueChange(passwordNgModel.value, passwordNgModel.errors)"
/>
<span class="error-message">{{ passwordErrorMessage }}</span>
</p>
<p>
<button type="submit" [disabled]="form.invalid">登入</button>
</p>
</form>

到目前為止的程式碼你看懂了多少呢?容我稍微說明一下:

  1. 首先是關於必填檢核,只要 <input ...> 欄位裡加上 HTML 原生的屬性 ─ required 即可。

  2. 帳號欄位的格式檢查則是使用原生的屬性 ─ pattern ,這個屬性可以直接使用正規表示法的方式來檢查使用者所輸入的值是否符合我們所訂定的格式。不過要注意的是,頭尾不需要特別加上 /^$/ ,所以整串表示法只需要用到中間的部份 ─ \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b

對這個屬性較不熟悉的朋友可以參照 MDN 的說明文件

  1. 字數長度的檢核也是使用原生的屬性 ─ minlengthmaxlength 。這部份有兩個地方需要特別留意:

    1. 字數長度的檢核不會管你的字元是半型還是全型、是英文還是中文,每個字元都是一樣以一個長度來計算,如果有特別需求就不能使用這個方式處理。
    2. HTML 的原生屬性 ─ maxlength 是會阻擋使用者輸入的,當需求是要檢核長度但不能阻擋使用者輸入的話,就不能使用這個方式。
  2. 很多人剛學會用 Angular 的朋友,在使用 ngModel 時都會忘記這兩件事情:

    1. 引入 FormsModule
    2. input 要有 name 屬性
  3. 使用範本語法 #accountNgModel="ngModel"#passwordNgModel="ngModel" 來取得型別為 NgModel 的物件,因為我們可以從中取得該欄位的 valueerrors ,前者指定給其相關屬性,後者用以判斷該欄位的錯誤,以設定相對應的錯誤訊息。

單純使用 #accountNgModel#accountNgModel="ngModel" 的差別在於前者取得的是單純的 HTMLInputElement 物件。

  1. 使用範本語法 #form="ngForm" 來取得型別為 NgForm 的表單物件。

單純使用 #form#form="ngForm" 的差別在於前者取得的是單純的 HTMLFormElement 物件。

  1. 最後,則是將登入按鈕加上 [disabled]="form.invalid" 的綁定,讓按鈕在表單無效時,無法按下登入按鈕。

至此,我們就完成今天的目標囉!是不是很簡單呢?!

最後的結果應該要像這樣:

complete gif

本日小結

剛開始學習 Angular 的朋友,通常都會因為不熟悉 Angular 的語法而導致明明很簡單的功能卻要弄得很複雜。

今天的學習重點主要有以下三點:

  1. 學習如何使用 Angular 的範本語法取得 Angular 已經包裝好的物件,例如 #accountNgModel="ngModel"#form="ngForm"
  2. 學習使用表單物件 NgModelNgForm
  3. 善用 NgModel 裡的 ValidationErrors 取得相應的錯誤訊息。

我會將今日的實作程式碼放在 Stackblitz 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

錯誤回報更新

  • 2021-09-19 22:54:50 ,感謝熱心讀者「程凱大」指正錯誤,已移除所有的 FormControl ,原因是因為在 Template Driven Forms 的範圍裡, NgModel 本身已有我們所需之屬性,是我自己豬頭捨近求遠,再次衷心感謝。

Angular 深入淺出三十天:表單與測試 Day01 - 前言

First Day

何謂表單?

維基百科是這樣說的:

  • 表單是一種帶有空格可用於書寫以及能選擇和勾選相似內容的文件。

  • 表單可用於填寫聲明、請求、命令、支票、稅單。

  • 網頁表單可以將使用者輸入的資料,傳送到伺服器進行處理。因為網際網路的使用者會操作核取方塊、無線電鈕或文字欄位來填寫網頁上的表格,所以網頁表單的形式類似檔案或資料庫。

  • 網頁表單可應用線上上的電子商務:以信用卡訂購產品、要求貨運,或可用於檢索資料(例如,搜尋引擎上搜尋)。

維基百科的網頁表單示意圖

以上文字與圖片皆來自於維基百科(表單網頁表單)的搜尋結果。

自古以來,表單一直都是各大商業系統裡舉足輕重的一部分,舉凡會員註冊、商品寄送、稅務申請、市場調查等,各式各樣大大小小的表單連結你我的生活,其中更甚者,光以表單這個服務就是足以讓一間公司賴以為生。

Angular 的表單製作方法

在 Angular 裡,製作表單的方法主要分成以下兩種方式:

  • Template Driven Forms
  • Reactive Forms

Template Driven Forms

Template Driven Forms 的方式很接近前端原始寫法,資料的驗證與限制都是使用 HTML 原生的表單機制,只是再額外加上 Angular 的資料綁定機制範本語法來處理,只要知道有 HTML 與 Angular 的基礎即可使用,淺顯易懂、學習成本低,對前端新手來說較為簡單且熟悉。

但隨著表單的複雜度增加,後續維護難度也會越來越高,且不易於測試重用性較差,所以一般用於較為簡單的情境。

Reactive Forms

Reactive Forms 的方式是直接用資料來創建與操作表單,較為抽象且需要對 RxJS 有基本的認知,有很多相對程式新手來說較困難、較不習慣的觀念要熟悉,學習成本較高

但因其擴充性重用性可測試性都很好,且就算表單的複雜度增加,後續維護的難易度也不會多高,如果表單功能是你的應用程式裡很關鍵、重要的一塊,抑或是需要處理較為複雜、動態處理的表單,那麼我相當推薦使用此方式。

何謂測試?

測試,是軟體工程裡很重要的一環,簡單地說就是用程式來驗證程式/系統/軟體的實際執行結果是否與預期執行結果相符。

試想,我們平常在開發功能時,如何知道我們所寫出來的程式其運作結果符合我們心中所想、符合功能需求抑或者使用者需求?

我猜想,大部分開發者的流程應該是這樣:

  1. 開發
  2. 測試
  3. 除錯
  4. 不斷重複 1 ~ 3 直到完成開發

這樣的開發流程當然沒什麼太大的問題,不過俗話說得好:「人非聖賢,孰能無過。」,我們自己在測試時,非常容易就會因為各種無心、有心的關係,漏掉一些測試的案例;又或者跟別人開發時,我們不能保證別人都跟我們一樣在寫完程式之後都會乖乖測試,所以常常會造成改 A 壞 B ,甚至會有不符合需求的情況。

那如果我們將測試的步驟交給電腦來幫我們做會怎麼樣?

我的程式啟蒙老師說過一句話:「電腦很聽話,你讓它往東它不會往西,如果程式碼有錯就一定是你的錯,不會是電腦的錯」,所以如果把測試這件事情讓電腦來做,你有幾個案例它就會測幾個案例,你要它測幾遍他就測幾遍,而且執行起來的速度比我們手動還要快太多太多,一旦有錯馬上就會知道,如此一來,就不會發生改 A 壞 B 的情況,使我們的程式碼品質變得更好。

關於前端的測試類型,我個人認為主要有以下三種:

  • 單元測試 (Unit Testing)
  • 整合測試 (Integration Testing)
  • E2E 測試 (End-to-end Testing)

單元測試 (Unit Testing)

測試單個類別的 function 其執行結果是否符合我們的預期。

整合測試 (Integration Testing)

測試兩個以上的類別實體之間的互動是否符合我們的預期。

E2E 測試 (End-to-end Testing)

模擬使用者的操作行為來測試一個完整的 story ,驗證其是否符合我們的預期。

想知道更多細節的話,可以參考保哥的文章:一次搞懂單元測試、整合測試、端對端測試之間的差異

關於本系列文

我有幸參與過一些系統的製作,因此對於如何在 Angular 製作表單還算小有心得,雖然官網已經有相關的文件,網路上的資源更是多不勝數,但系統性地學習表單製作的文章與課程相對較少,再加上一般程式初學者對於 「測試」 這件事情普遍都只是 「聽說」 ,可能連看都沒看過,更不用提實作了。正好我也在這個部分小有研究,也慣於撰寫測試甚至樂於推廣,所以我會在這個系列文裡把這兩個單元結合,希望可以幫到大家。

需要注意的是,本系列文不適合完全不會 Angular 的朋友,如果是您是初學者、抑或是想學習 Angular 的朋友,可以先看看我的 Angular 深入淺出三十天,直到對於 Angular 的資料綁訂機制與範本語法有相當程度地了解之後,再來閱讀此系列文會比較好。

本系列文將從 Angular 裡最簡單的表單製作方式開始分享,章節安排具體會分成三個階段:

第一個階段

此階段我會個別使用 Template Driven FormsReactive Forms 來分別實作靜態與動態的兩種表單,並且為這兩種表單撰寫單元測試與整合測試,最後再從幾個面向來比較這兩種開發表單的方式的優缺。

這個階段會有較大量的實作,主要是為希望大家能透過實作來了解並深刻體會這兩種表單開發方式的優缺,同時也能了解並熟悉撰寫測試的方式,篇幅大約是整個鐵人賽的一半左右。

第二個階段

此階段我會介紹一些 E2E 自動化測試工具,並著重在於 Cypress 這個自動化測試工具上,並且使用它來為我們在第一個階段所撰寫的表單來撰寫 E2E 測試。

經過了前面的大量實作,這階段的篇幅不會太長,因為很多測試觀念上的分享在第一個階段就已經分享給大家,這階段主要是讓大家了解並熟悉 Cypress 與 E2E 測試的開發。

第三個階段

最後一個階段主要會分享一些進階的 Reactive Forms 表單開發技巧、進階的測試撰寫技巧、測試驅動開發的技巧等較為進階的分享,希望對大家能夠有更進一步的幫助。

友情連結

這次我是跟朋友一起組隊參賽的,透過團隊的壓力更能督促我自己一定要順利完賽(壓力山大),如果以下這些文章對你有幫助,也希望大家可以花點時間閱讀:

特別友情貴賓 VVVVVIP 熱情強力推薦:

Angular v12 來了!!

YEAH!!!

今天一早準備工作時,看到令人興奮的好消息!那就是 ─ Angular v12 已經正式 Release 了!!

官方文章在此:Angular v12 is now available

幫看到英文就倒退三百步的朋友總結一下幾個重點:

Ivy Everywhere

為了更靠近 Ivy Everywhere 這個目標, Angular 棄用了舊的 Compiler - View Engine ,不過這對於一般開發者來說沒什麼影響,主要是如果你是身為 Library 的作者且是使用 View Engine 來 Compile 的話,可以開始著手準備使用 Ivy 來 Compile 了。

為此,官方有提供實作細節

Protractor 的未來

關於 Angular 生態圈所標配的 E2E 測試框架 ─ Protractor 的未來,目前官方決定將從 v12 開始不再預設包含 Protractor ,而是與其他夥伴合作(目前官方已收到的夥伴回音有 CypressTestCafeWebdriverIO 。如果你們公司也有在做這塊且有興趣,可以跟官方團隊聯繫),並透過 Angular CLI 的 schematic/builder 與 Angular 緊密結合。

官方堅信,透過與其他夥伴的合作以及擁抱社群的力量,可以使 Angular 變得更加強大!對於細節有興趣的朋友可以閱讀官方的RFC(請求意見稿)

Nullish 操作符

從 v12 開始,開發者們可以在 Angular 的 Template 裡使用 Nullish 操作符 ─ ??(沒錯,就是很香很甜的語法糖)了!

透過使用 Nullish 操作符,可以讓你的 code 看起來更加 Clean (雖然每個人、每個團隊對於 Clean Code 的定義都不一樣,但官方是這麼認為,我也是)。

舉個例子:

1
{{ age !== null && age !== undefined ? age : calculateAge() }}

使用 Nullish 操作符後,你的 code 像這樣:

1
{{ age ?? calculateAge() }}

謎之聲:耶!令人舒爽的程式碼!

樣式相關的改進

關於樣式方面,從 v12 開始,只要在 angular.json 裡加上 inlineStyleLanguage: "scss" 的設定,就可以在 Component 的 styles 裡寫 SCSS 了,雖然關於這點我其實覺得還好,因為我個人不喜歡將 Template 或是樣式直接寫在 *.component.ts 裡。

不過令人興奮的是, Angular CDKAngular Material 現在內部已經改使用 Sass 新的 Module System 了!與此同時, Angular CDKAngular Material 所外露的 API 介面也已經改採用 @use 語法,官方文件 也已經同步更新(尷尬,破版了XDD)。

不過如果你的應用程式是建立在 Angular v12 以下,但有使用到新版的 Angular CDK 或是 Angular Material 的話,可以使用 ng update 的指令讓 Angular CLI 幫你自動更新所有使用於 Angular CDKAngular Material@import 聲明(Before and After),並記得將你的 NPM 套件從 node-sass 改為 sassnode-sass 已經沒有持續更新且維護了(如果你是使用 Angular v12 則不用做此行為,內建已經是 sass 了)。

編譯模式預設為 Production 模式

v12 之後, ng build 指令將會預設為 Production 模式,意即開發者們不用再加上 --prod 來強調要編譯 Production 模式的檔案,減少多餘的步驟並防止將開發模式的程式碼誤植到 Production 上的意外發生。

預設開啟嚴格模式

v12 之後,將預設開啟 TypeScript 的嚴格模式,嚴格模式會幫助開發者們儘早發現錯誤。

關於嚴格模式可以閱讀 Angular 官方文件Angular 官方部落格的文章

Angular Language Service 預設為 Ivy-based

v12 之後,將原先 Angular Language Service 可以選擇性使用 Ivy-based 改為預設使用。

官方還有提供了一部影片 是關於Angular Language Service with Ivy 的,有興趣的朋友也可以看看。

使用 Webpack 5

從 v11 開始, Angular 其實就有實驗性地在支援 Webpack 5 ;但從 v12 開始,就正式支援啦!!!(灑花)

TypeScript v4.2

v12 預設所採用的 TypeScript 是 v4.2 ,關於 v4.2 的細節可以閱讀官方的文章

棄用對 IE 11 的支援

從 v12 開始,官方將棄用對於 IE 11 的支援,並預計將在 v13 時正式將其移除。關於此決定可以觀看 官方的 RFC(請求意見稿)

其他更新

其實其他還有一些滿重要的更新與調整,不過上述幾點是我個人擷取出來,覺得對一般開發者來說比較重要且有感的更新,所以關於其他的更新,可以直接閱讀官方的文章,抑或是直接看官方的 CHANGELOG


以上,就是今天李歐想要分享給大家的資訊,感謝您的收看!

如果您喜歡我的文章,歡迎按讚、訂閱以及分享,我們下次見囉,拜拜~!(好 Youtuber 式的結尾XD)

Angular 之 Injector 探討

關於 Angular 的 Injector ,雖然我之前在寫「Angular 深入淺出三十天」的系列文時,就有在基礎結構說明(四)這篇文章裡介紹過,不過今天我在跟朋友們聊到相關的話題時,除了好好的做了個實驗,並把程式碼保留下來之外,也忽然起了個念頭,覺得是該把這個問題寫成文章,讓剛學 Angular 或是對 Angular 的了解沒那麼深的朋友們可以比較容易理解、比較不會用錯(就我個人的經驗來看,沒有注意到這個部分的朋友們還滿多的)。


問題描述

在 Angular 裡寫 Service 的時候,我們一般會用以下三種方式註冊它:

  1. 在該 Service 的 @Injectable() 裡加上 { providedIn: 'root' } 的 Metadata ( Angular 7+ 推薦寫法),抑或者在 AppModule 的 providers 裡註冊它( Angular 6- 的寫法)。
  2. 在相關的功能模組的 providers 裡註冊它。
  3. 在某個 Component 的 providers 裡註冊它。

其實在 Angular 9 之後的版本,除了 providedIn: 'root' 之外,還多了 'platform''any' 這兩個選擇,不過此篇文章不討論這個部分。

這三種註冊方式其中的第一種與第三種,大家比較不會有問題,比較會有問題的是第二種,很多人可能會覺得,我使用起來明明就是同一個實體呀?怎麼官方說是不同實體呢?

實驗開始

為了釐清這個問題,我做了個實驗,程式碼在這裡:https://stackblitz.com/edit/angular-ivy-5fdo5y?file=src/app/app.component.html

在這個實驗裡,我用了四個 Module ─ AboutModuleContactModuleHomeModuleWhateverModule 與兩個 Service ─ ASserviceBService 來模擬大部分會遇到的情況:

  • AService 使用 providedIn: 'root' 註冊。
  • HomeModuleContactModuleWhateverModule 都 import 到 AppModule 裡。
  • AboutModuleLazyLoading 的方式載入。
  • BService 則分別註冊在 ContactModuleHomeModuleAboutModuleproviders 裡與 WhateverComponentproviders 裡。

結果:

實驗結果

有看懂嗎? BServiceHomeComponentContactComponet 裡是用同一個實體,這是因為他們都有被 import 到 AppModule 裡,這時 AppModule(上層 Module)會接手 ContactModuleHomeModule(子層 Module)的 injector,所以他們雖然有分別註冊 BService ,但實際上還是共用了同一個實體。

這部份其實官網文件有寫到:

官網文件

網址在這:https://angular.io/guide/hierarchical-dependency-injection#moduleinjector

不過我這裡只有實驗一層的情況,如果 Module 一層一層的接下去,是不是也是一樣的狀況我就沒有特別做實驗了,大家可以自己玩看看。

至於 AboutComponentWhateverComponent 呢,很明顯都是使用不同的
BService 的實體,前者因為使用 LazyLoading 的關係,跟 AppModule 沒有直接關聯,後者則是因為是使用了第三種註冊方式的關係。

結論

總而言之,官方之所以提供了不同的註冊方式給我們,就是希望我們可以在不同的情況下使用不同的註冊方式來解決我們的問題,只要能夠熟知這其中的差異,就可以寫出好維護又高品質的系統囉!

我的 Nx 筆記 - tsconfig.json 的坑

我最近在研究 Nx 時遇到了一個坑,這個坑讓我卡了兩、三個小時,所以我將這件事情筆記下來,希望如果以後有人遇到類似的問題,這篇筆記多少可以幫得上忙。


問題描述

在 Angular 專案裡我們經常會看到各種 tsconfig.json ,而這些 tsconfig.json 又繼承來繼承去的,雖然一般我們不會特別去動他們的設定,但當我們因為某些緣故需要去調整的時候,就要特別小心「覆蓋」的問題。

而我遇到的狀況是,我在使用 JSZip 這個套件時,遇到了下圖中的 Error :

JSZip 的 Error

解決辦法是在 apps/app-name/tsconfig.app.jsoncompilerOptions 裡加上 path 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
{
"//": "...",
"compilerOptions": {
"//": "...",
"paths": {
"jszip": [
"node_modules/jszip/dist/jszip.min.js"
]
}
}
"//": "..."
}

加上之後, JSZip 的問題解決了,但變成下圖中的 Error :

找不到 libs 裡的 Module

我百思不得其解,因為 ng serve 沒有問題,但 build 的時候就會說找不到,而我也試了很多方式都解不掉這個問題。

後來我想到,會不會是因為設定會被覆蓋的關係?!

因為原本 libs 裡的 import 路徑會在專案根目錄的 tsconfig.json 裡設定,而 tsconfig.json 們會像下圖這樣繼承配置:

tsconfig.json 的繼承方式示意圖

  1. apps/app-name/tsconfig.json 繼承 ./tsconfig.json 裡的配置 。
  2. apps/app-name/tsconfig.app.json 再繼承 apps/app-name/tsconfig.json 裡的配置。

因此,當我們一開始在 apps/app-name/tsconfig.app.json 加上因應 JSZippaths 配置時,就覆蓋掉原本在 ./tsconfig.json 裡的 paths 配置,造成找不到 libs 裡 export 的 module 的問題。

解決辦法

解決這個問題的辦法基本上有兩個:

  1. apps/app-name/tsconfig.app.jsonpaths 補上原本在 ./tsconfig.jsonpaths 配置。
  2. JSZip 的配置從 apps/app-name/tsconfig.app.json 移到 ./tsconfig.json 裡。

相對來說,第二個方式絕對會比第一個方式好得多。

結論

一般來說,其他的 tsconfig.json 都不太會需要去動,因此只要我們養成習慣,都只在修改在專案根目錄下的 tsconfig.json 的配置就不會有問題了。

參考連結

我的 Nx 筆記 - 如何在你的 Application 裡,用很漂亮的方式引入 Library 的 SCSS

Nx

最近在開始研究 Nx 這個非常好用的開發工具,當然也撞了不少牆…(鼻青臉腫中)。

今天主要要分享的是:

如何在你的 Application 裡,用很漂亮的方式引入 Library 裡的 SCSS

而這個方法是我在這個 issue 裡看到的,留言的人是 tcoz

在開始之前,請先準備 Nx 的專案,且裡面有一個 Application 名為 sample 、 一個 Library 名為 sample-lib ,然後在 sample-lib 裡準備一個 abc.scss

scss 的內容很簡單:

1
2
3
h1 {
color: red;
}

接著把 sample 跑起來,應該會看到以下畫面:

sample 的起始畫面

然後先在 Nx 的 angular.json 裡,找到你的專案的 build 配置,大概長這樣:

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
{
"//": "...",
"projects": {
"sample": {
"//": "...",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/sample",
"index": "apps/sample/src/index.html",
"main": "apps/sample/src/main.ts",
"polyfills": "apps/sample/src/polyfills.ts",
"tsConfig": "apps/sample/tsconfig.app.json",
"aot": false,
"assets": ["apps/sample/src/favicon.ico", "apps/sample/src/assets"],
"styles": ["apps/sample/src/styles.scss"],
"scripts": []
},
},
"//": "...",
}
}
},
"//": "...",
}

然後加上 stylePreprocessorOptionsextractCss 的配置,像這樣:

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
{
"//": "...",
"projects": {
"sample": {
"//": "...",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"//": "...",

"stylePreprocessorOptions": {
"includePaths": ["libs/sample-lib/src/lib/scss"]
},
"extractCss": true,

"//": "...",
},
},
"//": "...",
}
}
},
"//": "...",
}

includePaths 裡的路徑就是要處理的 SCSS 的資料夾路徑。

如此一來,就可以到 sample 裡的 SCSS 裡 import (我在 sample 裡的 style.scss import ):

1
@import "abc";

結果:

import 了 abc 之後的結果

參考資料

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×