使用 ATDD 驅動出 ngModel

Two-way Binding 的 ngModel非常方便,但因為這是 syntax sugar,該如何測試呢?

Version


Angular 4.3

Requirement


select000

畫面有一個下拉選單。

select001

下拉選單內有 AWSAzureAliyun 三個值。

select002

當選擇 AWS 時,在下方顯示 0

select003

當選擇 Azure 時,在下方顯示 1

select004

當選擇 Aliyun 時,在下方顯示 2

Acceptance Test (紅燈)


測試案例 :

  1. 應該有 1 個下拉選單
  2. 下拉選單應該有 3 個選項
  3. 選擇 AWS ,下方應該出現 0
  4. 選擇 Azure 時,下方應該出現 1
  5. 選擇 Aliyun 時,下方應該出現 2

e2e/app.e2e-spec.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
import { NG43ATDDngModelPage } from './app.po';

describe('NG43ATDDngModel App', () => {
let page: NG43ATDDngModelPage;

beforeEach(() => {
page = new NG43ATDDngModelPage();
});

it(`should have 1 select`, () => {
page.navigateTo();
expect(page.getSelect().isPresent()).toBeTruthy();
});

it(`should have 3 options in select`, () => {
page.navigateTo();
expect(page.getSelectCount()).toBe(3);
});

it(`should show '0' when selecting 'AWS' `, () => {
page.navigateTo();
page.select('AWS');

expect(page.getSelectedId()).toBe('0');
});

it(`should show '1' when selecting 'Azure' `, () => {
page.navigateTo();
page.select('Azure');

expect(page.getSelectedId()).toBe('1');
});

it(`should show '2' when selecting 'Aliyun'`, () => {
page.navigateTo();
page.select('Aliyun');

expect(page.getSelectedId()).toBe('2');
});
});

10 行

1
2
3
4
it(`should have 1 select`, () => {
page.navigateTo();
expect(page.getSelect().isPresent()).toBeTruthy();
});

測試案例 : 應該有 1 個下拉選單

若下拉選單存在,則 page.getSelect().isPresent() 回傳 true,否則為 false。

不是使用 expect(page.getSelect()).toBeTruthy(),這樣會永遠 綠燈

15 行

1
2
3
4
it(`should have 3 options in select`, () => {
page.navigateTo();
expect(page.getSelectCount()).toBe(3);
});

測試案例 : 下拉選單應該有 3 個選項

測試下拉選單的資料是否為 AWSAzureAliyun,實務上這些資料會從後端 API 來,可測試 API 是否正常接上,也可以測試 API 的 SQL 或 ORM 是否正確。

若要測試資料完全相同比較困難時,最少可以測試資料的筆數是否正確。

20 行

1
2
3
4
5
6
it(`should show '0' when selecting 'AWS' `, () => {
page.navigateTo();
page.select('AWS');

expect(page.getSelectedId()).toBe('0');
});

測試案例 : 選擇 AWS ,下方應該出現 0

當選擇 AWS 時,下方應該顯示 0

剩下 AzureAliyun 的寫法類似。

select005

  1. 編輯 e2e/app.e2e-spec.ts
  2. 加入驗收測試

e2e/app.po.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { browser, by, element } from 'protractor';

export class NG43ATDDngModelPage {
navigateTo() {
return browser.get('/');
}

getSelect(): any {
return element(by.id('TDDSelect'));
}

getSelectCount(): any {
return element(by.id('TDDSelect')).all(by.tagName('option')).count();
}

select(text: string) {
element(by.id('TDDSelect')).all(by.cssContainingText('option', text)).click();
}

getSelectedId(): any {
return element(by.css('p')).getText();
}
}

第 8 行

1
2
3
getSelect(): any {
return element(by.id('TDDSelect'));
}

回傳 id 為 TDDSelect<select>

12 行

1
2
3
getSelectCount(): any {
return element(by.id('TDDSelect')).all(by.tagName('option')).count();
}

先由 element(by.id('TDDSelect')) 先抓到 <select>,再由 all() 取得 <select> 下所有 element,再將 by.tagName('option') 傳入 all(),找出 tag 為 <option> 的 element 加以 count()

