Angular 深入淺出三十天:表單與測試 Day20 - E2E 測試實作 - 登入系統

Day20

經過這兩天的介紹,相信大家對於 Cypress 應該已經有了一定程度的理解,有沒有人已經開始用它來寫測試了呢?

今天就讓我帶著大家用 Cypress 來為我們的表單撰寫 E2E 測試吧!

實作開始

首先先輸入以下命令以啟動 Cypress 的 Test Runner :

1
$ npm run cy:open

或者是

1
$ yarn cy:open

如果你還不知道怎麼安裝 Cypress 或者是為什麼要輸入這個指令的話,請參考第 18 天的文章:與 Cypress 的初次見面(上)

接著就會看到熟悉的小視窗:

Cypress Test Runner Window

準備測試檔

之前在第 18 天的文章有介紹到,這些測試檔是官方產出的範例,如果大家嫌自己刪很麻煩的話,其實這個小視窗有提供一個方法可以一鍵刪除:

Cypress Test Runner Window

按下這個連結之後,它會顯示:

Cypress Test Runner Window

按確定之後,它就會幫你刪光光,非常的方便:

Cypress Test Runner Window

當然如果想留著也無所謂,只是順帶一提這個貼心的小功能。

然後我們就可以按下 + New Spec File 來建立我們的第一個 E2E 測試檔:

Cypress Test Runner Window

由於 E2E 測試是要模擬使用者的行為來操作真實的系統,所以在撰寫測試前,我們先確定要測的系統可以被訪問,待會才能夠使用 Cypress 來訪問它。

當然如果要測的系統有放在網路空間裡最好,沒有的話就在本地端啟動它即可。

第一個 E2E 測試的測試案例

接著我們打開剛建立的測試檔,來寫我們的第一個 E2E 測試的測試案例。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
describe('Login Form', () => {
it('have title "Template Driven Forms 實作 ─ 登入"', () => {
// Arrange
const title = 'Template Driven Forms 實作 ─ 登入';
// Act
cy.visit('http://localhost:4200');
// Assert
cy.get('h1').should('have.text', title);
});
});

執行結果:

Testing Result

雖然大家看我寫得好像很簡單,不過大家在實作時應該會有個疑問:怎麼都沒有 intellisense ?

intellisense 指的是當我們 Coding 時,編輯器會跟我們說有什麼方法可以使用的那個選單,有的人也會叫他 auto-complete 。

其實這是因為少了一句關鍵的語法:

Code Sample

只要大家將這個語法 /// <reference types="cypress" /> 放在檔案開頭,就可以在撰寫測試時有 intellisense 囉!

一開始我也沒注意到它,因為我平常是寫 TypeScript 的版本,所以我去查了一下這是什麼原理,原來這是早期 TypeScript 用來宣告依賴關係的方式,詳細大家可以參考我找到的網路文章:https://willh.gitbook.io/typescript-tutorial/basics/declaration-files#san-xie-xian-zhi-ling

撰寫測試案例

藉由第一個測試案例來驗證環境沒問題後,我們就可以正式來寫需求的測試案例了。

複習並整理一下要驗的案例:

  • 輸入正確格式的帳號與密碼,登入按鈕為 enabled 的狀態
  • 輸入不正確格式的帳號但正確格式的密碼,登入按鈕為 disabled 的狀態
  • 輸入正確格式的帳號但不正確格式的密碼,登入按鈕為 disabled 的狀態

程式碼如下:

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
describe('Login Form', () => {

beforeEach(() => {
cy.visit('http://localhost:4200');
});

it('have title "Template Driven Forms 實作 ─ 登入"', () => {
// Arrange
const title = 'Template Driven Forms 實作 ─ 登入';
// Assert
cy.get('h1').should('have.text', title);
});

context('When typing the correct account and password', () => {
it('should can login', () => {
// Act
cy.get('#account').type('[email protected]');
cy.get('#password').type('12345678');
// Assert
cy.get('button').should('be.enabled');
});
});

context('When typing the incorrect account and the correct password', () => {
it('should can not login', () => {
// Act
cy.get('#account').type('abcdef');
cy.get('#password').type('12345678');
// Assert
cy.get('button').should('be.disabled');
});
});

context('When typing the correct account and the incorrect password', () => {
it('should can not login', () => {
// Act
cy.get('#account').type('[email protected]');
cy.get('#password').type('12345');
// Assert
cy.get('button').should('be.disabled');
});
});
});

執行結果:

Testing Result

大家有沒有覺得寫起來其實跟之前的單元測試與整合測試並沒有什麼太大的差別?

這是因為在撰寫測試的時候,大體上的觀念都是共通且雷同的,只有所使用的語法與 API 不同罷了。

雖然上述測試程式碼只驗了三個情境,但這是因為我覺得其實大多的情境都已經有被整合測試覆蓋到的緣故。

不過在現實情況裡,寫整合測試的人不一定跟寫 E2E 測試的人是同一個,所以就算驗比較完整一點也是很正常的。

E2E 測試小技巧 ─ 自訂 Command

雖說已經寫完測試了,但既然每個測試案例都需要輸入帳號密碼,那我們可以使用自訂 Command 的技巧來重構一下這段程式碼。

首先我們打開在 /support 資料夾底下的 commands.js ,大家應該會看到像這樣被註解起來的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

這些程式碼一樣也是 Cypress 幫我們產生的範例,主要是讓我們知道怎麼做才能自訂 Command

我們可以從中看到,其實有四種方式可以自訂 Command ,不過今天我們只會用到第一種。

想知道其他方式如何使用?請參考官方的 Custom Commands - Examples 文件。

首先取消 login 那一行的註解,並將程式碼改成這樣:

1
2
3
4
Cypress.Commands.add('fillWith', (account, password) => {
cy.get('#account').type(account);
cy.get('#password').type(password);
});

然後我們就能到 login-form.spec.js 裡將測試案例改成這樣:

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
context('When typing the correct account and password', () => {
it('should can login', () => {
// Arrange
const account = '[email protected]';
const password = '12345678';
// Act
cy.fillWith(account, password);
// Assert
cy.get('button').should('be.enabled');
});
});

context('When typing the incorrect account and the correct password', () => {
it('should can not login', () => {
// Arrange
const account = 'abcdef';
const password = '12345678';
// Act
cy.fillWith(account, password);
// Assert
cy.get('button').should('be.disabled');
});
});

context('When typing the correct account and the incorrect password', () => {
it('should can not login', () => {
// Arrange
const account = '[email protected]';
const password = '12345';
// Act
cy.fillWith(account, password);
// Assert
cy.get('button').should('be.disabled');
});
});

這樣看起來是不是更清爽、更好閱讀了呢?

此外,撰寫完測試之後,未來再執行測試時,就不會用同個模式跑,這個模式主要是用來開發測試程式碼用的,未來要再重複執行測試的話,我們一樣可以先在 package.json 裡的 script 區段加上:

1
2
3
{
"cy:run": "cypress run"
}

你也可以取自己喜歡的指令如 "e2e": "cypress run" ,不一定要跟我一樣。

之後就能直接用以下的指令執行 E2E 測試了:

1
$ npm run cy:run

或者是

1
$ yarn cy:run

執行結果:

Testing Result

而且你會發現 Cypress 幫你錄了執行測試時的影片,不妨點開來看看吧!

本日小結

今天的重點主要是在撰寫 E2E 測試的測試案例上與自訂 Command 的部份,不過經歷之前的練習後,大家應該只要稍微熟悉一下就能輕易上手了。

明天我們再來練習用 Cypress 為我們之前寫的動態表單撰寫測試,敬請期待!

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

如果你有任何的問題或是回饋,還請麻煩留言給我讓我知道!

Angular 深入淺出三十天:表單與測試 Day19 - 與 Cypress 的初次見面(下)

Day19

昨天跟大家初步地分享了 Cypress 怎麼安裝、 Cypress 的資料夾結構 、 Cypress Test Runner 有哪些功能、和 Cypress 提供的強大 Dashboard 的服務之後,大家有試著自己玩玩看嗎?

今天我想要分享給大家的是 Cypress 在執行測試時提供的功能。

可能有人會覺得,執行測試就執行測試,哪有什麼功能好分享的?

別急,且聽我娓娓道來。

The Test Runner

Cypress Test Runner Window

昨天介紹到這個 Test Runner 開啟的小視窗,我們可以直接點擊列表中的某個檔案來執行該檔案的測試。

今天我們就來先點擊第一個 todo.spec.js 以執行這個檔案的測試,點擊該檔案之後你會發現, Cypress 用你所指定的瀏覽器打開了一個視窗,接著大家會看到這個畫面:

Cypress Test Runner

Cypress 咻咻咻地就把事情做完了,是不是超快的?!

沒錯,Cypress Test Runner 後來開的這個瀏覽器裡面其實是有玄機的,他不僅僅只是把結果顯示出來而已。

Application Under Test

AUT

Cypress Test Runner 後來開的這個瀏覽器裡其實是一個互動介面,右邊這個區塊是實際受測的頁面,官方將其稱作為 AUT。

這個畫面不僅僅只是顯示而已,它是真實的頁面,所以我們一樣可以打開控制台、檢查元素與查看 CSS 設定。

不過 Cypress 其實是使用 iframe 的方式將其嵌在這個互動介面裡,也因此可能會遇到一些問題,官方有將可能的問題統整在這個文件裡,大家如果有遇到類似的問題可以到這個文件裡查詢。

而如果測試的語法有誤,它也會直接將錯誤訊息顯示在同一個地方:

AUT

網址列

AUT

在 AUT 的上方的網址列顯示的是當然是 AUT 的網址,不過該網址列是不能輸入的,點擊只會直接開新頁面以瀏覽該網頁而已。

Viewport

AUT Viewport

右上方所顯示的則是當前這個 AUT 所使用的視窗大小,單位是 px 。

