Angular 2 — Testing Guide 的閱讀筆記,這篇文章是由 Gerard Sans 撰寫的
這篇文章包含Angular Application的基本單元測試,像是Components, Services, Http and Pipes.
該文章有提供另外一個Testing Checklist 幫助建立測試
Jasmine的基本介紹
Jasmine有幾個基本的元素
Suites - describe(title:string, function)
: 基本容器. 用來裝 Specs.
Specs - it(title:string, function)
: 基本測試單位,裡面可以包含一個或多個expectations
expectations - expect(actual).toBe(expected)
用來比對測試結果與預期結果
Matchers - 預先設定的運算式, Eg. toBe(expected)
, toEqual(expected)
, 更多
Jasmine有提供4個handlers,來處理一些在測試前或是測試後可以額外執行的動作
beforeEach
, afterEach
於每一個 spec
前後執行
beforeAll
,afterAll
於每一個 Suit
前後執行
可以透過上列的方式避免重複程式碼的產生
Angular 測試
TestBed : 在測試裡面建立ngModule,設定方式與一般設定ngModule一樣,提供方法讓測試案例裡可以取得想要測試的component/service等
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @NgModule ({ declarations : [ ComponentToTest ] providers : [ MyService ] }) mockMyService = {}; class AppModule { }TestBed .configureTestingModule ({ declarations : [ ComponentToTest ], providers : [ {provide : MyService , useValue : mockMyService} ] }); let service = TestBed .get (MyService );
Inject : 允許我們在TestBed Level取得dependencies
1 2 3 it ('should return ...' , inject ([MyService ], service => { service.foo (); }));
Component Injector : 允許我們在Component Level取得dependencies
1 2 3 4 5 6 @Component ({ providers : [ MyService ] }) class ComponentToTest { }let fixture = TestBed .createComponent (ComponentToTest );let service = fixture.debugElement .injector .get (MyService );
如果DI是在Component裡面定義的話,只能透過上述方法才能取得. TestBed.get
或是Inject
是取不到的
service測試的範例程式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 describe ('Service: LanguagesService' , () => { let service; beforeEach (() => TestBed .configureTestingModule ({ providers : [ LanguagesService ] })); beforeEach (inject ([LanguagesService ], s => { service = s; })); it ('should return available languages' , () => { expect (service.get ()).toContain ('en' ); }); });
產生component的方式
1 2 3 4 5 6 7 8 9 10 11 beforeEach (() => { fixture = TestBed .createComponent (MyTestComponent ); }); beforeEach (async (() => { TestBed .configureTestingModule ({ declarations : [ MyTestComponent ], }).compileComponents (); }));
用非同步的方式產生component, 這方式同時間會產生zone
來負責所有非同步的行為
Testing Checklist
需要決定測試的種類: Isoldated, shallow or integration 參閱
應該使用 Mocks
、Stubs
or Spies
?
同步或非同步?
測試範例
Component
要測試的Component
1 2 3 4 5 6 7 8 9 @Component ({ selector : 'greeter' , template : `<h1>Hello {{name}}!</h1>` }) export class Greeter { @Input () name; }
Angular建議使用TestBed來產生component
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 describe ('Component: Greeter' , () => { let fixture, greeter, element, de; beforeEach (() => { TestBed .configureTestingModule ({ declarations : [ Greeter ] }); fixture = TestBed .createComponent (Greeter ); greeter = fixture.componentInstance ; element = fixture.nativeElement ; de = fixture.debugElement ; }); it ('should render `Hello World!`' , async (() => { greeter.name = 'World' ; fixture.detectChanges (); fixture.whenStable ().then (() => { expect (element.querySelector ('h1' ).innerText ).toBe ('Hello World!' ); expect (de.query (By .css ('h1' )).nativeElement .innerText ).toBe ('Hello World!' ); }); })); })
fixture是用來讀取component的方法. 他有下列的方法
1 2 3 4 5 6 abstract class ComponentFixture { debugElement; componentInstance; nativeElement; detectChanges (); }
whenStable
是當所有非同步的行為都完成後,會執行whenStable
,這時,就可以取得應有的結果
其他讀取搜尋debugElement的方式:
query(By.all())
query(By.directive(MyDirective))
Service
要測試的serivce範例
1 2 3 4 5 6 export class LanguagesService { get ( ) { return ['en' , 'es' , 'fr' ]; } }
類似測試Component的方式,一樣使用TestBed來產生Service.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 describe ('Service: LanguagesService' , () => { let service; beforeEach (() => TestBed .configureTestingModule ({ providers : [ LanguagesService ] })); beforeEach (inject ([LanguagesService ], s => { service = s; })); it ('should return available languages' , () => { let languages = service.get (); expect (languages).toContain ('en' ); expect (languages).toContain ('es' ); expect (languages).toContain ('fr' ); expect (languages.length ).toEqual (3 ); }); });
Using Http
通常在測試階段不會做任何HTTP call. 但是還是簡單介紹一下,因為這時需要使用到HttpModule
要測試的Service程式碼
1 2 3 4 5 6 7 8 export class LanguagesServiceHttp { constructor (private http :Http ) { } get ( ){ return this .http .get ('api/languages.json' ) .map (response => response.json ()); } }
測試
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 describe ('Service: LanguagesServiceHttp' , () => { let service; beforeEach (() => TestBed .configureTestingModule ({ imports : [ HttpModule ], providers : [ LanguagesServiceHttp ] })); beforeEach (inject ([LanguagesServiceHttp ], s => { service = s; })); it ('should return available languages' , async (() => { service.get ().subscribe (x => { expect (x).toContain ('en' ); expect (x).toContain ('es' ); expect (x).toContain ('fr' ); expect (x.length ).toEqual (3 ); }); })); })
Using MockBackend
由於測試時不呼叫真實的後端API, 就會寫一個假的來模擬替代真實的呼叫
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 describe ('MockBackend: LanguagesServiceHttp' , () => { let mockbackend, service; beforeEach (() => { TestBed .configureTestingModule ({ imports : [ HttpModule ], providers : [ LanguagesServiceHttp , { provide : XHRBackend , useClass : MockBackend } ] }) }); beforeEach (inject ([LanguagesServiceHttp , XHRBackend ], (_service, _mockbackend ) => { service = _service; mockbackend = _mockbackend; })); it ('should return mocked response (async)' , async (() => { let response = ["ru" , "es" ]; mockbackend.connections .subscribe (connection => { connection.mockRespond (new Response ({body : JSON .stringify (response)})); }); service.get ().subscribe (languages => { expect (languages).toContain ('ru' ); expect (languages).toContain ('es' ); expect (languages.length ).toBe (2 ); }); })); })
Directive
因為Directive沒有view, 而且是相依在dom上,所以必須建立一個component容器來測試directive
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Directive ({ selector : "[log-clicks]" }) export class logClicks { counter = 0 ; @Output () changes = new EventEmitter (); @HostListener ('click' , ['$event.target' ]) clicked (target ) { console .log (`Click on [${target} ]: ${++this .counter} ` ); this .changes .emit (this .counter ); } }
測試
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 @Component ({ selector : 'container' , template : `<div log-clicks (changes)="changed($event)"></div>` , directives : [logClicks] }) export class Container { @Output () changes = new EventEmitter (); changed (value ){ this .changes .emit (value); } } describe ('Directive: logClicks' , () => { let fixture; let container; let element; beforeEach (() => { TestBed .configureTestingModule ({ declarations : [ Container , logClicks ] }); fixture = TestBed .createComponent (Container ); container = fixture.componentInstance ; element = fixture.nativeElement ; }); it ('should increment counter' , fakeAsync (() => { let div = element.querySelector ('div' ); container.changes .subscribe (x => { expect (x).toBe (1 ); }); div.click (); tick (); })); })
fakeAsync
所有的非同步行為會被暫停直到 呼叫tick
fakeAsync
/ tick
不能跟XHR一起使用
Pipe
Pipe很容易測試,很單純的Class
1 2 3 4 5 6 7 8 9 10 11 12 import {Pipe , PipeTransform } from '@angular/core' ;@Pipe ({ name : 'capitalise' }) export class CapitalisePipe implements PipeTransform { transform (value : string ): string { if (typeof value !== 'string' ) { throw new Error ('Requires a String as input' ); } return value.toUpperCase (); } }
測試
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 ('Pipe: CapitalisePipe' , () => { let pipe; beforeEach (() => TestBed .configureTestingModule ({ providers : [ CapitalisePipe ] })); beforeEach (inject ([CapitalisePipe ], p => { pipe = p; })); it ('should work with empty string' , () => { expect (pipe.transform ('' )).toEqual ('' ); }); it ('should capitalise' , () => { expect (pipe.transform ('wow' )).toEqual ('WOW' ); }); it ('should throw with invalid values' , () => { expect (()=> pipe.transform (undefined )).toThrow (); expect (()=> pipe.transform ()).toThrow (); expect (()=> pipe.transform ()).toThrowError ('Requires a String as input' ); }); })
Routes
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 @Component ({ selector : 'my-app' , template : `<router-outlet></router-outlet>` }) class TestComponent { }@Component ({ selector : 'home' , template : `<h1>Home</h1>` }) export class Home { }export const routes : Routes = [ { path : '' , redirectTo : 'home' , pathMatch : 'full' }, { path : 'home' , component : Home }, { path : '**' , redirectTo : 'home' } ]; @NgModule ({ imports : [ BrowserModule , RouterModule .forRoot (routes), ], declarations : [TestComponent , Home ], bootstrap : [TestComponent ], exports : [TestComponent ] }) export class AppModule {}
測試
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 import { RouterTestingModule } from '@angular/router/testing' ;describe ('Router tests' , () => { beforeEach (() => { TestBed .configureTestingModule ({ imports : [ RouterTestingModule .withRoutes (routes), AppModule ] }); }); it ('can navigate to home (async)' , async (() => { let fixture = TestBed .createComponent (TestComponent ); TestBed .get (Router ) .navigate (['/home' ]) .then (() => { expect (location.pathname .endsWith ('/home' )).toBe (true ); }).catch (e => console .log (e)); })); it ('can navigate to home (fakeAsync/tick)' , fakeAsync (() => { let fixture = TestBed .createComponent (TestComponent ); TestBed .get (Router ).navigate (['/home' ]); fixture.detectChanges (); tick (); expect (location.pathname .endsWith ('/home' )).toBe (true ); })); it ('can navigate to home (done)' , done => { let fixture = TestBed .createComponent (TestComponent ); TestBed .get (Router ) .navigate (['/home' ]) .then (() => { expect (location.pathname .endsWith ('/home' )).toBe (true ); done (); }).catch (e => console .log (e)); }); });
Observables
如何測試Observable
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 describe ('Observable: basic observable' , () => { var basic$; beforeEach (() => { basic$ = new Observable (observer => { observer.next (1 ); observer.next (2 ); observer.next (3 ); observer.complete (); }); }) it ('should create the expected sequence (async)' , async (() => { let expected = [1 ,2 ,3 ], index = 0 ; basic$ .subscribe ({ next : x => expect (x).toEqual (expected[index++]), error : e => console .log (e) }); })); });
EventEmitters
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Component ({ selector : 'counter' , template : ` <div> <h1>{{counter}}</h1> <button (click)="change(1)">+1</button> <button (click)="change(-1)">-1</button> </div>` }) export class Counter { @Output () changes = new EventEmitter (); constructor ( ){ this .counter = 0 ; } change (increment ) { this .counter += increment; this .changes .emit (this .counter ); } }
測試方式類似Observable
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 describe ('EventEmitter: Counter' , () => { let counter; beforeEach (() => TestBed .configureTestingModule ({ providers : [ Counter ] })); beforeEach (inject ([Counter ], c => { counter = c; })) it ('should increment +1 (async)' , async (() => { counter.changes .subscribe (x => { expect (x).toBe (1 ); }); counter.change (1 ); })); it ('should decrement -1 (async)' , async (() => { counter.changes .subscribe (x => { expect (x).toBe (-1 ); }); counter.change (-1 ); })); })