Two-way Binding 的 ngModel 非常方便,但因为这是 syntax sugar,该如何测试呢?
Version
Angalar 4.3
Requirement
画面有一个下拉选单。
下拉选单内有 AWS 、 Azure 与 Aliyun 三个值。
当选择 AWS 时,在下方显示 0 。
当选择 Azure 时,在下方显示 1 。
当选择 Aliyun 时,在下方显示 2 。
Acceptance Test (红灯)
测试案例
下拉选单应该有 3 个选项
选择 AWS ,下方应该出现 0
选择 Azure 时,下方应该出现 1
选择 Aliyun 时,下方应该出现 2
e2e/app.e2e-spec.ts
import { NG43ATDDSelectPage } from './app.po'; describe('NG43ATDDSelect', () => { let page: NG43ATDDSelectPage; beforeEach(() => { page = new NG43ATDDSelectPage(); }); 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 行
it(`should have 3 options in select`, () => { page.navigateTo(); expect(page.getSelectCount()).toBe(3); }); |
测试下拉选单的资料是否为 AWS 、 Azure 与 Aliyun ,实务上这些资料会从后端 API 来,可测试 API 是否正常接上,可以测试 API 的 SQL 或 ORM 是否正确。
要测试资料完全相同比较困难时,最少可以测试资料的笔数是否正确。
15 行
it(`should show '0' when selecting 'AWS' `, () => { page.navigateTo(); page.select('AWS'); expect(page.getSelectedId()).toBe('0'); }); |
当选择 AWS 时,下方应该显示 0 。
剩下 Azure 与 Aliyun 的写法类似。
1、编辑 e2e/app.e2e-spec.ts
2、加入验收测试
e2e/app.po.ts
import { browser, by, element } from 'protractor'; export class NG43ATDDSelectPage { navigateTo() { return browser.get('/'); } 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 行
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() 。
12 行
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() 。
16 行
getSelectedId(): any { return element(by.css('p')).getText(); } |
下拉选单所选的值,预期只会用 <p> 包起来而已,因此 by.css('p') 即可。
1.编辑 e2e/app.po.ts
2.加入 page object
因为我们还没实作此功能,得到预期的验收测试 红灯 。
Integration Test (红灯)
测试案例
ngModel 应该使用 selectedId field
<p> 内应该使用 selectedId field
triggerEventHandler()
src/app/app.component.spec.ts
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 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 行
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 变化。
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。
expect(component.selectedId).toBe('2');
期望 component.selectedId 为 2 这个 stub。
40 行
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
因为目前是整合测试,而 AppComponent 的 selectedId 根本还没实现,理论上也应该使用 spyOn() ,但可惜 Jasmine 的 spyOn() 并没有支援 field,只能使用最基本的方式: 建立假物件,并测试假物件 的方式。
因为还没实作,整合测试是预期的 红灯 。
dispatchEvent()
src/app/app.component.spec.ts
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 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'); }); it(`should use 'selectedId' field`, () => { component.selectedId = '1'; fixture.detectChanges(); htmlElement = debugElement.query(By.css('p')).nativeElement; expect(htmlElement.textContent).toBe('1'); }); }); }); }); |
34 行
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'); }); |
使用 triggerEventHandler() 方式虽然可行,但我们发现其第 2 个参数的 stub : {target: {value: '2'}} 有点小复杂,且必须很了解 $event 物件的结构。
(<HTMLSelectElement>htmlElement).value = '2';
当 <select> 的选择改变时,事实上就是改变其 value 值。
htmlElement.dispatchEvent(new Event('change'));
使用 dispatchEvent() 触发 event,传入 Event 物件,并以 evetn 名称传入 Event 。
triggerEventHandler() 与 dispatchEvent() 都是触发 event,只是 triggerEventHandler() 是从 DOM 的方式去触发 event,而 dispatchEvent() 是从 Angular 的方式触发 event,两种方式都可以,实务上推荐使用 dispatchEvent() ,就不用了解 DOM 的 $event 物件,也比较物件导向。
因为还没实作,整合测试是预期的 红灯 。
Unit Test (红灯)
因为 ngModel 不须要 class 实作任何 method,因此也不需要单元测试。
Unit Test (绿灯)
因为 ngModel 没有单元测试,因此也不需要通过单元测试。
Integration Test (绿灯)
测试案例 : ngModel 应该使用 selectedId field
src/app/app.component.html
<select id="TDDSelect" [(ngModel)]="selectedId"> <option *ngFor="let cloud of clouds" [value]="cloud.id">{{ cloud.name }}</option> </select> <p></p> |
在 <select> 加上 [(ngModel)]="selectedId" 。
在 HTML 实作 ngModel 绑定到 selectedId field 之后,使用 triggerEventHandler() 写法的整合测试就 绿灯 了。
使用 dispatchEvent() 写法的整合测试也 绿灯 了。
src/app/app.component.html
测试案例 : <p> 内应该使用 selectedId field
<select id="TDDSelect" [(ngModel)]="selectedId"> <option *ngFor="let cloud of clouds" [value]="cloud.id">{{ cloud.name }}</option> </select> <p>{{ selectedId }}</p> |
在 <p> 内加上 selectedId 。
在 HTML 实作 <p> 内的 selectedId 绑定之后,使用 triggerEventHandler() 写法的整合测试就 绿灯 了。
使用 dispatchEvent() 写法的整合测试也 绿灯 了。
Acceptance Test (绿灯)
测试案例
1.下拉选单应该有 3 个选项
2.选择 AWS 时,下方应该出现 0
3.选择 Azure 时,下方应该出现 1
4.选择 Aliyun 时,下方应该出现 2
Refactoring
因为 class 没有逻辑,所以不需要重构。
Conclusion
ngModel 只有验收测试与整合测试,没有单元测试,因为 class 没有逻辑。
ngModel 整合测试的关键在于测试 ngModel 有没有绑定到正确的 field,因此建立 stub,并触发 DOM 的 change event ,测试 field 值是否为 stub,则完成 ngModel 的整合测试。
在整合测试触发 event 时,可以使用 triggerEventHandler() 或 dispatchEvent() ,实务上建议使用 dispatchEvent() ,较物件导向。