而大小旁邊的 % 數指的是當前我們所看到的大小是實際受測大小的縮放比例。

如果想知道更多細節的話可以直接點它,它會跟你說要怎麼設置:

AUT Viewport

The Selector Playground

再來要介紹的是一個非常好用的功能,在網址列左方有一顆 icon 是準心的按鈕:

Selector Playground

點下去之後會出現這一列工具列:

Selector Playground

並且整個 AUT 會進入一個像是平常我們滑鼠右鍵檢查元素的模式,只要我們鼠標指到 AUT 任一元素上面,該元素就會變成藍色,並且會有個小小的 tooltip 跟你說這個元素的 CSS Selector 是什麼:

Selector Playground

點擊下去之後你會發現,上面的輸入框會變成你剛剛點擊的元素的 CSS Selector :

Selector Playground

這時我們可以點選右邊的複製按鈕以直接複製 cy.get('[data-test=new-todo]') 這一串文字:

Selector Playground

而複製按鈕旁邊的按鈕叫做 Print to console

Selector Playground

點擊這顆按鈕可以將被選取元素的資訊印到控制台裡面,印出來的資料長得會像是這樣:

Selector Playground

輸入框左邊的 cy.get 其實是可以切換的,點擊它會出現另外一個選項 cy.contains

Selector Playground

cy.contains 的功用是透過直接比對元素內是否有該字串來找尋元素,我們如果直接在輸入框輸入 Walk ,就會看到 Cypress 直接幫我們選取了 Walk the dog 這個代辦事項的元素:

Selector Playground

而輸入框右邊的數字也會讓我們知道該選擇器一共選取到了多少個元素:

Selector Playground

是不是非常地方便?我覺得這個工具會讓我們在撰寫測試案例的時候輕鬆許多,尤其如果你是需要撰寫 E2E 測試的 QA ,這個工具對你的幫助真的非常巨大,再也不用纏著前端工程師問該元素要怎麼選取,直接用這個工具就能輕鬆搞定!

而且這個工具所提供的 CSS Selector 可是有玄機在裡頭的!

