Angular 深入淺出三十天:表單與測試 Day07 - 整合測試實作 - 登入系統 by Template Driven Forms

Day7

昨天幫我們用 Template Driven Forms 所撰寫的登入系統寫完單元測試之後,今天則是要來為它寫整合測試。

大家還記得整合測試的目標是要測什麼嗎?我幫大家複習一下:

整合測試的測試目標是要測試兩個或是兩個以上的類別之間的互動是否符合我們的預期。

再更直接一點地說,整合測試就是在測互動關係,其他的事情我們都不在乎,因為其他的事情基本上都會在單元測試的部份裡測。

這時候可能會有人覺得奇怪,我們現在就只有一個 Component ,並沒有符合「兩個或是兩個以上的類別」,這樣是要怎麼測試?

沒錯,雖然我們現在並沒有「兩個或是兩個以上的類別」,但是前端比較不一樣的地方是前端會有畫面,使用者實際上是看著畫面來跟我們的程式互動的。

用我們用做的登入系統來說,雖然很簡單、很陽春,但如果沒有畫面、沒有那些輸入欄位,使用者也沒辦法使用。

所以今天寫整合測試的目的就是要來來驗證我們所做的登入系統的畫面,有沒有如我們所預期地和我們的程式碼互動

實作開始

首先我們先增加一個 describe 的區塊,有關於整合測試的程式碼接下來都會放在這裡面:

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
import { TestBed } from '@angular/core/testing';

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

describe('AppComponent', () => {
let component: AppComponent;

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

const fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
});

describe('Unit testing', () => {
// 昨天寫的單元測試...
});

describe('Integration testing', () => {
// 今天要寫的整合測試
});
});

一般我們不會特別將單元測試跟整合測試的程式碼分開檔案來寫,只會用測試集合將其區隔。

由於今天的整合測試跟畫面會比較有相關,所以我們打開 app.component.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
<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>

大家有看出來要測什麼了嗎?我來幫大家整理一下要測的項目:

  • 帳號欄位
    • 屬性 type 的值要是 email
    • 屬性 name 的值要是 account
    • 屬性 pattern 的值要是 \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b
    • 要有屬性 required
    • 要將 Component 的屬性 account 的值綁定到此欄位上
    • 此欄位的值如果有變動,要能觸發函式 accountValueChange
  • 密碼欄位
    • 屬性 type 的值要是 password
    • 屬性 name 的值要是 password
    • 屬性 minlength 的值要是 8
    • 屬性 maxlength 的值要是 16
    • 要有屬性 required
    • 要將 Component 的屬性 password 的值綁定到此欄位上
    • 此欄位的值如果有變動,要能觸發函式 passwordValueChange
  • 錯誤訊息
    • 要將 Component 的屬性 accountErrorMessage 的值綁定到畫面上
    • 要將 Component 的屬性 passwordErrorMessage 的值綁定到畫面上
  • 登入按鈕
    • 屬性 type 的值要是 submit
    • 當表單是無效的狀態時,要有屬性 disabled
    • 當表單是有效的狀態時,沒有屬性 disabled
    • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

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

不過上面這些我個人列的項目有些其實並不屬於整合測試的範圍,但我個人會在這時候一起測,因為這樣可以省下一些重複的程式碼,而我自己也習慣在寫測試的時候分成 Component/Template 兩塊,而不是單元測試/整合測試,這樣的命名會比較符合實際上在做的事情。

那要怎麼測畫面呢?

beforeEach 裡有個 fixture ,我們在測單元測試的時候,是從這裡取得 Component 的實體。而現在要測畫面,一樣是從 fixture 裡取得 Angular 渲染出來的畫面:

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
import { ComponentFixture, TestBed } from '@angular/core/testing';

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

describe('AppComponent', () => {
let component: AppComponent;

// 將 fixture 抽出來
let fixture: ComponentFixture<AppComponent>;

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

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

describe('Unit testing', () => {
// 昨天寫的單元測試...
});


describe('Integration testing', () => {
let compiledComponent: HTMLElement;

beforeEach(() => {
// 此行的意思是讓 Angular 幫我們將畫面的元素都渲染出來
fixture.detectChanges();

// 取得渲染完之後的元素
compiledComponent = fixture.nativeElement;
});

});
});

拿到渲染完的元素之後,接下來要做的事情應該是每個前端工程師都應該要很熟悉的 DOM 操作。

不知道什麼是 DOM 的朋友可能是走錯棚了噢!

沒錯,在撰寫測試以驗證畫面上的元素時,就是用大家都滾瓜爛熟的 DOM 操作來撰寫,以帳號欄位為例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('Integration testing', () => {
let compiledComponent: HTMLElement;

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

describe('Account input field', () => {
let accountInputElement: HTMLInputElement;

beforeEach(() => {
accountInputElement = compiledComponent.querySelector('#account');
});
});
});

