# Angular Testing 筆記 angular 內建使用 Karma & Jasmine 測試框架,在使用 Angular CLI 建立專案時,會一併將測試文件建立好了(`.spec.ts` 結尾的檔案)。 [Karma & Jasmine 簡介](https://hackmd.io/s/B1h08Ea2X) ## 測試 Service ### 手動注入依賴 要測試一個不帶依賴的 Service 時,可以簡單地實例化這個服務,並調用他即可: ```javascript= // Straight Jasmine testing without Angular's testing support describe('ValueService', () => { let service: ValueService; beforeEach(() => { service = new ValueService(); }); it('#getValue should return real value', () => { expect(service.getValue()).toBe('real value'); }); it('#getObservableValue should return value from observable', (done: DoneFn) => { service.getObservableValue().subscribe(value => { expect(value).toBe('observable value'); done(); }); }); it('#getPromiseValue should return value from a promise', (done: DoneFn) => { service.getPromiseValue().then(value => { expect(value).toBe('promise value'); done(); }); }); }); ``` 上例中,用 beforeEach 在每個 spec 執行測試前,都實例化一份 ValueService 來做測試。 但是如果要測試的 Service 相依於其他的服務時: ```javascript= @Injectable() export class MasterService { constructor(private valueService: ValueService) { } getValue() { return this.valueService.getValue(); } } ``` 為要測試的 Service 注入真實的服務,就顯得容易不受控制,例如: ```javascript= describe('MasterService without Angular testing support', () => { let masterService: MasterService; it('#getValue should return real value from the real service', () => { masterService = new MasterService(new ValueService()); expect(masterService.getValue()).toBe('real value'); }); }); ``` 而這樣測試的正確性,除了 MasterService 之外,還會被 ValueService 正確與否影響,如果 ValueService 又還需要向外發出 HTTP request 資料時,情況就顯得更複雜了。 這個時候我們可以製作 FakeValueService 注入,或是在測試文件中直接實作 fake object 來提供假資料,也可以利用 Jasmine 提供的 spy 函式來模擬依賴的服務: ```javascript= describe('MasterService without Angular testing support', () => { let masterService: MasterService; // 製作 FakeValueService 注入 it('#getValue should return faked value from a fakeService', () => { masterService = new MasterService(new FakeValueService()); expect(masterService.getValue()).toBe('faked service value'); }); // 直接實作 fake object it('#getValue should return faked value from a fake object', () => { const fake = { getValue: () => 'fake value' }; masterService = new MasterService(fake as ValueService); expect(masterService.getValue()).toBe('fake value'); }); // 使用 Jasmine 提供的 spy 函式模擬出依賴的服務 it('#getValue should return stubbed value from a spy', () => { // create `getValue` spy on an object representing the ValueService const valueServiceSpy = jasmine.createSpyObj('ValueService', ['getValue']); // set the value to return when the `getValue` spy is called. const stubValue = 'stub value'; valueServiceSpy.getValue.and.returnValue(stubValue); masterService = new MasterService(valueServiceSpy); expect(masterService.getValue()) .toBe(stubValue, 'service returned stub value'); expect(valueServiceSpy.getValue.calls.count()) .toBe(1, 'spy method was called once'); expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) .toBe(stubValue); }); }); ``` 最後一個 spec 中,`jasmine.createSpyObj` 的第二個參數陣列是這個 spy object 中所包含的假函式,後面串接 `.and.returnValue(stubValue)` 回傳我們設定好的假資料。 --- ### 使用 Angular 提供的 TestBed 除了用上述方式注入 fake service 外,Angular 也提供了類似於 `@NgModule` 的 `TestBed` 來創建模擬的測試模組。 ```javascript= let service: ValueService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ValueService] }); service = TestBed.get(ValueService); }); it('should use ValueService', () => { expect(service.getValue()).toBe('real value'); }); ``` `TestBed.configureTestingModule` 接受 `@NgModule` 大部分的 Metdata; 而使用 `TestBed.get` 可以把在 TestBed 的 providers 中註冊的 Service 注入到測試中。 如果要測試帶依賴的 Service 時,只要把要測試的 service 和模擬的 fake service 都放進 providers 陣列中,在 spec 中我們就可以調用並測試他: ```javascript= let masterService: MasterService; let valueServiceSpy: jasmine.SpyObj<ValueService>; beforeEach(() => { const spy = jasmine.createSpyObj('ValueService', ['getValue']); TestBed.configureTestingModule({ // Provide both the service-to-test and its (spy) dependency providers: [ MasterService, { provide: ValueService, useValue: spy } ] }); // Inject both the service-to-test and its (spy) dependency masterService = TestBed.get(MasterService); valueServiceSpy = TestBed.get(ValueService); }); it('#getValue should return stubbed value from a spy', () => { const stubValue = 'stub value'; valueServiceSpy.getValue.and.returnValue(stubValue); expect(masterService.getValue()) .toBe(stubValue, 'service returned stub value'); expect(valueServiceSpy.getValue.calls.count()) .toBe(1, 'spy method was called once'); expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) .toBe(stubValue); }); ``` :::info 相對於使用自建 fake service 注入,jasmine spy 函式提供許多 matchers 可以追蹤 spy method,確認是否正確綁定和呼叫,如 `toHaveBeenCalled`。 ::: --- ## 測試 HTTP Service ### 手動注入 spy http service 想要測試使用 Angular HTTPClient 的 Service 時,可以像測試帶依賴的 Service 一樣,注入 spy object: ```javascript= let httpClientSpy: { get: jasmine.Spy }; let heroService: HeroService; beforeEach(() => { // 這裏 spy 了 get method,如果用到其他的 HTTP method 也請一並加入陣列中 httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); heroService = new HeroService(<any> httpClientSpy); }); it('should return expected heroes (HttpClient called once)', () => { const expectedHeroes: Hero[] = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]; // asyncData 是需要自行實做產生 observable 的 function // 或選用適合的 RxJS Operator,如: of() httpClientSpy.get.and.returnValue(asyncData(expectedHeroes)); // subscribe 接受兩個 callback function // fail 可以在失敗時,明確指定這個 spec 不成立 heroService.getHeroes().subscribe( heroes => expect(heroes).toEqual(expectedHeroes, 'expected heroes'), fail ); expect(httpClientSpy.get.calls.count()).toBe(1, 'one call'); }); it('should return an error when the server returns a 404', () => { const errorResponse = new HttpErrorResponse({ error: 'test 404 error', status: 404, statusText: 'Not Found' }); httpClientSpy.get.and.returnValue(asyncError(errorResponse)); heroService.getHeroes().subscribe( heroes => fail('expected an error, not heroes'), error => expect(error.message).toContain('test 404 error') ); }); ``` ### 使用 HttpClientTestingModule Angular 有提供專為測試模式使用的 http library:`@angular/common/http/testing`,方便我們攔截並模擬 http service。 #### 環境建置 要使用 HttpClientTestingModule,我們需要先將 HttpClientTestingModule 和 HttpTestingController 引入: ```javascript= import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; // 其他會用到的功能 import { TestBed } from '@angular/core/testing'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; ``` 把 HttpClientTestingModule 加到 TestBed 的 import 中,並且用 TestBed.get 生成 HttpTestingController: ```javascript= describe('HttpClient testing', () => { let httpClient: HttpClient; let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ] }); // Inject the http service and test controller for each test httpClient = TestBed.get(HttpClient); httpTestingController = TestBed.get(HttpTestingController); }); /// Tests begin /// }); ``` 接著就可以撰寫 spec: ```javascript= it('can test HttpClient.get', () => { const testData: Data = {name: 'Test Data'}; // Make an HTTP GET request httpClient.get<Data>(testUrl) .subscribe(data => // When observable resolves, result should match test data expect(data).toEqual(testData) ); // The following `expectOne()` will match the request's URL. // If no requests or multiple requests matched that URL // `expectOne()` would throw. const req = httpTestingController.expectOne('/data'); // Assert that the request is a GET. expect(req.request.method).toEqual('GET'); // Respond with mock data, causing Observable to resolve. // Subscribe callback asserts that correct data was returned. req.flush(testData); // Finally, assert that there are no outstanding requests. httpTestingController.verify(); }); ``` 這段程式碼的執行說明: - 執行 httpClient 並發出 GET Request,這個 Request 會被測試後端攔截處理。 - httpTestingController.expectOne 驗證參數中的 URL 是否被 request 過一次,並在下一行確認 HTTP Method 的正確性。 - 在 flush() 執行時,將 testData 回沖給 httpClient 作為後端傳回的資料,並在此時執行 subscribe 中的 expect。 :::danger 如果漏寫了 flush() ,雖然這筆 spec 還是會顯示綠燈,但並不是因為驗證成功,而是 `.subscribe` 中的 expect 並沒有被執行。 ::: - 最後,httpTestingController.verify 驗證有沒有額外的 request 被執行。 - 如果上述都為真,此 spec 才會綠燈通過。 #### 測試 Request 的其他部分 expectOne 除了用來驗證 URL 外,還可以驗證 request 其他部分是否符合預期: ```javascript= // Expect one request with an authorization header const req = httpTestingController.expectOne( req => req.headers.has('Authorization') ); ``` 如果並非一個請求符合這個期待,則這個 spec 不會通過。 --- ## 測試 Component Class 內部邏輯 對於依賴較簡單且測試內容無關模板的元件,我們可以如同測試 Service 一樣,直接在測試檔案中使用 new 或 TestBed.get 生成元件實體,並且調用內部邏輯來做測試。遇到有相依時,使用上述方式生成假的服務實例注入。 ### 手動注入 ```javascript= // app.component.ts export class AppComponent { constructor(private demoService: DemoService, private stateService: StateService) {} @Input() hero; @Output() selected = new EventEmitter(); click() { this.selected.emit(this.hero); } } // app.component.spec.ts describe('AppComponent', () => { it('raises the selected event when clicked', () => { // 在測試時不需要被追蹤的相依物件,直接生成物件實體注入 const demo = { ... }; // 在測試中希望被追蹤或模擬執行時,可以用 jasmine.createSpyObj 生成並植入 spy const state = jasmine.createSpyObj('StateService', ['yourMethod']); const comp = new AppComponent(demo, state); const hero = { id: 42, name: 'Test' }; comp.hero = hero; comp.selected.subscribe(selectedHero => { expect(selectedHero).toBe(hero); }); comp.click(); }); }); ``` 上例中測試檔案中,comp.selected.subscribe 內的 expect 需要等到 comp.click() 執行時,才會被執行,所以如果漏寫了 comp.click(),該筆 spec 還是會綠燈,但其實內部的 expect 並沒有被執行到。 --- ### 使用 TestBed ```javascript= // app.component.ts export class AppComponent implements OnInit { constructor(private demoService: DemoService, private stateService: StateService, private userService: UserService) {} @Input() hero; @Output() selected = new EventEmitter(); click() { this.selected.emit(this.hero); } ngOnInit(): void { this.click(); } } // app.component.spec.ts describe('AppComponent Test Using TestBed', () => { const demo = { ... }; // 實作 mock service class 來替代真實的 StateService class mockStateService { ... } // 使用 createSpyObj 製作 spy object const user = jasmine.createSpyObj('UserService', ['get']); const hero = { id: 42, name: 'Test' }; beforeEach( () => { TestBed.configureTestingModule({ declarations: [ AppComponent ] providers: [ {provide: DemoService, useValue: demo}, {provide: StateService, useClass: mockStateService}, {provide: UserService, useValue: user} ] }); }); it('should call ngOnInit Method', () => { const comp = TestBed.get(AppComponent); spyOn(comp, 'ngOnInit'); comp.ngOnInit(); expect(comp.ngOnInit).toHaveBeenCalled(); }); it('raises the selected event when ngOnInit', () => { const comp = TestBed.get(AppComponent); comp.hero = hero; comp.selected.subscribe(selectedHero => { expect(selectedHero).toBe(hero); }); comp.ngOnInit(); }); }); ``` 第一個 spec 使用 spy ,測試 ngOnInit() 是否正常被呼叫。第二個 spec 則測試 hero 是否正常被發送。 :::info 要使用 TestBed.get 生成元件實體,需要把元件註冊在 providers 中, ::: --- ## Testing Component with Compiled 當我們希望測試時,先將 component class、HTML 和 css 編譯成完整的 component,可以用 TestBed 提供的 compileComponent 函式: ```javascript= ``` --- ## fakeAsync / tick / flush 當測試中有非同步時,可以使用 fakeAsync: ```javascript= // app.component.ts export class AppComponent { theNumber = 0; theMethod() { setTimeout(() => { this.theNumber = 10; }, 5000); } } // app.component.spec.ts describe('AppComponent', () => { const comp = new AppComponent(); it('test fakeAsync', fakeAsync( () => { comp.theMethod(); tick(5000); expect(comp.theNumber).toBe(10); })); ``` fakeAsync 中的 tick(5000) 等同讓程式時間經過了五秒。 如果不知道需要等待多長時間的話,我們可以使用 flush(),讓 macrotask queue 清空並得到測試結果。flush() 的回傳值是模擬清空 queue 所需的時間(單位 ms)。