Cypress 預設會照著以下順序的優先權來提供 CSS Selector 給我們:

  1. 是否擁有屬性 ─ data-cy
  2. 是否擁有屬性 ─ data-test
  3. 是否擁有屬性 ─ data-testidi 真的是小寫!,不是我打錯噢!)
  4. 是否擁有屬性 ─ id
  5. 是否擁有屬性 ─ class
  6. tag 名稱(該元素的名稱,如 inputdiv
  7. 其他屬性名稱
  8. 偽元素選擇器 ─ nth-child

因此,只要該元素有優先權比較高的選擇器,不用擔心你會得到像是 div > div > div > div > p 之類的 CSS Selector ,只要前端工程師在開發時有加上高優先權選取器,都會有著事半功倍的效果。

講到這裡其實一定會有人擔心:這樣不就要每個元素都加?這樣不就會需要加很多?

其實:

  1. 只要關鍵元素有加,讓寫 E2E 測試的人方便選取並與頁面互動即可。
  2. 如果寫 E2E 測試是必然,那增加方便選取的屬性名稱也是必然的。

謎之聲:不然你來寫 E2E 測試阿?!

不過這個優先權,官方其實是有提供 API 以供我們在有需要的時候加以調整,雖然我覺得並不是很必要,但有需要的人可以參考官方的 Selector Playground API 文件。

而基於這個優先權,官方也提供 Best Practice Guide - Selecting Elements 給大家,希望大家未來在實作時能盡量照這個方式實作。

Command Log

接下來是左半邊的重點 ─ Command Log :

Command Log

同樣地,這邊所顯示的東西也不僅僅只是單純的顯示結果,它一樣是個可以互動的介面。

Command Log

上方藍色的連結是受測檔案的位置,我們可以直接點擊它, Cypress 會幫我們用我們設定的 File Opener 來打開它。

如果想知道怎麼設定 File Opener 的話,我昨天其實有介紹過,大家可以回頭看一下昨天的文章。

Command Log

圖中列表裡,黑字且可點擊收合的項目,是我們在 .spec.js 裡所寫的 describe 或是 context

context 在 cypress 裡的功用與 describe 等同,唯一不同的大概就是在語意上,官方範例中的 context 會在 describe ,但 describe 不會在 context 裡。

而每個前面有勾勾 icon 的項目,則是我們在 .spec.js 裡所寫的每一個 it ,也就是每一個測試案例。

點擊任一個測試案例後,我們可以在它展開的區塊中看到該測試案例的所執行的 Command :

Command Log

而且你會發現,Cypress 還會告訴你這些 Command 是在 beforebeforeEachafterEachafter 這些 Hooks 中執行的,還是在 it 中執行的( TEST BODY 指的就是在 it 中執行的 Command ):

Command Log

此外,當我們的滑鼠游標滑到任一 Command 上,或是點擊任一 Command 時, Cypress 會直接幫我們還原在執行該 Command 時,頁面當前的狀況( DOM Snapshot ):

Command Log

就像坐上了時光機一樣!

Cypress 預設會幫我們每個測試案例都保留 50 筆的 DOM Snapshot 以供我們進行時空旅行

不過一旦測試案例一多,這其實是件很吃記憶體的事情。

因此, Cypress 也有提供設定讓我們可以調整保留的筆數,透過在 cypress.json 或者是 cypress.env.jsonnumTestsKeptInMemory 調小即可。

想調大當然也是沒有問題的!(笑)

還有,大家有沒有發現在滑鼠游標滑過每一個 Command 的時候,每一行 Command 開頭的數字會變成一顆圖釘?

這是因為 Cypress 有提供釘選的功能。

當我們想透過時空旅行去觀看某一個 Command 執行時的狀況,除了將滑鼠游標滑到該 Command 上之外,點擊該 Command 會將當前 AUT 的內容釘選在當下的那個狀況:

Command Log

同時, Cypress 也會幫我們資訊印出來,以供我們使用:

Command Log

如果覺得藍藍的很礙事,我們可以點擊這顆按鈕把它關掉:

Command Log

如果要恢復則再點擊一次即可。

而如果要解除釘選,則可以按旁邊這顆按鈕:

Command Log

也可以透過再次點擊當前釘選的 Command 或者是釘選其他的 Command 來解除對當前 Command 的釘選。

此外,如果釘選到的 Command 是有狀態切換效果的,像是 Checkbox 、 Radio button ,還會有 Before / After 的 Toggle Button :

Command Log

如此一來我們就可以知道在該行 Command 執行完之後,狀態有沒有如我們所預期的改變:

Command Log

Test Status Menu

Test Status Menu

最後,則是左上方的這個 Status Menu。

Test Status Menu

最左邊 < Test 的部分按下去之後,會把一開始的那個小視窗叫出來,這時我們可以再選擇其他的測試檔案來執行。

不過 Cypress 在這個模式下,一次只能執行一個檔案噢!

Test Status Menu

而旁邊這個勾勾跟叉叉應該不用我說大家應該都知道,是指通過測試跟沒通過測試的數量,右邊那個灰色圈圈則是指略過的測試數量。

Test Status Menu

再旁邊的數字則是執行完這個檔案的所有測試所耗費的時間,相信大家也都知道。

Test Status Menu

右邊這個橘色有上下箭頭的 icon ,可以用來切換在執行測試時,如果測試案例已經長到超過螢幕高度,是否要自動 Scroll 的功能:

Test Status Menu

最右邊的則是是重新執行所有測試案例的按鈕,沒什麼特別的:

Test Status Menu

不過,其實還有一個小秘密不知道大家有沒有發現,其實 View All TestDisabled Auto-scrollingRun All Tests 這三個按鈕是有快捷鍵的!

雖然不是什麼大不了的事情,但我覺得 Cypress 在很多小細節都很細心、很貼心,所以 Cypress 這個 E2E 自動化測試工具才會這麼好用!

本日小結

今天的重點主要是想讓大家清楚地知道和了解這個 Cypress Test Runner 提供了些什麼功能,相信之後在後續使用 Cypress 寫 E2E 測試的時候,大家會比較知道怎麼 debug 、或者是比較知道我在做什麼,而且使用上也一定會比較熟悉且順手。

明天我們就要開始正式用 Cypress 寫 E2E 測試囉,敬請期待!

如果有任何的問題或是回饋,還請麻煩留言給我讓我知道,感謝大家!

Angular 深入淺出三十天:表單與測試 Day18 - 與 Cypress 的初次見面(上)

Day18

昨天跟大家分享了 Cypress 有多厲害之後,大家有沒有很期待呢?

這兩天就讓我來跟大家介紹 Cypress 到底有多厲害吧!

由於 Cypress 的功能非常地豐富且強大,所以我打算分成兩篇來介紹它,希望可以讓大家感受到它的魅力。

安裝 Cypress

要開始使用它之前,當然要先安裝它囉!

首先,我們新增一個空的資料夾,然後在終端機中輸入以下指令以進入該資料夾:

1
$ cd /your/project/path

接著輸入以下指令以完成初始化:

1
$ npm init

大家也可以選擇現有的專案,只要有 package.json 這個檔案即可。

然後輸入以下指令以安裝

1
$ npm install cypress --save-dev

安裝完成之後,你會發現你的專案除了 package.json 有變動之外,就沒有其他變動了。

別擔心!這不是因為你做錯了什麼,純粹就是 Cypress 剛安裝完就是這樣,接著我們可以先打開 package.json ,並且在 scripts 的區段加上這個指令:

1
2
3
4
5
{
"scripts": {
"cy:open": "cypress open"
}
}

大家不一定要像我一樣叫 cy:open ,可以自己取自己想要的名字,只要後面的 cypress open 不變即可。

修改好並儲存後,我們就可以在終端機裡以下指令以啟動 Cypress :

1
$ npm run cy:open

或者是

1
$ yarn cy:open

初次執行時,Cypress 會知道你這個專案第一次執行它:

Terminal capture

然後幫你產生出 cypress.json 與名為 cypress 的資料夾,並幫你開啟一個長這樣的小視窗:

Cypress window

這就表示我們順利完成 Cypress 的安裝囉!是不是超簡單的呢?!

資料夾結構介紹

接下來我們先來看看 Cypress 到底幫我們產生了些什麼檔案。

cypress.json

當大家點開它的時候應該會嚇一跳,因為剛開始時,它裡面就只有這樣:

1
{}

第一次使用 Cypress 的朋友應該會多少覺得有點錯愕,不知道這個檔案到底要用來幹嘛。

其實這個檔案是 Cypress 的設置檔,有關 Cypress 全局的配置都會在這裡設定。

那到底有哪些設定可以配置呢?

關於這點大家其實可以看官方的 Configuration 文件,裡面寫得非常清楚,我就不再贅述。

而且裡面的設定非常地多,雖然不一定都會用到,但也由此可見 Cypress 的功能是多麼地強大。

接下來,點開 cypress 資料夾後你會發現裡面還有四個名為 fixturesintegrationpluginssupports 的資料夾。

fixtures

這個資料夾主要是用來放一些在撰寫測試案例時,可能會常用到或共用的資料。例如:固定會輸入的帳密、固定會驗證的使用者資訊等等,並以 JSON 的形式存在。

後續使用時,大多會在 .spec.js 裡用像這樣子的方式直接引用:

1
const requiredExample = require('../../fixtures/example');

integration

這個資料夾是我們用來擺放 .spec.js 的地方。

比較值得一提的是,由於 Cypress 可以平行地執行多個不同的 .spec.js,所以我們在寫測試案例時可以善加利用此點,將不同系統或不會有依賴的測試分成不同的 .spec.js 來撰寫。

不過要反向思考的是,不同的測試檔之間就更不可以有依賴關係了。

plugins

這個資料夾裡有一個 index.js ,當我們需要用到一些外掛模組的時候,就會需要到這裡面來設定,例如我們可能會需要在驗證重設密碼這個功能時,要到信箱裡去確認是否有收到信、點開重設密碼的連結等等。

這點如果真的要仔細介紹起來可能會需要花一到兩篇的篇幅,所以如果大家有興趣的話,可以直接看官網的 Write a Plugin 來學習怎麼樣撰寫與使用 Plugin。

此外,官方也有列出它們精選的 Plugin 供大家參考與使用。

support

在 Cypress 裡,我們都是使用 cy.xxx 的方式來操作 Cypress 提供的 API ,而這些 API 在 Cypress 我們叫做 Command

雖然 Cypress 有提供許多的 Command 讓我們使用,不過我們其實也可以自定我們想要的 Command ,令我們在寫測試時更加地方便與輕鬆。

而這個資料夾就是用來擺放這些我們自定 Command 的地方。

打開資料夾後我們會看到裡面有兩個檔案 ─ index.jscommands.js ,其中的 commands.js 裡就是我們自定 Command 的地方。

index.js 則是用來 import 我們自定 Command 的檔案,執行時 Cypress 會自己從這裡去找到測試案例所需要用到的 Command ,不用特別在測試案例裡 import 。

在檔案命名上,當然也不一定要叫 commands.js ,你可以自己取你想要的檔名,只要記得在 index.js 裡 import 即可。

除了自定 Command 外,其實我們還可以覆寫既有的 Command ,語法大家可以參考官方的 Custom Commands 文件,後續我也會再分享給大家。

介紹完 Cypress 的資料夾結構後,我們回頭來看看 Cypress 打開的小視窗是什麼玩意兒吧!

Cypress Test Runner

Cypress Test Runner Window

這個小視窗其實是 Cypress Test Runner 所開啟的一個小視窗,一般開發時我們會使用 cypress open 的指令來啟動這個 Test Runner 的小視窗以便開發。

現在大家看到畫面中會有許多檔案,而這些檔案其實都是位於 /cypress/integration 之中, Test Runner 啟動時會幫我們把它們抓出來並顯示在上圖的列表中。

當我們想要測試某一個檔案裡的測試案例時,就只要點擊該檔案的名稱即可。

大家可以先點點看、玩玩看,點擊之後會做的事情我會在明天將它們更仔細地介紹給大家。

而這個 Test Runner 的小視窗其實有滿多滿強大的功能,例如在小視窗的右上角有個下拉選單可以選擇想要測試的瀏覽器:

Cypress Test Runner Window

這些瀏覽器是 Cypress 自動從你的作業系統中抓取的,只要你的作業系統有安裝 Cypress 所支援的瀏覽器,它就會成為這個下拉選單的選項。

想知道更多更詳細的資訊可以參考官方的 Launching Browsers 文件。

此外, 小視窗的上方有三個頁籤: TestsRunsSettings ,當前的畫面所顯示的是 Tests 的頁籤,我們點擊 Settings 的頁籤之後會看到以下畫面:

Cypress Test Runner Window

這邊比較重要且常用的會是 Configuration 的區塊,我們一樣點開它之後會看到許多設定:

Cypress Test Runner Window

我覺得這個功能非常地方便,因為這邊所顯示的設定是我們可以在 cypress.json 或者是 cypress.env.json 裡配置的所有設定。

有了這個之後,不用再為了找有哪些設定可以使用去看官網文件,甚至也透過不同顏色來得知當前的配置是來自於哪裡,非常地貼心!

至於 Configuration 後面的區塊, Node.js Version 是可以顯示目前所執行的 node 的版本:

Cypress Test Runner Window

細節可參考官方的 Configuration - Node Version 文件。

Proxy Settings 則是顯示目前的 Proxy 相關設定:

Cypress Test Runner Window

想知道如何設定 Proxy 可參考官方的 Proxy Configuration 文件。

File Opener Preference 則是可以設定你想要用什麼軟體來開啟測試檔(因為在測試中可以透過點擊檔名來開啟該檔案):

Cypress Test Runner Window

詳細請參考官方的 IDE Integration - File Opener Preference 文件。

最後 Experiments 則是顯示目前尚在實驗階段的設定:

Cypress Test Runner Window

詳細請參考官方的 Experiments 文件。

Runs 是做什麼用的呢?

Cypress Test Runner Window

這邊是如果你有使用它們家的 Dashboard 服務的話,可以直接在這邊看到歷史的執行結果。

Dashboard 服務?這又是什麼東西?

Cypress Dashboard

Cypress Dashboard 是 Cypress 它們家所提供的服務,主要是方便我們在使用 Cypress 來執行完自動化的 E2E 的測試後,把結果上傳到這裡來,已供後續觀測與整合第三方工具所用。

這又是一個很強大的工具,如果要介紹它又得花一整篇的文章,不過由於官網其實有非常詳盡的說明與影片,而且大家其實可以自己登入進去玩玩看,連結在這裡:https://dashboard.cypress.io/login

至於這個服務的收費方式,大家可以看這邊:https://dashboard.cypress.io/organizations/48db8376-6414-489b-b988-92233f50e335/pricing

想知道更詳細的資訊可以看官方的 Dashboard Introduction 文件。

不過值得一提的是,其實有個開源的工具叫做 Sorry Cypress ,它可以說是免費版的 Cypress Dashboard ,主要的核心功能如平行執行測試儲存歷史執行結果等都有,不過在介面的設計上比較陽春一些些、最重要的是需要自己架設、自己設定。

我覺得這就像我們自己架設 Jenkins 與使用別人所提供的 CI 工具如 Circle CI 的概念很像,就看我們的需求來決定要選擇哪一種工具囉!

本日小結

今天主要是跟大家介紹 Cypress 的基礎功能面,我想讓大家在對它有個基本的了解之後,接下來我們在使用時會比較知道它在幹嘛、有哪些功能,不會忽然講到一個功能之後要忽然中斷原本的進度來解釋它。

而明天的文章會著重在 Cypress 的 Test Runner 上(今天只有介紹到一小部分的功能),敬請期待!!

如果你有任何的問題或是回饋,還請麻煩留言給我讓我知道,感謝大家!

Angular 深入淺出三十天:表單與測試 Day17 - E2E 自動化測試工具簡介

Day17

在這個各種前端框架、開發工具層出不窮、百花齊放、百鳥齊鳴的美好時代, E2E 自動化測試工具的選擇自然也很多。

今天我們會先來初步了解一下目前有哪些 E2E 自動化測試工具,讓大家在未來需要時,能夠以最短的時間找到最貼近自己需求的工具。

Selenium

Selenium 的 logo

Selenium 是老牌的測試工具,出道已久且頗富盛名的它擁有豐富的 API 與衍生的工具軟體,可使用許多種語言撰寫,如:C#、JavaScript、Java、 Python 、 Ruby 。

主要是藉由 W3C WebDriver 所提供的 API (以前叫 Selenium WebDriver)。

TestCafe

TestCafe 的 logo

TestCafe 的主打是安裝與設置快速,且可以使用相對於 Selenium 來說,較少、較簡潔的程式碼來做到相同的操作。

主要原因是因為他們並不是以 W3C WebDriver 為基底,而是基於 Node.js 之上所開發的。

最強大的地方在於他們支援幾乎是目前市面上所有的瀏覽器, Chrome 跟 Firefox 我就不提了,其他還有 IE 、 Edge 、 Safari 、 Opera ,是至是跨瀏覽器的測試工具平台 BrowserStackLambdaTest

NightWatch

NightWatch 的 logo

NightWatch 也是用 Node.js 所寫的,不過跟 TestCafe 不一樣的是,雖然是用 Node.js 所寫,但其底層還是使用 W3C WebDriver API 來驅動瀏覽器執行操作。

不過它們家也是說它們可以在一分鐘內快速完成設定並啟動服務,有興趣的朋友可以試試看。

不過它們家的貓頭鷹 Logo 很可愛!

Puppeteer

Puppeteer 的 logo

Puppeteer 也是一個基於 Node.js 所開發的 E2E 測試工具,不過他是直接透過 Chrome 的 DevTools Protocol 來操控 Chrome 或 Chromium ,而且它預設會跑在 Headless 的模式下,非常方便。

除此之外,它所主打的功能有:

  • 可以產生出所測試頁面的螢幕擷圖和 PDF
  • 可以抓取 SPA (Single Page Application) 並將其元素都渲染出來

Angular 有個 SSG (Static Site Generation) 的框架 Scully 就是基於這件事情上所做出來的。

  • 自動化表單送出、UI測試、鍵盤輸入等事件
  • 使用最新版本的自動化測試環境、 JavaScript 並直接在最新版本的 Chrome 裡執行測試
  • 提供 Timeline trace 的功能以幫助診斷效能問題
  • 可以用來測試 Chrome 的 Extension

它們還有提供一個線上的 Playground ,大家有興趣可以玩玩看。

WebDriverIO

WebDriverIO 的 logo

WebDriverIO 號稱是下一個世代的 E2E 測試工具,它既可以使用 WebDriver 來達到跨瀏覽器測試的功能,也能像 Puppeteer 那樣使用 Chrome DevTools Protocol ,非常厲害。

Protractor

Protractor 的 logo

Protractor 是為 Angular 量身打造的 E2E 測試工具,而其根本也是使用 WebDriver 來驅動瀏覽器。

身為 Angular 御用的 E2E 測試工具以及 Angular 生態圈的一員,它的方便之處在於新增 Angular 專案時,一定也會連帶地將 Protractor 也給配置妥當。

不過隨著 Angular 征戰多年,Angular 在今年五月於 Angular v12 版本推出時宣布, 在 Angular v12 之後,Protractor 將不會再內建在新專案中,而預計將會在 Angular v15 時(大概是 2022 年尾), Angular 團隊會正式終結 Protractor 。

而目前 Angular 的官方團隊正在積極尋找其他的 E2E 測試框架夥伴,像上面有介紹到的 TestCafe 、 WebDriverIO 與稍後會介紹的 Cypress 都名列其中。

關於 Angular E2E 與 Protractor 的計畫,想要知道詳細情況的朋友可以閱讀官方的 RFC(請求意見稿)

想要知道 Angular v12 更新了什麼,可以參考我的部落格文章

Cypress

Cypress 的 logo

Cypress 有一句非常有趣的標語,叫做:

Test your code, not your patience.

大概是知道大家寫 E2E 測試時都寫的滿痛苦嗎?

此外,它還有一句標語叫做:

A test runner built for humans.

這是因為它們的主張 ─ 開發者友善,不管你是 QA 還是一般工程師都是一樣。

而且它還覺得它有七個地方跟別的 E2E 自動化測試工具不一樣:

  1. 它不使用 Selenium 框架

    因為它認為大多數的 E2E 測試工具都是基於 Selenium 的框架下所運作,這就是為什麼它們都會有相同的問題。

  2. 它專注於非常出色地進行 E2E 測試

    因為它們只想專注地在為 Web Application 撰寫 E2E 測試時,提供做出色的開發者體驗。

  3. 它適用於任何前端框架或是網站

    只要是可以在瀏覽器上跑的網頁它都可以測試。(不過我想應該是要它有支援的瀏覽器才行)。

  4. 它只用 JavaScript 來撰寫

    官方的原意是,因其測試程式碼是在瀏覽器上所執行,所以除了使用 JS 外,不需和任何的語言或是驅動程式綁定。

    不過我覺得這邊有一個隱含的意思,就是只要是可以編譯成 JavaScript 的,它都可以接受,就像是我個人目前是使用 TypeScript 來撰寫它,但其他的語言我就沒試過了。

  5. 它是一個 All-in-one 的框架

    就像 Angular 一樣不需自己去整合各個工具或函式庫,只要安裝 Cypress ,其他的它會幫我們搞定。

  6. 它適用於開發者和 QA

    它們想讓測試驅動開發這件事情變得更加容易,也意即它們的測試將會又好寫、寫得又快。

  7. 它執行地比其他框架要快的多

    官網的原意我覺得跟執行速度比較有關的地方是它可以併行運作並自動負載平衡這件事情。

至於瀏覽器支援度的部份,除了 Chrome 跟 FireFox 之外,也支援 Edge 、 Brave 甚至是 Electron

這麼多 E2E 自動化測試工具,你有比較喜歡哪一個嗎?

除了上述介紹的這七種 E2E 自動化測試工具之外,我相信一定還有其他的 E2E 自動化測試工具是我沒有介紹到的,不過族繁不及備載,如有遺珠之憾還請多加見諒。

本日小結

大家有沒有發現,其實大多數的測試框架都是透過 W3C WebDriver 來進行操作或者是驗證,比較特別一點的則是使用 Chrome 的 DevTools Protocol ,甚至是兩個都可以用。

但在這些 E2E 自動化測試工具裡,最特別的就是 Cypress ,而它其實也是我這次系列文要分享給大家的 E2E 自動化測試工具,後續的 E2E 測試也都將會分享如何使用 Cypress 來撰寫。

明天我更進一步地分享如何使用 Cypress,除了讓大家更進一步地了解這個框架之外,也讓大家如果在閱讀後續文章有任何不懂的地方可以回來複習。

如果你有任何的問題或是回饋,還請麻煩留言給我讓我知道,感謝大家!

Angular 深入淺出三十天:表單與測試 Day16 - Template Driven Forms vs Reactive Forms

Day16

這段期間,我們用 Template Driven FormsReactive Forms 各自做了一個登入表單(靜態)與被保人表單(動態),而且我們也都為這些表單寫了單元測試整合測試,大家應該對於這兩種開發表單的方式有一定的認知與體會。

因此,我們今天來將這兩種開發表單的方式做個小結與比較,順便沉澱一下這段時間的學習成果。

Template Driven Forms vs Reactive Forms

一般我們在對比這兩個開發表單的方式時,會用以下三個面向來分析優劣:

  1. 開發難易度
  2. 維護難易度
  3. 測試難易度

開發難易度

開發難易度指的是,開發者分別使用這兩種開發表單的方式開發同一個表單時的難易程度。

而這段時間,我也讓大家跟著我一起分別使用這兩種開發表單的方式開發了兩個表單,大家可以自己在心裡比較看看。

Template Driven Forms

Template Driven Forms 的方式很接近前端原始寫法,資料的驗證與限制都是使用 HTML 原生的表單機制,只是再額外加上 Angular 的資料綁定機制範本語法來處理,對於剛開始使用框架的初學者較為友善。

就像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
<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)
"
/>

