# 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)。