16 行

1
2
3
select(text: string) {
element(by.id('TDDSelect')).all(by.cssContainingText('option', text)).click();
}

先由 element(by.id('TDDSelect')) 先抓到 <select>,再由 all() 取得 <select> 下所有 element,再將 by.cssContainingText('option', text) 取得正確 option 加以 click()

使用 Protractor 在下拉選單選擇指定字串的寫法較為 tricky,需動用 by.cssContainingText()

20 行

1
2
3
getSelectedId(): any {
return element(by.css('p')).getText();
}

下拉選單所選的值,預期只會用 <p> 包起來而已,因此 by.css('p') 即可。

select006

  1. 編輯 e2e/app.po.ts
  2. 加入 page object

select007

因為我們還沒實作此功能,得到預期的驗收測試 紅燈

Integration Test (紅燈)


測試案例 :

  1. <select> 應該使用 *ngFor 產生 <option>
  2. ngModel 應該使用 selectedId field
  3. <p> 內應該使用 selectedId field

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

import { AppComponent } from './app.component';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
let debugElement: DebugElement;
let htmlElement: HTMLElement;
let target: AppComponent;

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

fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
target = new AppComponent();
fixture.detectChanges();
});

describe(`ATDDSelect`, () => {
describe(`Integration Test`, () => {
it(`should use '*ngFor' to list clouds`, () => {
component.clouds = [
{id: '3', name: 'GCP'}
];
fixture.detectChanges();

const option = debugElement.query(By.css('#TDDSelect')).children[0].nativeElement;
expect(option.textContent).toBe('GCP');
});

it(`should have 'selectedId' field on 'ngModel' directive`, () => {
debugElement.query(By.css('#TDDSelect')).triggerEventHandler('change', {target: {value: '2'}});

expect(component.selectedId).toBe('2');
});

it(`should use 'selectedId' field`, () => {
component.selectedId = '1';
fixture.detectChanges();

htmlElement = debugElement.query(By.css('p')).nativeElement;
expect(htmlElement.textContent).toBe('1');
});
});
});
});

34 行

1
2
3
4
5
6
7
8
9
it(`should use '*ngFor' to list clouds`, () => {
component.clouds = [
{id: '3', name: 'GCP'}
];
fixture.detectChanges();

const option = debugElement.query(By.css('#TDDSelect')).children[0].nativeElement;
expect(option.textContent).toBe('GCP');
});

測試案例 : <select> 應該使用 *ngFor 產生 <option>

整合測試除了測試 binding 是否正確外,另外一個目的就是要驅動出 directive 的使用。

<select><option> 有多筆資料,勢必會使用 *ngFor 顯示所 binding 的資料。

為了確認是否使用 *ngFor,一樣使用整合測試慣用的手法 :

建立 stub,並測試 stub

1
2
3
4
component.clouds = [
{id: '3', name: 'GCP'}
];
fixture.detectChanges();

建立 component.clouds 的 stub,並加上 fixture.detectChanges() 將假資料反映到 HTML 上。

1
const option = debugElement.query(By.css('#TDDSelect')).children[0].nativeElement;

debugElement.query(By.css('#TDDSelect')) 先抓到 <select>,由於目前 stub 只有一筆,故取其 children 陣列的第 0 筆資料即可,並取其 nativeElement

1
expect(option.textContent).toBe('GCP');

期望其 textContent 為 stub 的 GCP

triggerEventHandler()

44 行

1
2
3
4
5
it(`should have 'selectedId' field on 'ngModel' directive`, () => {
debugElement.query(By.css('#TDDSelect')).triggerEventHandler('change', {target: {value: '2'}});

expect(component.selectedId).toBe('2');
});

測試案例 : ngModel 應該使用 selectedId field

ngModel 就是要將 HTML element 直接綁定到 field,因此最重要的就是測試有沒有綁定到正確的 field。

Two-way binding 的重點在於當 HTML 改變時,class 的 field 會自動跟著改變,因此我們測試的手法就是改變 HTML,並測試 field 是否跟著 HTML 變化。