Reactive Forms

Reactive Forms 的方式則是直接用程式來創建與操作表單物件 ─ FormGroup ,然後再把相應的 FormControl 綁定到畫面上的表單欄位。

就像這樣

1
2
3
4
5
const data = {
account: ['', [ /* Validators... */]]
password: ['', [ /* Validators... */]]
};
const formGroup = this.formBuilder.group(data);
1
2
3
4
5
6
7
8
9
10
11
12
<form [formGroup]="formGroup">
<input
type="email"
id="account"
formControlName="account"
/>
<input
type="password"
id="password"
formControlName="password"
/>
</form>

Template Driven Forms 相比,大部分的程式新手會覺得較為抽象,且會有相對來說比較多較困難、較不習慣的觀念要熟悉,學習成本較高

此外,如果有遇到更複雜的連動邏輯、更動態的表單,會比較需要對 RxJS 有更進一步的認知。

開發難易度小結

大部分剛學 Angular 的朋友應該都會覺得 Template Driven Forms 比較簡單,不過雖然一開始做的時候好像很簡單且自然,但表單一複雜起來,那沱 HTML 實在是會有點慘不忍睹。

這還是因為我們這段時間所做的表單其實很陽春、很簡單,如果我們要做的是有著更複雜的連動邏輯、更動態的表單,光想到要處理一堆事情就頭皮發麻、冷汗狂流。

Reactive Forms 初接觸時好像感覺很難,但隨著熟悉度的提升,大家一定會覺得它越來越好用,用它來開發又快又輕鬆,尤其當要做的表單越複雜、越動態時,更能體會它的美好。

我自己一開始使用 Angular 的時候也是只會使用 Template Driven Forms 的開發方式來開發,甚至還實作過頗為複雜的動態表單。直到我學會了使用 Reactive Forms 的開發方式之後,才發現之前做的表單有多麼可怕。

維護難易度

我這邊的維護難易度主要指的是以擴充性重用性這兩種面向來比較這兩種開發方式的難易程度。

Template Driven Forms

擴充性來說,假如我們一起開發過被保人表單需要新增一個產品欄位,並增加年齡與產品之間的互動邏輯時,除了要在 Template 上新增一個產品欄位與其必須的驗證之外,在 Component 裡也需要加上相應欄位有變動時,與其他欄位的互動、提示訊息的邏輯。

重用性來說,假如今天有同樣的表單欄位與驗證邏輯要在別的地方使用,但是畫面可能會長得很不一樣,抑或是只是其他表單裡的其中幾個欄位,這時為了要重用也會需調整不少程式碼。

好一點的情況可能只需要將這些欄位包裝起來並增加 input/output 的邏輯,差一點的情況大概就連重用都很困難,只能盡量把能抽的邏輯抽離,又或者把他們抽成最小最不會有影響的 Component 來使用。

Reactive Forms

擴充性來說,不管是要新增欄位還是調整結構,由於 Reactive Forms 本身就是用程式來建立表單,所以基本上都只需要在程式裡處理好,而 Template 就只是很簡單的增加該增加的欄位、並給予相應的綁定而已,如 formContorlName="xxx" ,輕鬆自在。

重用性來說,這件事在 Reactive Forms 看來更是小菜一碟。本來就是用程式建立表單的它,本身基本就具備非常良好的重用性,就算要把原本的表單抽成最小單位的 FormControl ,也只是像樂高積木一樣,需要的時候再組合起來就好。