如果你的專案有開啟嚴格模式的話( Angular v12 之後預設開啟),可能會在 accountInputElement 底下看到紅色毛毛蟲:

strict mode error

這是因為 TypeScript 在跟你說,這裡有可能會找不到元素,所以型別有可能會是 null

如果我們很有自信它一定找的到、絕對不會是 null 的話,可以在該行結尾加 ! ,像這樣: accountInputElement = compiledComponent.querySelector('#account')! ,就不會有紅色毛毛蟲囉。

帳號欄位的驗證

複習一下帳號欄位的驗證項目:

  • 屬性 type 的值要是 email
  • 屬性 name 的值要是 account
  • 屬性 pattern 的值要是 \b[\w\.-]+@[\w\.-]+\.\w{2,4}\b
  • 要有屬性 required
  • 要將 Component 的屬性 account 的值綁定到此欄位上
  • 此欄位的值如果有變動,要能觸發函式 accountValueChange

接下來就把帳號欄位要驗證的項目寫成測試案例:

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
describe('Account input field', () => {
let accountInputElement: HTMLInputElement;

beforeEach(() => {
accountInputElement = compiledComponent.querySelector('#account')!;
});

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

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

it('should have attribute "pattern" and the value is "\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b"', () => {
// Arrange
const attributeName = 'pattern';
const attributeValue = '\\b[\\w\\.-]+@[\\w\\.-]+\\.\\w{2,4}\\b';
// Assert
expect(accountInputElement.getAttribute(attributeName)).toBe(attributeValue);
});

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

it('should binding the value of property "account"', () => {
// Arrange
const account = 'whatever';
// Act
component.account = account;
fixture.detectChanges();
// Assert
expect(accountInputElement.getAttribute('ng-reflect-model')).toBe(account);
});

it('should trigger function "accountValueChange" when the value be changed', () => {
// Arrange
spyOn(component, 'accountValueChange');
// Act
accountInputElement.value = 'whatever';
accountInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.accountValueChange).toHaveBeenCalled();
});
});

測試結果:

testing result

在這些測試案例裡,比較特別需要說明的是: should trigger function "accountValueChange" when the value be changed 這個測試案例,怎麼說呢?

大家應該都有發現在這個測試案例裡,有使用一個叫做 spyOn 的函式,這個函式的第一個參數是一個物件,第二個參數是這個物件裡的函式的名字。

這個函式的用意是,它會把該物件裡我們所指定的函式替換成一個叫做 Spy 的物件,讓後續如果有人執行該函式時,實際執行的會是我們替換掉的 Spy 物件,而不是原本我們寫的那個函式,這樣才能在後續去驗證該函式是否已經被呼叫過,甚至還可以知道被呼叫的次數、被呼叫時所傳入的參數等等。

這個方式是大家在寫測試時所慣用的手法。在這個測試案例裡,我們只在意該函式是不是有被觸發,不在意該函式的實際執行結果,因為該函式的實際執行結果已經在寫單元測試的時候驗證過了,而整合測試的部份所在意的是互動行為

關於測試的替身,可以參考此篇網路文章:Unit Test 中的替身:搞不清楚的Dummy 、Stub、Spy、Mock、Fake

不過這個測試案例其實有個美中不足的地方,因為嚴格來說我們必須要驗證在該函式被呼叫的時候有傳入 accountNgModel.valueaccountNgModel.errors ,但因為這個物件是透過 Angular 的範本語法去產生出來的,如果要抓到它需要在 Component 裡新增一個屬性,並使用 Angular 的裝飾器 @ViewChild() 來幫我們把這個物件抓出來:

1
2
3
4
export class AppComponent {
@ViewChild('accountNgModel') accountNgModelRef!: NgModel;
// ...
}

如此就能改用 toHaveBeenCalledWith 來驗證:

1
2
3
4
5
6
7
8
9
10
it('should trigger function "accountValueChange" when the value be changed', () => {
// Arrange
spyOn(component, 'accountValueChange');
const accountNgModel = component.accountNgModelRef;
// Act
accountInputElement.value = 'whatever';
accountInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.accountValueChange).toHaveBeenCalledWith(accountNgModel.value, accountNgModel.errors);
});

除了這個測試案例大家可能會不習慣之外,其他的測試看起來滿簡單的對吧?!

密碼欄位的驗證

帳號欄位的測試寫完之後,再來就輪到密碼欄位的部分囉!

複習一下密碼欄位的驗證項目:

  • 屬性 type 的值要是 password
  • 屬性 name 的值要是 password
  • 屬性 minlength 的值要是 8
  • 屬性 maxlength 的值要是 16
  • 要有屬性 required
  • 要將 Component 的屬性 password 的值綁定到此欄位上
  • 此欄位的值如果有變動,要能觸發函式 passwordValueChange