1
debugElement.query(By.css('#TDDSelect')).triggerEventHandler('change', {target: {value: '2'}});

在整合測試時,我們慣用 triggerEventHandler() 去觸發 event,由於 DOM 的 change event 會觸發 ngModelChange event,因此我們使用 triggerEventHandler() 去觸發 change event。

我們對 <select> 做任何選擇的 value 值,會由 change() 的第 2 個參數的 $event.target.value 代入。

因此我們要準備 {target: {value: '2'}} 這個 stub,並測試 field 值否為此 stub,若相等,則表示 ngModel 有綁定到正確的 field。

1
expect(component.selectedId).toBe('2');

期望 component.selectedId 為 stub 的 2

dispatchEvent()

44 行

1
2
3
4
5
6
7
it(`should have 'selectedId' field on 'ngModel' directive`, () => {
htmlElement = debugElement.query(By.css('#TDDSelect')).nativeElement;
(<HTMLSelectElement>htmlElement).value = '2';
htmlElement.dispatchEvent(new Event('change'));

expect(component.selectedId).toBe('2');
});

測試案例 : ngModel 應該使用 selectedId field

使用 triggerEventHandler() 方式雖然可行,但我們發現其第 2 個參數的 stub : {target: {value: '2'}} 有點小複雜,且必須很了解 $event 物件的結構。

1
(<HTMLSelectElement>htmlElement).value = '2';

<select> 的選擇改變時,事實上就是改變其 value 值。

1
htmlElement.dispatchEvent(new Event('change'));

使用 dispatchEvent() 觸發 event,傳入 Event 物件,並以 evetn 名稱傳入 Event

triggerEventHandler()dispatchEvent() 都是觸發 event,只是 triggerEventHandler() 是從 DOM 的方式去觸發 event,而 dispatchEvent() 是從 Angular 的方式觸發 event,兩種方式都可以,實務上建議使用 dispatchEvent(),不用了解 DOM 的 $event 物件的結構,也比較物件導向。

52 行

1
2
3
4
5
6
7
it(`should use 'selectedId' field`, () => {
component.selectedId = '1';
fixture.detectChanges();

htmlElement = debugElement.query(By.css('p')).nativeElement;
expect(htmlElement.textContent).toBe('1');
});

測試案例 : <p> 應該使用 selectedId field

因為目前是整合測試,而 AppComponentselectedId 根本還沒實現,理論上也應該使用 spyOn(),但可惜 Jasmine 的 spyOn() 並沒有支援 field,只能使用最基本的方式:建立 stub,並測試 stub

select008

因為還沒實作,整合測試是預期的 紅燈

Unit Test (紅燈)


測試案例 :

  1. Class 應該有 clouds field 且初始值為 AWSAzureAliyun
  2. Class 應該有 selectedId field 且初始值為 0

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

import { AppComponent } from './app.component';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
let debugElement: DebugElement;
let htmlElement: HTMLElement;
let target: AppComponent;

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

fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
target = new AppComponent();
fixture.detectChanges();
});

describe(`ATDDSelect`, () => {
describe(`Unit Test`, () => {
it(`should have 'clouds' field with ['AWS', 'Azure', 'Aliyun']`, () => {
expect(target.clouds).toEqual([
{id: '0', name: 'AWS'},
{id: '1', name: 'Azure'},
{id: '2', name: 'Aliyun'}
]);
});

it(`should have 'selectedId' field with '0'`, () => {
expect(target.selectedId).toBe('0');
});
});
});
});

34 行

1
2
3
4
5
6
7
it(`should have 'clouds' field with ['AWS', 'Azure', 'Aliyun']`, () => {
expect(target.clouds).toEqual([
{id: '0', name: 'AWS'},
{id: '1', name: 'Azure'},
{id: '2', name: 'Aliyun'}
]);
});

測試案例 : Class 應該有 clouds field 且初始值為 AWSAzureAliyun

目前需求只有 3 筆資料,直接 expect().toEqual() 是否為預期陣列。

Q : toBe()toEqual() 有什麼差別 ?

  • toBe() : 比較 value
  • toEqual() : 比較 object

42 行