維護難易度小結

簡單來說, Template Driven Forms 的開發方式有點像在煮義大利麵,煮完之後就很難去分離,雖然麵依然是麵、醬汁依然是醬汁,但麵已飽富醬汁、醬汁也難以再還原回原本的食材。

Reactive Forms 就像是樂高積木,具有豐富的可變性與卓越的彈性,你想要怎麼組合都可以,就算拼成樂高版的義大利麵,也是說拆就拆、說散就散。

雖然整體來說還是要看個人的功力,但就同一個人用這兩種方法來比較的話,應該還是會有差不多的結果。

測試難易度

測試嚴格來說應該是維護中的一環,因為每當程式碼有調整時,或者是需求有調整時,都有可能會影響到測試。

不過此處特別提出來比較主要是想要只在撰寫測試這件事情上來比較這兩種方式的難易度,尤其是我們這段時間總計寫了八篇的測試,大家應該會比較能感同身受。

Template Driven Forms

Template Driven Forms 在撰寫測試上也因為其方式很接近前端原始寫法的關係,我覺得還算好寫,只要檢查元素的屬性與其值即可。

但由於 Template Driven Forms 比較不可控,且其更新時機是非同步且比較不可預測的關係,造成想要把它的測試寫得很好並不容易。

就拿我們寫過的測試來說,我們在第七天第十三天時,都有著過相同的問題,而這問題,說不定其實是我寫不好,並不是框架本身的問題。

所以說,在某些特定情境下的測試案例,要寫得好其實並不容易。

Reactive Forms

Reactive Forms 的更新時機基本上是同步且可預測的,有什麼變化就可以直接驗證到,畢竟它本身就是用程式來建立表單,可控性很高。

同樣地拿我們寫過的測試來說,相信大家應該都沒有遇到什麼問題,不知道大家是否也覺得它的測試案例相對好寫呢?

測試難易度小結

撰寫測試的難易度其實很大程度地影響了開發人員是否會持續撰寫或維護測試程式的意願。

所以對開發人員來說,當然是越容易越好。

本日小結

今天主要是想明確地讓大家知道 Template Driven FormsReactive Forms 之間的不同與更清楚地對比,讓大家未來在遇到需要製作表單的情境時,可以根據需求來選擇最適合的方式。

下表總結了 Template Driven FormsReactive Forms 的不同之處:

  Reactive Forms Template Driven Forms
表單模型的設置 清楚的,在 Component 裡建立 隱晦的,用 Directive 建立
資料模型 有結構性且不變的 鬆散且容易改變
可預測性 同步的 非同步的
表單驗證方式 用函式驗證 Directive 驗證

雖說這段時間有分享如何使用 Template Driven Forms 的方式來開發表單,不過我個人在遇到要製作表單的情境時,其實都是選擇用 Reactive Forms 的方式來開發,因為實在是真的太好寫了!

此外,我們的後續文章也將不會再分享 Template Driven Forms 的開發方式,而是會用 Reactive Forms 的方式來分享更多更進階的用法,讓大家可以因應更複雜、更動態的情境。

如果你有任何的問題或是回饋,還請麻煩留言給我讓我知道,感謝大家!

Angular 深入淺出三十天:表單與測試 Day15 - 整合測試實作 - 被保人 by Reactive Forms

Day15

昨天幫我們用 Reactive 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
<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

把要測的項目都列出來之後,有沒有覺得要測的項目很多阿?哈哈!

再次跟大家說明,雖然上面這些項目有些其實並不真的屬於整合測試的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼。

大家應該還記得怎麼測吧?忘記的趕快回去看一下之前的文章!

此外,開始之前也別忘記先做以下程式碼所展示的前置作業,後面將不再贅述:

1
2
3
4
5
6
7
8
9
describe('Integration testing', () => {
let compiledComponent: HTMLElement;

beforeEach(() => {
compiledComponent = fixture.nativeElement;
});

// 案例寫在這邊
});

姓名欄位的驗證