測試程式碼如下:

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
describe('Password input field', () => {
let passwordInputElement: HTMLInputElement;

beforeEach(() => {
passwordInputElement = compiledComponent.querySelector('#password')!;
});

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

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

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

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

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

it('should binding the value of property "password"', () => {
// Arrange
const password = 'whatever';
// Act
component.password = password;
fixture.detectChanges();
// Assert
expect(passwordInputElement.getAttribute('ng-reflect-model')).toBe(password);
});

it('should trigger function "passwordValueChange" when the value be changed', () => {
// Arrange
spyOn(component, 'passwordValueChange');
const passwordNgModel = component.passwordNgModelRef;
// Act
passwordInputElement.value = 'whatever';
passwordInputElement.dispatchEvent(new Event('ngModelChange'));
// Assert
expect(component.passwordValueChange).toHaveBeenCalledWith(passwordNgModel.value, passwordNgModel.errors);
});
});

密碼欄位的部份基本上跟帳號欄位差不多,只有一兩個屬性不一樣而已。

測試結果:

testing result

錯誤訊息的驗證

錯誤訊息的驗證也非常簡單,真要說個比較難的地方,大概就是對於 CSS Selector 的熟悉程度吧!

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

  • 要將 Component 的屬性 accountErrorMessage 的值綁定到畫面上
  • 要將 Component 的屬性 passwordErrorMessage 的值綁定到畫面上

測試程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe('Error Message', () => {
it('should binding the value of property "accountErrorMessage" in the template', () => {
// Arrange
const errorMessage = 'account error';
const targetElement = compiledComponent.querySelector('#account + .error-message');
// Act
component.accountErrorMessage = errorMessage;
fixture.detectChanges();
// Assert
expect(targetElement?.textContent).toBe(errorMessage);
});

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

如果你對於 CSS Selector 真的不熟,就在要驗的元素上增加你可以找到的 ID 、類別或者是屬性吧!

測試結果:

testing result

登入按鈕的驗證

最後是登入按鈕的驗證,它的驗證項目是:

  • 屬性 type 的值要是 submit
  • 當表單是無效的狀態時,要有屬性 disabled
  • 當表單是有效的狀態時,沒有屬性 disabled
  • 當表單是有效狀態時,按下登入按鈕要能觸發函式 login

程式碼如下:

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('Login button', () => {
let buttonElement: HTMLButtonElement;

beforeEach(() => {
buttonElement = compiledComponent.querySelector('button')!;
});

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

it('should have attribute "disabled" when the form\'s status is invalid', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(true);
});

describe('When the form\'s status is valid', () => {
beforeEach(() => {
component.account = '[email protected]';
component.password = '12345678';
fixture.detectChanges();
});

it('should not have attribute "disabled"', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(false);
});

it('should trigger function "login" when being clicked', () => {
// Arrange
spyOn(component, 'login');
// Act
buttonElement.click();
// Assert
expect(component.login).toHaveBeenCalled();
});
});
});

測試結果:

testing result

咦?怎麼會有 Error 咧?我自己在第一次遇到這個狀況也是有點傻眼,於是我深入調查了之後發現:

testing result

原來是因為 Karma 渲染出來的元素跟 Angular 渲染出來的元素狀態不一樣,Karma 渲染出來的 form 元素跟沒有正確吃到底下的表單欄位:

testing result

關於這個問題,我會再發 issue 詢問官方,如果後續有任何消息,我會再更新此篇文章讓大家知道。

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

1
2
3
4
5
6
xit('should have attribute "disabled" when the form\'s status is invalid', () => {
// Arrange
const attributeName = 'disabled';
// Assert
expect(buttonElement.hasAttribute(attributeName)).toBe(true);
});

測試結果:

testing result

至此,我們就完成了整合測試的部份囉!雖然剛好遇到奇怪的問題,但學習如何排除異常也是非常重要的一部分噢!

今天的文章就到這邊,明天我們要為用 Reactive Forms 所撰寫的登入表單來撰寫單元測試,不過我其實昨天其實就教過大家怎麼寫單元測試,在看我的文章之前,建議大家先自己寫寫看再參考我的文章,相信一定會有更多的收穫!

本日小結

再次提醒大家,在寫整合測試時,需要測試的是兩個類別實體之間在各種情況下的互動行為是否符合我們的預期,跟單元測試要測試的重點是很不一樣的。

除此之外,就算我們是在寫整合測試不是單元測試,但依然要盡量做到我在如何寫出優秀的測試?文中所提到的部份噢!

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

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

評論

Your browser is out-of-date!

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

×