1
2
3
it(`should have 'selectedId' field as '0'`, () => {
expect(target.selectedId).toBe('0');
});

測試案例 : Class 應該有 selectedId field且初始值為 0

期望 target.selectedId0

select015

因為還沒實作,單元測試是預期的 紅燈

Unit Test (綠燈)


測試案例 : Class 應該有 clouds field 且初始值為 AWSAzureAliyun

src/app/app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';
import { Cloud } from './cloud';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
clouds: Cloud[] = [
{id: '0', name: 'AWS'},
{id: '1', name: 'Azure'},
{id: '2', name: 'Aliyun'}
];
}

10 行

1
2
3
4
5
clouds: Cloud[] = [
{id: '0', name: 'AWS'},
{id: '1', name: 'Azure'},
{id: '2', name: 'Aliyun'}
];

建立 cloud field 與設定初始陣列。

select016

在 class 實作出 cloud field 後,Wallaby 單元測試就 綠燈 了。

測試案例 : Class 應該有 selectedId field 且初始值為 0

src/app/app.component.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component } from '@angular/core';
import { Cloud } from './cloud';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
clouds: Cloud[] = [
{id: '0', name: 'AWS'},
{id: '1', name: 'Azure'},
{id: '2', name: 'Aliyun'}
];

selectedId = '0';
}

16 行

1
selectedId = '0';

建立 selected field 與設定初始值為 0

select009

在 class 實作出 selectedId field 後,Wallaby 單元測試就 綠燈 了。

Integration Test (綠燈)


測試案例 : <select> 應該使用 *ngFor 產生 <option>

src/app/app.component.html

1
2
3
<select id="TDDSelect">
<option *ngFor="let cloud of clouds" [value]="cloud.id">{{ cloud.name }}</option>
</select>

使用 *ngFor 根據 clouds field 產生 <option>

select013

使用 *ngFor 之後,Wallaby 整合測試就 綠燈 了。

測試案例 : ngModel 應該使用 selectedId field

src/app/app.module.ts

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

import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';

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

12 行

1
2
3
4
imports: [
BrowserModule,
FormsModule
],

ngModel directive 需要使用 FormsModule,而 Angular CLI 預設沒有載入,必須手動加上。

src/app/app.component.html

1
2
3
4
<select id="TDDSelect" [(ngModel)]="selectedId">
<option *ngFor="let cloud of clouds" [value]="cloud.id">{{ cloud.name }}</option>
</select>
<p></p>

<select> 加上 [(ngModel)]="selectedId"

select010

在 HTML 實作 ngModel 綁定到 selectedId field 之後,Wallaby 整合測試就 綠燈了。

src/app/app.component.html

測試案例 : <p> 內應該使用 selectedId field

1
2
3
4
<select id="TDDSelect" [(ngModel)]="selectedId">
<option *ngFor="let cloud of clouds" [value]="cloud.id">{{ cloud.name }}</option>
</select>
<p>{{ selectedId }}</p>

<p> 內加上 selectedId

select012

在 HTML 實作 <p> 內的 selectedId 綁定之後,Wallaby 的整合測試就全部 綠燈 了。

Acceptance Test (綠燈)


測試案例

  1. 應該有 1 個下拉選單
  2. 下拉選單應該有 3 個選項
  3. 選擇 AWS ,下方應該出現 0
  4. 選擇 Azure 時,下方應該出現 1
  5. 選擇 Aliyun 時,下方應該出現 2

select014

整合測試 綠燈 後,最後再跑一次驗收測試確認為 綠燈

重構


因為 class 沒有邏輯,所以不需要重構。

Conclusion


  • ngModel 整合測試的關鍵在於測試 ngModel 有沒有綁定到正確的 field,因此建立 stub,並觸發 DOM 的 change event ,測試 field 值是否為 stub,則完成 ngModel 的整合測試。
  • 在整合測試觸發 event 時,可以使用 triggerEventHandler()dispatchEvent(),實務上建議使用 dispatchEvent(),較物件導向。

Reference


Andras Sevcsik, Testing ngModel in Angular 2

Sample Code


完整的範例可以在我的 GitHub 上找到。

2017-08-19