複習一下姓名欄位的驗證項目:

  • 屬性 type 的值要是 text
  • 屬性 formControlName 的值要是 name
  • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值只有一個字時,則顯示 `姓名至少

程式碼如下:

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
98
99
100
101
102
103
104
105
106
107
108
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);
});
});
});
});
});

測試結果:

testing result

這段程式碼中有兩個重點:

  1. 為了之後測其他欄位,我多新增了一個 test insured fieldsdescribe 。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 beforeEach 來做。

  2. 切記不要使用 component.addInsured() 來新增被保人。

性別欄位的驗證

性別欄位要驗證的部份非常簡單,項目如下:

    • 屬性 type 的值要是 radio
    • 屬性 value 的值要是 male
    • 屬性 formControlName 的值要是 gender
    • 屬性 type 的值要是 radio
    • 屬性 value 的值要是 male
    • 屬性 formControlName 的值要是 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
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);
});
});
});

測試結果:

testing result

年齡欄位的驗證

年齡欄位要驗證的項目如下:

  • 屬性 formControlName 的值要是 age
  • 當此欄位的狀態是 pristine 時,則不會有錯誤訊息
  • 當此欄位的狀態不是 pristine 且欄位的值為空字串時,則顯示 此欄位必填 的錯誤訊息

程式碼如下:

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
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% 像,複製過來再稍微調整一下即可。

測試結果:

testing result

刪除按鈕的驗證

刪除被保人按鈕要驗證的是:按下按鈕要能觸發函式 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', () => {
// 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);
});
});

測試結果:

testing result

新增被保人按鈕的驗證

新增被保人按鈕要驗證的是:按下按鈕要能觸發函式 addInsured ,跟刪除被保人的按鈕要驗證的項目幾乎是一模一樣,複製過來稍微修改一下即可。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
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();
});
});

測試結果:

testing result

送出按鈕的驗證

最後,送出按鈕要驗證的項目是:

  • 屬性 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
49
50
51
52
53
54
55
56
57
58
59
60
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);
})
});
});

測試結果:

testing result

至此,我們就完成了整合測試的部份囉!

今天所有的測試結果:

testing result

本日小結

今天一樣主要是讓大家練習,提昇撰寫測試的熟悉度,該講的重點應該在之前的文章都有提到。

不過我相信大家應該寫差不多類型的測試寫到有點索然無味了,所以我明天不會讓大家寫測試,而是會總結一下 Template Driven FormsReactive Forms 這兩種開發方式的優缺點,敬請期待。

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

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

Angular 深入淺出三十天:表單與測試 Day14 - 單元測試實作 - 被保人 by Reactive Forms

Day14

今天我們要來為我們用 Reactive Forms 所撰寫的被保人表單寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第十一天的文章: Reactive Forms 實作 - 動態表單初體驗

實作開始

複習一下目前的程式碼:

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
export class ReactiveFormsAsyncInsuredComponent implements OnInit {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 用以取得 FormArray
*
* @readonly
* @type {FormArray}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}

/**
* 綁定在送出按鈕上,判斷表單是不是無效
*
* @readonly
* @type {boolean}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get isFormInvalid(): boolean {
return this.formArray.controls.length === 0 || this.formGroup!.invalid;
}

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

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
}

/**
* 新增被保人
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
addInsured(): void {
const formGroup = this.createInsuredFormGroup();
this.formArray.push(formGroup);
}

/**
* 刪除被保人
*
* @param {number} index
* @memberof ReactiveFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.formArray.removeAt(index);
}

/**
* 送出表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
submit(): void {
// do login...
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
getErrorMessage(key: string, index: number): string {
const formGroup = this.formArray.controls[index];
const formControl = formGroup.get(key);
let errorMessage: string;
if (!formControl || !formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.minlength) {
errorMessage = '姓名至少需兩個字以上';
} else if (formControl.errors.maxlength) {
errorMessage = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 建立被保人的表單
*
* @private
* @return {*} {FormGroup}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
}

以目前的程式碼來看,我們要驗的單元一共有以下這些函式:

  • formArray
  • isFormInvalid
  • ngOnInit
  • addInsured
  • deleteInsured
  • getErrorMessage

以下就按照順序來撰寫測試吧!

開始撰寫測試案例前,記得先處理好依賴,如果忘記的話,可以先回到第六天的文章複習,我就不再贅述囉!

不過今天的測試案例幾乎都建立在 ngOnInit 被觸發後的情況之下,所以這次我打算直接把 fixture.detectChanges() 放在一開始的 beforeEach 裡,這樣就不用在每個測試案例加了。

像這樣:

1
2
3
4
beforeEach(() => {
// 其他省略
fixture.detectChanges();
});

測試單元 - formArray

這個單元很單純,基本只要驗在 ngOnInit 被觸發後,可以取得 formArray 即可。

程式碼如下:

1
2
3
4
5
6
7
8
describe('formArray', () => {
it('should get the FormArray from the FormGroup after "ngOnInit" being trigger', () => {
// Act
const formArray = component.formGroup?.get('insuredList') as FormArray;
// Assert
expect(component.formArray).toBe(formArray);
});
});

測試結果:

testing result

測試單元 - isFormInvalid

這個單元基本上要測三個狀況:

  1. formArray 裡的 controls 的長度為 0 時,回傳 true
  2. formGroup 裡有任何 errors 時,回傳 true
  3. formArray 裡的 controls 的長度不為 0formGroup 裡也沒有任何 errors 時,回傳 false

程式碼如下:

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
describe('isFormInvalid', () => {
it('should be true when there are not any insureds', () => {
// Act
const expectedResult = component.isFormInvalid;
// Assert
expect(expectedResult).toBe(true);
});

it('should be true when there are any errors', () => {
// Arrange
const formControl = new FormControl('', Validators.required);
component.formArray.push(formControl);
// Act
const expectedResult = component.isFormInvalid;
// Assert
expect(expectedResult).toBe(true);
});

it('should be false when there are not any errors', () => {
// Arrange
const formControl = new FormControl('');
component.formArray.push(formControl);
// Act
const expectedResult = component.isFormInvalid;
// Assert
expect(expectedResult).toBe(false);
});
});

測試結果:

testing result

測試單元 - ngOnInit

ngOnInit 要驗證的情況也很簡單,就是看執行完有沒有順利地把 formGroup 建立出來。

不過要驗證到什麼地步就看個人了,例如我們可以很簡單地這樣子驗:

1
2
3
4
5
6
7
8
describe('ngOnInit', () => {
it('should initialize property "formGroup"', () => {
// Act
fixture.detectChanges();
// Assert
expect(component.formGroup).toBeTruthy();
});
});

也可以驗稍微仔細一點:

1
2
3
4
5
6
7
8
describe('ngOnInit', () => {
it('should initialize property "formGroup"', () => {
// Act
fixture.detectChanges();
// Assert
expect(component.formGroup).toBeInstanceOf(FormGroup);
});
});

驗得越粗糙,測試對你的單元保護力越低;反之則越高。所以就看你想要提供給你要測的單元怎麼樣的保護。

測試結果:

testing result

測試單元 - addInsured & deleteInsured

這兩個單元就更沒難度了,一個只是驗證執行後, formArray 的長度有沒有增加;另一個則是減少 formArray 的長度。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('addInsured', () => {
it('should push a "formGroup" into the "formArray"', () => {
// Act
component.addInsured();
// Assert
expect(component.formArray.length).toBe(1);
});
});

describe('deleteInsured', () => {
it('should remove the "formGroup" from the "formArray" by the index', () => {
// Arrange
const index = 0;
const formGroup = new FormGroup({});
component.formArray.push(formGroup);
// Act
component.deleteInsured(index);
// Assert
expect(component.formArray.length).toBe(0);
});
});

測試結果:

testing result

我知道一定有人會有一個疑問:「為什麼測 deleteInsured 的時候, Arrange 的部分不直接用 component.addInsured() 就好,還要自己敲?」。

這是因為我們要做到測試隔離,大家還記得嗎?不記得的趕快回去翻第五天的文章:如何寫出優秀的測試?

大家可以想想,如果今天我們真的使用了 component.addInsured() ,之後哪一天 addInsured 這個函式被改壞了不就也連帶導致了 deleteInsured 這個不相干的測試也會跑失敗嗎?

雖然廣義一點來講,一個跑失敗跟兩個跑失敗貌似沒什麼區別,都是失敗。但在實質意義上來說就差很多,這點務必請大家銘記在心。

測試單元 - getErrorMessage

最後是大家都非常熟悉的 getErrorMessage ,有沒有一種整天都在測這個案例的感覺?

雖然前面都測得比較隨便粗糙,我們這個單元測仔細一點好了。

要驗證的項目如下:

  • 如果用錯誤的 key 值導致找不到對應的 FormControl ,則回傳空字串。
  • 如果該欄位沒有任何錯誤,則回傳空字串。
  • 如果該欄位的 pristinetrue,則回傳空字串。
  • 如果該欄位的有 required 的錯誤,則回傳 此欄位必填
  • 如果該欄位的有 minlength 的錯誤,則回傳 姓名至少需兩個字以上
  • 如果該欄位的有 maxlength 的錯誤,則回傳 姓名至多只能輸入十個字

程式碼如下:

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
describe('getErrorMessage', () => {
let formGroup: FormGroup;

beforeEach(() => {
const nameControl = new FormControl('', [
Validators.required,
Validators.minLength(2),
Validators.maxLength(10)
]);
formGroup = new FormGroup({
name: nameControl,
});
component.formArray.push(formGroup);
});

it('should return empty string with the wrong key', () => {
// Arrange
const key = 'leo'
const index = 0;
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('');
});

it('should return empty string when the "formControl" without errors', () => {
// Arrange
const key = 'name'
const index = 0;
formGroup.get(key)?.setValue('Leo');
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('');
});

it('should return empty string when property "pristine" of the "formControl" is `true`', () => {
// Arrange
const key = 'name'
const index = 0;
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('');
});

it('should return "此欄位必填" when the "formControl" has the required error', () => {
// Arrange
const key = 'name'
const index = 0;
formGroup.get(key)?.markAsDirty();
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('此欄位必填');
});

it('should return "姓名至少需兩個字以上" when the "formControl" has the min-length error', () => {
// Arrange
const key = 'name'
const index = 0;
const formControl = formGroup.get(key)!;
formControl.setValue('A')
formControl.markAsDirty();
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('姓名至少需兩個字以上');
});

it('should return "姓名至多只能輸入十個字" when the "formControl" has the max-length error', () => {
// Arrange
const key = 'name'
const index = 0;
const formControl = formGroup.get(key)!;
formControl.setValue('ABCDEF123456')
formControl.markAsDirty();
// Act
const errorMessage = component.getErrorMessage(key, index);
// Assert
expect(errorMessage).toBe('姓名至多只能輸入十個字');
});
});

測試結果:

testing result

今天所有測試的結果:

testing result

本日小結

跟昨天一樣的是,其實測試手法大致上差不多就這些,當然更複雜的情境會用到其他的手法,但目前主要還是以讓大家多熟悉、多練習為主,後面才會提到更複雜的情況。

我個人覺得,提高撰寫測試的功力不外乎就是練習以及多跟他人交流,所以如果在公司沒人可以幫你 code review 或是你也不會幫其他人 code review 的話,是很可惜的一件事。

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

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

Angular 深入淺出三十天:表單與測試 Day13 - 整合測試實作 - 被保人 by Template Driven Forms

Day13

昨天幫我們用 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 的值綁定到畫面上
  • 新增被保人按鈕
    • 按下按鈕要能觸發函式 addInsured
  • 刪除被保人按鈕
    • 按下按鈕要能觸發函式 deleteInsured
  • 送出按鈕
    • 屬性 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"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'text';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "name-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = key;
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "minlength" and the value is "2"', () => {
// Arrange
const attributeName = 'minlength';
const attributeValue = '2';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "maxlength" and the value is "10"', () => {
// Arrange
const attributeName = 'maxlength';
const attributeValue = '10';
// Assert
expect(nameInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(nameInputElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "name"', () => {
// Arrange
const name = 'whatever';
// Act
component.insuredList[0].name = name;
fixture.detectChanges();
// Assert
expect(nameInputElement.getAttribute('ng-reflect-model')).toBe(name);
});

it('should trigger function "insuredNameChange" when the value be changed', () => {
// Arrange
spyOn(component, 'insuredNameChange');
const nameFormControl = component.nameNgModelRefList.get(0)!.control;
// Act
nameInputElement.value = 'whatever';
nameInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.insuredNameChange).toHaveBeenCalledWith(nameFormControl, component.insuredList[0]);
});
});
});

測試結果:

testing result

這段程式碼中有幾個重點:

  1. 為了之後測其他欄位,我多新增了一個 test insured fieldsdescribe 。這是因為要驗證這些欄位之前,一定要先讓被保人的表單長出來,所我才會多包一層,並把大家都會做的事情拉到這層的 beforeEach 來做。

  2. should have attribute "name" and the value is "name-0" 這個測試案例要記得我們在 Template 綁定時是用 [name] 的方式綁定,所以在驗證的時候是抓 ng-reflect-name ,如果單純抓 name 來驗是會報錯的噢!

  3. 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"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'radio';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "gender-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = 'gender-0';
// 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);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(radioButtonElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "gender"', () => {
// Arrange
const gender = 'male';
// Act
component.insuredList[0].gender = gender;
fixture.detectChanges();
// Assert
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"', () => {
// Arrange
const attributeName = 'type';
const attributeValue = 'radio';
// Assert
expect(radioButtonElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "name" and the value is "gender-0"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = 'gender-0';
// 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);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(radioButtonElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "gender"', () => {
// Arrange
const gender = 'female';
// Act
component.insuredList[0].gender = gender;
fixture.detectChanges();
// Assert
expect(radioButtonElement.getAttribute('ng-reflect-model')).toBe(gender);
});
});
});

這邊的測試雖然簡單,但我還是遇到了一個問題:「怎麼驗雙向綁定裡,關於 ngModelChange 的部份」。

我的預期是我點擊了某個性別的單選鈕之後,它會把值指定給被保人的 gender 欄位。

但我試了好幾種驗法,也查了老半天資料,就是沒辦法成功(攤手),如果有朋友成功驗出來,請麻煩在下方留言分享一下,感謝!

測試結果:

testing result

年齡欄位的驗證

年齡欄位的驗證項目如下:

  • 屬性 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"', () => {
// Arrange
const attributeName = 'ng-reflect-name';
const attributeValue = key;
// Assert
expect(ageSelectElement.getAttribute(attributeName)).toBe(attributeValue);
});

it('should have attribute "required"', () => {
// Arrange
const attributeName = 'required';
// Assert
expect(ageSelectElement.hasAttribute(attributeName)).toBe(true);
});

it('should binding the value of the insured\'s property "age"', () => {
// Arrange
const age = '18';
// Act
component.insuredList[0].age = age;
fixture.detectChanges();
// Assert
expect(ageSelectElement.getAttribute('ng-reflect-model')).toBe(age);
});

it('should trigger function "insuredAgeChange" when the value be changed', () => {
// Arrange
spyOn(component, 'insuredAgeChange');
const ageNgModel = component.ageNgModelRefList.get(0)!;
// Act
ageSelectElement.value = '18';
ageSelectElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.insuredAgeChange).toHaveBeenCalledWith(ageNgModel.value, ageNgModel.errors, component.insuredList[0]);
});
});

年齡欄位的驗證跟姓名的驗證有 87% 像,複製過來再稍微調整一下即可。

測試結果:

testing result

錯誤訊息的驗證

錯誤訊息要驗證的項目是:

  • 要將被保人的屬性 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', () => {
// Arrange
const insured = component.insuredList[0];
const errorMessage = 'account error';
const targetElement = compiledComponent.querySelector('#name-0 + .error-message');
// Act
insured.nameErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

it('should binding the value of the insured\'s property "ageErrorMessage" in the template', () => {
// Arrange
const insured = component.insuredList[0];
const errorMessage = 'password error';
const targetElement = compiledComponent.querySelector('#age-0 + .error-message');
// Act
insured.ageErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});
});

錯誤訊息的驗證也非常簡單,大家應該都能輕鬆驗證!

測試結果:

testing result

刪除按鈕的驗證

刪除被保人按鈕要驗證的是:按下按鈕要能觸發函式 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', () => {
// 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);
});
});

測試結果:

testing result

新增被保人按鈕的驗證

新增被保人按鈕要驗證的是:按下按鈕要能觸發函式 addInsured ,跟刪除被保人的按鈕要驗證的項目幾乎是一模一樣,複製過來稍微修改一下即可。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
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();
});
});

測試結果:

testing result

送出按鈕的驗證

最後,送出按鈕要驗證的項目是:

  • 屬性 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', () => {
// Assert
expect(buttonElement).toBeTruthy();
});

it('should be disabled when "insuredList" is empty array', () => {
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(true);
});

it('should be disabled when there ara any verifying errors that insured\'s data', () => {
// Arrange
component.insuredList = [{
name: 'A',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
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
component.insuredList = [{
name: 'Leo',
gender: 'male',
age: '18',
nameErrorMessage: '',
ageErrorMessage: ''
}];
// Act
fixture.detectChanges();
// Assert
expect(buttonElement.hasAttribute('disabled')).toBe(false);
})
});

測試結果:

testing result

咦?怎麼會有 Error 咧?原來這個問題跟上次我們寫登入表單的整合測試所遇到的情況一樣。

所以我們目前先在這個案例的 it 的前面加上一個 x ,代表我們要 ignore 這個案例的意思,像這樣:

1
2
3
xit('should be disabled when there ara any verifying errors that insured\'s data', () => {
// 省略...
})

測試結果:

testing result

至此,我們就完成了整合測試的部份囉!

今天所有的測試結果:

testing result

本日小結

其實今天用所有用到的測試手法與概念都在之前的的文章就已經分享過了,今天主要是讓大家練習,提昇撰寫測試的熟悉度。

明天我們要為用 Reactive Forms 所撰寫的被保人表單來撰寫單元測試,我覺得大家可以在看我的文章之前先自己寫寫看,之後再參考我的文章,一定會有更多的收穫!

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

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

Angular 深入淺出三十天:表單與測試 Day12 - 單元測試實作 - 被保人 by Template Driven Forms

Day12

今天我們要來為我們用 Template Driven Forms 所撰寫的被保人表單寫單元測試,如果還沒有相關程式碼的朋友,趕快前往閱讀第十天的文章: Template Driven Forms 實作 - 動態表單初體驗

此外,由於許多同樣的事情已在第六天的文章:單元測試實作 - 登入系統 by Template Driven Forms 講過了,例如前置作業的部份,我就不再重複贅述囉!

實作開始

個人習慣要撰寫測試時的第一件事情,就是先把目標類別的依賴都先準備好,例如我們的被保人表單至少會需要 FormsModule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { TestBed } from '@angular/core/testing';
import { TemplateDrivenFormsAsyncInsuredComponent } from './template-driven-forms-async-insured.component';

describe('TemplateDrivenFormsAsyncInsuredComponent', () => {
let component: TemplateDrivenFormsAsyncInsuredComponent;
let fixture: ComponentFixture<TemplateDrivenFormsAsyncInsuredComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TemplateDrivenFormsAsyncInsuredComponent],
imports: [FormsModule]
}).compileComponents();

fixture = TestBed.createComponent(TemplateDrivenFormsAsyncInsuredComponent);
component = fixture.componentInstance;
});

it('should create', () => {
expect(component).toBeTruthy();
});
});

加完之後使用 ng test 的指令將測試程式啟動起來,應該要能通過我們的第一個測試案例 should create

雖然有些人可能會發現,當前這個階段如果不加不會報錯,其實這是因為我們的程式一開始沒有任何表單(空陣列),一旦後續測試時加了之後一定會報錯噢!

再次幫大家複習:單元測試主要是要用來驗證單個類別函式其實際執行結果是否符合我們預期的執行結果。

開始前先打開 .ts 來看一下要寫哪些案例:

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

// 被保險人清單
insuredList: Insured[] = [];

/**
* 根據索引來重新渲染有更改的節點
* 詳情請參考官方文件:https://angular.tw/api/common/NgForOf
*
* @param {string} index
* @return {*} {number}
* @memberof AppComponent
*/
trackByIndex(index: number): number {
return index;
}

/**
* 綁定在姓名欄位上,當使用者改變被保險人的姓名時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} name
* @param {ValidationErrors | null} errors
* @param {Insured} insured
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredNameChange(name: string, errors: ValidationErrors | null, insured: Insured): void {
insured.name = name;
insured.nameErrorMessage = this.getErrorMessage(errors);
}

/**
* 綁定在年齡欄位上,當使用者改變被保險人的年齡時,會觸發此函式,並取得對應的錯誤訊息
*
* @param {string} age
* @param {ValidationErrors | null} errors
* @param {Insured} insured
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
insuredAgeChange(age: string, errors: ValidationErrors | null, insured: Insured): void {
insured.age = age;
insured.ageErrorMessage = this.getErrorMessage(errors);
}

/**
* 新增被保險人
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
addInsured(): void {
const insured: Insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
this.insuredList.push(insured);
}

/**
* 刪除被保險人
*
* @param {number} index
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.insuredList.splice(index, 1);
}

/**
* 根據 FormControl 的 errors 屬性取得相應的錯誤訊息
*
* @private
* @param {ValidationErrors | null} errors - FormControl 的 errors
* @return {*} {string}
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
private getErrorMessage(errors: ValidationErrors | null): string {
let errorMessage = '';
if (errors?.required) {
errorMessage = '此欄位必填';
} else if (errors?.minlength) {
errorMessage = '姓名至少需兩個字以上';
}
return errorMessage;
}
}

以目前的程式碼來看,我們要測的單元有 trackByIndexinsuredNameChangeinsuredAgeChangeaddInsureddeleteInsured 這五個,接下來我們照順序先從 trackByIndex 來寫好了。

測試單元 - trackByIndex

這個測試單元非常簡單,不多說直接看程式碼:

1
2
3
4
5
6
7
8
describe('trackByIndex', () => {
it('should just return the index', () => {
// Arrange
const index = 0;
// Assert
expect(component.trackByIndex(index)).toBe(index);
})
});

測試結果:

testing result

測試單元 - insuredNameChange

接下來要測的單元是 insuredNameChange ,要測的案例有:

  1. 會將傳入的 name 的值賦值給傳入的 insured 裡的 name
  2. 如果傳入的 errorsrequired 欄位,則會將錯誤訊息 此欄位必填 賦值給傳入的 insured 裡的 nameErrorMessage
  3. 如果傳入的 errorsminlength 欄位,則會將錯誤訊息 姓名至少需兩個字以上 賦值傳入的 insured 裡的 nameErrorMessage

程式碼如下:

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
describe('insuredNameChange', () => {
let insured: Insured;

beforeEach(() => {
insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
});

it('should assign the value of the formControl to property "name" of the insured', () => {
// Arrange
const value = 'Leo';
const errors = null;
// Act
component.insuredNameChange(value, errors, insured);
// Assert
expect(insured.name).toBe(value);
});

it('should assign error message "此欄位必填" to property "nameErrorMessage" of the insured when the value of the formControl is empty string', () => {
// Arrange
const value = '';
const errors = { required: true };
const errorMessage = '此欄位必填';
// Act
component.insuredNameChange(value, errors, insured);
// Assert
expect(insured.nameErrorMessage).toBe(errorMessage);
});

it('should assign error message "姓名至少需兩個字以上" to property "nameErrorMessage" of the insured when the value\;s length of the formControl less than 2', () => {
// Arrange
const value = 'L';
const errors = {
minlength: {
actualLength: 1,
requiredLength: 2
}
};
const errorMessage = '姓名至少需兩個字以上';
// Act
component.insuredNameChange(value, errors, insured);
// Assert
expect(insured.nameErrorMessage).toBe(errorMessage);
});
});

這邊的程式碼大家應該都還算熟悉,比較特別需要提醒的是,記得要把初始化這件事寫在 beforeEach 裡,讓每個測試案例在執行之前都能拿到重新初始化過後的值,避免與其他的測試案例共用同個資料或物件。

測試結果:

testing result

測試單元 - insuredAgeChange

下個要測的單元是 insuredAgeChange ,基本上跟 insuredNameChange 相似度高達 87% ,要測試的案例有:

  1. 會將傳入的 age 的值賦值給傳入的 insured 裡的 name
  2. 如果傳入的 errorsrequired 欄位,則會將錯誤訊息 此欄位必填 賦值給傳入的 insured 裡的 nameErrorMessage

程式碼如下:

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
describe('insuredAgeChange', () => {
let insured: Insured;

beforeEach(() => {
insured = {
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
};
});

it('should assign the value of the formControl to property "age" of the insured', () => {
// Arrange
const age = '18';
const errors = null;
// Act
component.insuredAgeChange(age, errors, insured);
// Assert
expect(insured.age).toBe(age);
});

it('should assign error message "此欄位必填" to property "ageErrorMessage" of the insured when the value of the formControl is empty string', () => {
// Arrange
const age = '';
const errors = { required: true };
const errorMessage = '此欄位必填';
// Act
component.insuredAgeChange(age, errors, insured);
// Assert
expect(insured.ageErrorMessage).toBe(errorMessage);
});
});

測試結果:

testing result

測試單元 - addInsured

這個單元的測試也是相當簡單,基本上只要驗證執行後會新增一個被保人表單的資料即可。

程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('addInsured', () => {
it('should add a new insured data into property "insuredList" after being triggered', () => {
// Arrange
const expectResult: Insured[] = [{
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
// Act
component.addInsured();
// Assert
expect(component.insuredList).toEqual(expectResult);
});
});

測試結果:

testing result

雖然很間單,但大家有沒有注意到,在這我裡我不是用 toBe 而是用 toEqual 來驗證?

toBe 的比較一般會用在原始型別的對比上,但如果今天要對比的是物件就要改成用 toEqual 來驗證了。

如果不知道為什麼的朋友,可能要先複習一下 JS 的核心概念囉!

參考文件:MDN 官方文件 - 理解相等比較模型

測試結果:

testing result

測試單元 - deleteInsured

最後一個單元也非常簡單,基本上只要驗證能將被保人的資料從 insuredList 中刪除即可。

程式碼如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('deleteInsured', () => {
it('should delete the insured data by the index after being triggered', () => {
// Arrange
component.insuredList = [{
name: '',
gender: '',
age: '',
nameErrorMessage: '',
ageErrorMessage: ''
}];
// Act
component.deleteInsured(0);
// Assert
expect(component.insuredList).toEqual([]);
});
});

測試結果:

testing result

今天所有的測試結果:

testing result

本日小結

不知道大家有沒有覺得今天的單元測試很簡單,甚至是有點無聊了呢?

與一開始還沒接觸時相比,是不是覺得其實寫單元測試也沒花多少時間,而且更可以保證程式碼的品質呢?

沒錯,這一切都會隨著熟練度的提升而變得愈加容易!

今天的學習重點主要是:

  1. 清楚 toBetoEqual 的差別。
  2. 測試隔離
  3. 練習寫測試

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

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

Angular 深入淺出三十天:表單與測試 Day11 - Reactive Forms 實作 - 動態表單初體驗

Day11

今天要來用 Reactive Forms 的方式再來實作一次昨天的表單。

具體的規格需求跟昨天差不多,如下所示:

  • 被保險人的欄位:
    • 姓名(文字輸入框)
      • 最少需要填寫兩個字,如驗證有誤則顯示錯誤訊息姓名至少需兩個字以上
      • 最多只能填寫十個字,如驗證有誤則顯示錯誤訊息姓名最多只能十個字
    • 性別(單選)
      • 選項:男性、女性
    • 年齡(下拉選單)
      • 選項: 18 歲、 20 歲、 70 歲、 75 歲
  • 以上欄位皆為必填,如驗證有誤則顯示錯誤訊息此欄位為必填
  • 以上驗證皆需在使用者輸入時動態檢查
  • 按下新增被保險人按鈕可以新增被保險人
  • 按下刪除被保險人按鈕可以刪除被保險人
  • 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態

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

實作開始

首先我們一樣先準備好基本的 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
<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input type="text" id="name" formControlName="name" />
<span class="error-message">{{ getErrorMessage("name") }}</span>
</p>
<p>
性別:
<input type="radio" id="male" value="male" formControlName="gender" />
<label for="male"></label>
<input type="radio" id="female" value="female" formControlName="gender" />
<label for="female"></label>
</p>
<p>
<label for="age">年齡:</label>
<select id="age" 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") }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

未經美化的畫面跟昨天長得一樣:

Template view

接著跟昨天一樣先把它當成靜態表單來準備相關的屬性與方法:

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
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
selector: 'app-template-driven-forms-async-insured',
templateUrl: './template-driven-forms-async-insured.component.html',
styleUrls: ['./template-driven-forms-async-insured.component.scss']
})
export class TemplateDrivenFormsAsyncInsuredComponent {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

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

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
getErrorMessage(key: string): string {
const formControl = this.formGroup?.get(key);
let errorMessage: string;
if (!formControl || !formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.minlength) {
errorMessage = '姓名至少需兩個字以上';
} else if (formControl.errors.maxlength) {
errorMessage = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 綁定在表單上,當按下送出按鈕時會觸發此函式
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
submit(): void {
// do submit...
}
}

準備好相關的屬性和方法之後,我們直接把他們跟 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
<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input
type="text"
id="name"
formControlName="name"
/>
<span class="error-message">{{ getErrorMessage('name') }}</span>
</p>
<p>
性別:
<input type="radio" id="male" value="male" formControlName="gender">
<label for="male"></label>
<input type="radio" id="female" value="female" formControlName="gender">
<label for="female"></label>
</p>
<p>
<label for="age">年齡:</label>
<select id="age" 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') }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

目前為止,大體上跟我們上次的實作差不多,應該沒有什麼難度。

不過這次綁定 FormControl 的方式,我改成用 formControlName="name" ,而不是上次的 [formControl]="nameControl" ,大家可以自行選用喜歡的方式。

如果大家在這邊有遇到問題,可以檢查看看自己有沒有引入 FormsModuleReactiveFormsModule ,我就不再贅述囉。

目前的結果:

result

有了基本的互動效果之後,我們就可以開始來思考怎麼樣把這個表單變成動態的。

跟昨天一樣的是,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來表達這些被保人的資料,也就是說,我們現在的 FormGroup 要從 1 個變成 N 個。

之前曾經提到,我們如果從資料面來看, {} 代表表單,也就是 FormGroup'' 代表表單裡的子欄位,也就是 FormControl ;那 [] 呢?

答案是 ─ FormArray

不過 FormArray 不能直接跟 form 元素綁定,唯一可以跟 form 元素綁定的只有 FormGroup ,所以 FormArray 一定要在 FormGroup 裡面,就像這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});

這邊要注意的是, FormArray 一定要透過 FormBuilder 或是 FormArray 的建構式來建立,像上面示範的那樣,或是這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: new FormArray([])
});

絕對不能偷懶寫成這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: []
});

這樣的話,就會變成普通的 FormControl 囉!切記切記!

接著我們就可以將原本的程式碼修改成用陣列的方式,並把新增被保人、刪除被保人與判斷表單是否有效的函式都補上:

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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@Component({
// 省略...
})
export class AppComponent implements OnInit {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 用以取得 FormArray
*
* @readonly
* @type {FormArray}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}

/**
* 綁定在送出按鈕上,判斷表單是不是無效
*
* @readonly
* @type {boolean}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get isFormInvalid(): boolean {
return this.formArray.controls.length === 0 || this.formGroup!.invalid;
}

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

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
}

/**
* 新增被保人
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
addInsured(): void {
const formGroup = this.createInsuredFormGroup();
this.formArray.push(formGroup);
}

/**
* 刪除被保人
*
* @param {number} index
* @memberof ReactiveFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.formArray.controls.splice(index, 1);
this.formArray.updateValueAndValidity();
}

/**
* 送出表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
submit(): void {
// do login...
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
getErrorMessage(key: string, index: number): string {
const formGroup = this.formArray.controls[index];
const formControl = formGroup.get(key);
let errorMessage: string;
if (!formControl || !formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.minlength) {
errorMessage = '姓名至少需兩個字以上';
} else if (formControl.errors.maxlength) {
errorMessage = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 建立被保人的表單
*
* @private
* @return {*} {FormGroup}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
}

接著我們到 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
<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">{{ 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">{{ 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>

初次看到這種綁定方式的 Angular 初學者可能會傻眼,不過靜下心來看之後你會發現,其實這只是我們所建立的 FormGroup 裡的階層關係,這樣綁定 Angular 才能從一層層的表單之中開始往下找。

如果我們把其他的 HTML 都拿掉的話其實會清楚很多:

1
2
3
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<!-- 其他省略 -->
</form>

最外層的這個大家應該都知道,就是我們在 .ts 裡的 formGroup

1
2
3
4
5
6
7
8
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<!-- 其他省略 -->
</ng-container>
</form>

而這裡呢,就像我們寫靜態表單的時候,會從 FormGroup 裡根據對應的 key 值找到對應的 FormControl 一樣,這裡則是把對應的 FormArray 找出來。

然後再用 *ngFor 的方式,把 FormArray 底下的 AbstractControl 都迴圈出來。

關於 AbstractControl ,它其實是一個抽象類別,而 FormGroupFormArrayFormControl 這三種類型其實都繼承於這個類別,所以大家不知道有沒有注意到,一般我們在 .ts 裡使用的時候,我們會特別用 as FormControl 或是 as FormArray 的方式來讓編譯器知道現在取得的物件實體是什麼型別,以便後續使用。

想知道更多 AbstractControl 的資訊的話,請參考官方 API 文件: https://angular.io/api/forms/AbstractControl

1
2
3
4
5
6
7
8
9
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
</fieldset>
</ng-container>
</form>

最後再用索引值 index 找出對應的 FormGroup

而要做這件事情其實要有相對應的階層關係的 HTML 來幫忙,但因為我的 HTML 的階層關係少一層,所以我才會用 ng-container 多做一層階層,好讓我的表單可以順利綁上去。

如果今天你做的 HTML 的階層數是足夠的,就可以不用用 ng-container 多做一層階層,例如把上面的 HTML 改成這樣其實也可以:

1
2
3
4
5
6
7
8
9
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<div
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
</fieldset>
</div>
</form>

不過用 ng-container 的好處是這個元素並不會真的出現在畫面上,大家可以視情況斟酌使用。

改完之後就大功告成囉!來看看最後的結果:

result

本日小結

今天的學習重點主要是在圍繞在 FormArray 上,因為多了這個階層的關係,所以在與 Template 的綁定上看起來會較為複雜一點點。

話雖如此,大家可以拿今天的 template 與昨天的 template 互相比較一下,除了 forid 這兩個屬性因為天生侷限的關係真的沒辦法之外,但 name 的部份就不用再去處理了,還是很方便的。

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

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

Your browser is out-of-date!

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

×