Angular Testing 筆記 --- 3 測試 service 元件。

這次我們來介紹怎麼測試 Angular 的 service 元件。 service 元件通常在前端比較常見的功能就是向後端要求資料,所以 API 的功能通常都會寫在 service 元件裡面。這邊我們會建立一個測試 service 元件的專案。一樣先提供我的開發環境。

開發環境:


  1. Ubuntu:  18.04 LTS
  2. Angular-Cli:  7.0.7
  3. bootstrap:  4.2.1
這邊我們先建立一個新專案。

# ng new mytest --routing

然後在建立一個login的component。

# ng  g  c  login

接著在建立一個demo的 service。
# ng g s demo

所以目前我們有一個 logincomponent 跟一個 demoservice專案內。

接著把 app.component.html 內的html code 刪除只留下最後一行的

然後我們把 Angular 產生的 app.component.spec.ts 的預設測試程式部份刪除只保留如下的內容。


由於我使用bootstrap 做簡單的 css style 處理所以請安裝bootstrap,安裝方式就看你要使用npm 或是 yarn了。

npm :
# npm install bootstrap --save

yarn:
# yarn add bootstrap

由於要使用bootstrap提供的css 所以要在 angular.json 裡面找到 build -> styles 加入 bootstrap css的路徑:
"./node_modules/bootstrap/dist/css/bootstrap.min.css"
angular.json
然後修改 app-routing.module.ts 讓網址自動轉址到login component。

Login component:

在 login.component.html 裡面程式碼打入下述內容:

<section id="loginComp" class="loginComp col-xs-12 col-sm-12 col-md-12 col-lg-12">
<article id="loginTitleSec" class="col-xs-12 col-sm-12 col-md-12 col-lg-12"> <h4 class="">登入帳號</h4> </article> <section class="loginData col-xs-12 col-sm-12 col-md-12 col-lg-12" id="loginData"> <form [formGroup]="loginForm" (ngSubmit)="onSubmit()" id="loginForm"> <div class="form-group input-group col-xs-12 col-sm-12 col-md-12 col-lg-12"> <input type="email" name="email" id="email" formControlName="email" class="form-control email" placeholder="電子郵件(用於帳號登入)"> </div> <div class="form-group input-group col-xs-12 col-sm-12 col-md-12 col-lg-12"> <input type="password" name="password" id="password" formControlName="password" class="form-control password" placeholder="密碼"> </div> <button type="submit" class="btn btn-primary col-xs-12 col-sm-12 col-md-12 col-lg-12" [disabled]="loginForm.invalid"> 登入 </button> </form> </section> </section>

由於這個頁面的 form 表單我用的是 Angular 的 ReativeFormsModule 所以我們要先把ReactiveFormsModule 載入到 app.modules.ts 裡。修改 app.module.ts 加入 ReactiveFormsModule:


回到 login.component.ts 我們要把 ReactiveForm 對應到 html 表單上面要給使用者輸入的欄位名稱。
import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators, FormArray } from '@angular/forms';
@Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.scss'] }) export class LoginComponent implements OnInit {
public loginForm: FormGroup;
constructor(private fb: FormBuilder) { }
ngOnInit() { this.loginForm = this.fb.group({ email: ['', [ Validators.required, Validators.minLength(5), Validators.pattern( '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$') ]], password: ['', [ Validators.required, Validators.minLength(8), Validators.pattern( '^(?=.*[0-9]+.*)(?=.*[a-zA-Z]+.*)[0-9a-zA-Z]{8,20}$') ]] }); }
onSubmit() { console.log(`email: ${this.loginForm.value.email}`); console.log(`email: ${this.loginForm.value.password}`); }
}

簡單的說明也就是 loginForm 會對應到 html 的 form 表單( [formGroup]="loginForm"),然後 loginForm 裡的 email 物件會對應到 input type="email"的DOM(因為 formControlName="email")並且這個欄位為必填( Validators.required)、最小長度為5( Validators.minLength(5))並且內容必須為 email 格式 (Validators.pattern())。password也是如上。

  • 有關 Angular Reactive Form 的用法可以參考官方網站的說明。

然後我們執行 ng serve 開啟 localhost:4200 應該就會看到下面如圖的login 畫面。

以上的 login component 會在之後的章節用到,所以就先把他建好吧!

demo service 部份:

總算要進入正題了 😁 首先我們在 demo.service.ts 加入我們的 method 如下:
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
@Injectable({ providedIn: 'root' }) export class DemoService { protected value = 'Hi Clover'; constructor() { }
getValue() { return this.value; } setValue(value: string) { this.value = value; }
getObservableValue() { return of('observable Clover'); } getPromiseValue() { return Promise.resolve('promise value'); } }

上面這幾個其實只是 return 一些字串、Promise或是 Observable 物件回去,這是我們在前端很常會這樣做的功能。再來就是要開始針對 demo.service 撰寫測試了。

demo.service.spect.ts:

開啟這個檔案我們會先看到 Angular 預先寫好的一些簡易測試如下

簡單的說明一下這邊的內容:

TestBed :

可以說是 Angular 測試工具最重要的東西,它會建立一個動態的、虛擬的、可初始化的 Angular @NgModule 來當作我們測試的平台。

TestBed.configureTestingModule():
這個 method 會設定一個擁有@NgModule 的物件。

所以我們可以看出程式就是在'DemoService'  內 it 這個測試之前,我們會產生一個@NgModule的物件。這個物件其實就是我們拿來模擬 app.module.ts 的功用。但是由於我們是作單元測試因此不像我們的app.module.ts 一樣 declaretions 跟 import 一堆東西。

it() 這段裡面我們會看到一段 TestBed.get(DemoService) 的程式碼就是從 TestBed這個 instance 取得 DemoService,而下方的expect 就是期待 service是存在的。

不過我們可以把 beforeEach() 做點修改如下:
beforeEach(() => TestBed.configureTestingModule({ providers: [DemoService] }));

這會比較接近我們在寫Angular 時一開始設定 @NgModule 的模式,這樣也是比較方便在我們之後如果測試的 service 或是 Component 需要依賴其他服務的時候可以透過 providers 加入更多的 service 再做測試。

另一種則是在 it() 裡面我們 inject service 進去,程式碼如下:

這邊就是依需求去看需要用哪種方式去寫測試了。

現在我們可以先執行 ng test來看看結果。
# ng test



這邊我們就先採用 Angular 預設的作法開始寫對於 methods 的測試。

單純的 return Value:

首先我們看到 demo.getValue() 必須 return "Hi Clover" 這個字串,所以就可以這樣子測試:
describe('DemoService', () => { let service: DemoService; beforeEach(() => TestBed.configureTestingModule({ providers: [DemoService] }));
it('should be created', () => { service = TestBed.get(DemoService); expect(service).toBeTruthy(); });
it('should return "Hi Clover" when call getValue().', () => { service = TestBed.get(DemoService); expect(service.getValue()).toBe('Hi Clover'); }); });

這個很容易理解的寫法。

 Promise method:


it('should return "promise Clover" call getPromiseValie().',
() => { service = TestBed.get(DemoService); service.getPromiseValue().then(
value => expect(value).toBe('promise Clover')); });


Observable methoid:


it('should return "observable Clover" call getObservableValue().', () => { service = TestBed.get(DemoService); service.getObservableValue().subscribe( res => { expect(res).toBe('observable Clover'); }); });


接下來我們在
demo.service.ts 這個 service 裡面多寫一個 function 這個 function 的內容很接近實際我們前端網頁在login之後被把 token 存在 local Storage 的方法。但是我們這邊很簡單的就只做檢查 browser 是否有沒有這個 token。
isAuthenticated(): boolean { return !!localStorage.getItem('token'); }


然後在 demo.service.spec.ts 內獨立再寫一個 describe() 針對這個 isAuthenticated() 做測試。

// for isAuthenticated test describe('isAuthenticated testing', () => { let service: DemoService; beforeEach(() => { service = new DemoService(); });
afterEach(() => { service = null; localStorage.removeItem('token'); });
it('should return true from isAuthenticated when there is a token', () => { localStorage.setItem('token', '1234'); expect(service.isAuthenticated()).toBeTruthy(); }); });

這邊我們看到在 beforeEach() 內用了 new 的方法來產生 DemoService ,這個方法跟之前的用TestBed.configureTestingModule()不同,然後我們多寫了一個 afterEach() 裡面我們在每個測試之後會把 service 清空然後用 localStorage 把測試用的 token 清掉。

然後在 it() 裡面我們在測試時由於並沒有真的 token 在  browser 的 local storage 內,所以在測試的時候我們可以用一個假的token來當作實際資料使用,這樣子也能模擬真實環境通過測試結果。

所以在測試的時候也是需要用一些所謂的 mock data 作為模擬真實環境的測試方式。

測試的 Service 有 depend service:

有時候我們測試的 service 本身可能會透過DI而注入他需要依靠的另外service。這個時候要怎麼測試呢?我們可以來看看以下幾種方式。

1. 首先我們先建立一個給 demo 注入的 depend service 來讓 demo 有 depend service。

# ng g s  demodepend

2. 然後我們在這個 demodepend 很簡單寫一個 giveValue method,他的作用只是 return value而已。
export class DemodependService { public value = 'Hello World'; constructor() { }
giveValue(): string { return this.value; } }

3.  我們修改 demo.service.ts 來注入(DIdemodepend.service ,主要是 import  demodepend service 並且在 constructor 裡注入。
import { DemodependService } from './demodepend.service';

export class DemoService { protected value = 'Hi Clover'; constructor(private depServ: DemodependService) { } .... 略 ......

4. 我們在 demo.service.ts 內新增一個 method 是會使用到 demodepend service的:
/** * show DemodependService value. */ showDepValue() { return this.depServ.giveValue(); }

這邊我們應該很簡單的知道 showDepValue() 就只是呼叫 demodepend service 的 giveValue() 然後把 demodepend 的 value  'Hello World' 回傳回來而已。接下來就是開始著手寫測試了。

測試手法:

1. 真實的把 depende service 也一起建立:

在我們測試 demo.service的時候也把 demodepend service  import 進來。所以我們一樣先在測試程式 import 它。
import { DemodependService } from './demodepend.service';

然後我們一樣針對有 depend service 的 method 再寫一個 describe()。
// for depende servicer describe('test with DI Demo service', () => { let service: DemoService;
it('giveValue should return value from the real service', () => { service = new DemoService(new DemodependService()); expect(service.showDepValue()).toBe('Hello World'); }); });

這邊在 service = new DemoService() 時,參數裡面又 new depend service (DemodependService)進來,這方式就是直接把depend service 一起建立。

但是這種方法有時候並不實用,如果你的 depend service 需要給一堆特殊參數或是他本身又需要其他 service 或是他是一個真實會去跟後端拿資料的 service 這樣子可能就很不容易去做真實建立的方式。

2. 使用 fake service

這個方法就是我們 fake 一個 demodependService如下:

export class fakeService extends DemodependService { value = 'fake hello world'; }

這個我們把他寫在 describe()的 scope 外等等用來做假 DemodependService 的使用,測試的程式碼我們就可以寫成如下:
it('giveValue should return faked value from fakeService', () => { service = new DemoService(new fakeService()); expect(service.showDepValue()).toBe('fake hello world'); });

我們做單元測試希望可以盡量去耦合! 讓 DemodependService 有他自己的測試,demo service 也是自己專屬的測試而不需要去測到 DemodependService。如果 DemodependService 他本身的測試有pass 我們就假設它本身是沒問題的。所以還可以用 fake object 來當作測試使用。

3. 使用 fake Object:

it('giveValue should return faked value from fake object.', () => { const fake = { giveValue: () => 'fake object hello world'}; service = new DemoService(fake as DemodependService); expect(service.showDepValue()).toBe('fake object hello world'); });

這邊我們假設 DemodependService本身沒有問題,所以直接 fake 一個物件當作 depend Service ,所以這邊 fake 物件就是當作 DemodependService 來做測試。

4. 使用 Jasmine.createSpyObj:

另外一種就是使用 jasmine.createSpyObj的方式產生我們 depend service ,有點類似上述的 fake Object,但是由於 jasmine 提供一些額外 method 可以使用。
it('getValue should return return value from jasmine spyObj', () => { const depServSpy = jasmine.createSpyObj( 'DemodependService', ['giveValue']); const stubValue = 'stub value'; depServSpy.giveValue.and.returnValue(stubValue); service = new DemoService(depServSpy);
expect(service.showDepValue()).toBe('stub value'); expect(depServSpy.giveValue.calls.count()) .toBe(1, 'spy method was called once'); expect(depServSpy.giveValue.calls.mostRecent().returnValue) .toBe(stubValue); });

這邊我們用 jasmine.createSpyObj 建立一個名為 DemodependService 並且有一個 method 為 giveValue的屬性。
接著我們用 stubValue 這個變數存放這個 depServSpy 要回傳的字串內容,然後用depServSpy.giveValue.and.returnValue(stubValue) 來對對 depServSpy的giveValue指定回傳的訊息為stubValue ("stub value")。這樣子就可以在 new DemoService時一併把這個spy service 建立起來做測試。

最後兩個 expect 的測試可以看到 jasmine 的 spyObj 本身還有提供一些如: calls.count() 、 calls.mostRecent() 這種的method 提供使用。

不過個人最愛用的方法是 spyOn 這個待最後再來介紹。

測試 Http service:

在 angulaar 裡面對於 http service 通常都是使用 rxjs 的 observable 作為 asnyc 方法的使用,所以在測試 http service 也會使用 observable 作為測試的要件之一。

這邊我們新增一個 http 的 service 作為我們測試的對象。

# ng g s myhttp

然後在 app.module.ts 裡 import  HttpClientModule :
import { HttpClientModule } from '@angular/common/http';

imports: [ BrowserModule, AppRoutingModule, ReactiveFormsModule, HttpClientModule ],

回到 myhttp.service.ts 我們簡單的撰寫一個 getUsers() 的 method:
import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { Observable, of } from 'rxjs'; import { catchError, map, tap } from 'rxjs/operators';
const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json'}) };
@Injectable({ providedIn: 'root' }) export class MyhttpService { readonly userUrl = 'api/users'; // url to web api
constructor(private http: HttpClient) { }
getUsers(): Observable<DataForm[]> { return this.http.get<DataForm[]>(this.userUrl) .pipe( catchError(this.handleError('getUsers')) ) as Observable<DataForm[]>; }
private handleError<T> (operation = 'operation', result?: T) { return (error: any): Observable<T> => { console.error(error); return of(result as T); }; } }
export class DataForm { name: string; id: number; }

實際上因為我們沒有真實的後台,所以那個 userUrl 實際是沒有伺服器去接的。這邊我們就暫時以這個 http service 作為我們測試的目標。

接著我們到 myhttp.service.spec.ts 來撰寫測試

1. 使用 Jasmine.createSpyObj:

首先我們必須 import Observable 的 of 與 MyhttpService :
import { of } from 'rxjs';
import { MyhttpService, DataForm } from './myhttp.service';

我們把 Angular 產生的 預設測試那段 describe() 刪除,改寫成如下:
describe('MyhttpService by createSpyObj', () => { let httpClientSpy: { get: jasmine.Spy }; let myhttpServ: MyhttpService;
beforeEach(() => { httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); myhttpServ = new MyhttpService(<any> httpClientSpy); });
it('should return expected {id:1, name: "clover"}', () => { const expectedData: DataForm[] = [{id: 1, name: 'clover'}]; httpClientSpy.get.and.returnValue(of(expectedData));
myhttpServ.getUsers().subscribe( res => { expect(res[0].id).toBe(1); expect(res[0].name).toBe('clover'); expect(res).toEqual(expectedData); }); }); });

2. 使用 SpyOn :

我個人比較喜歡這個用法!這邊我們先 import Angular 的 HttpClientModule ,因為我比較喜歡用 TestBed.configureTestingModule()的寫法,比較像在寫 @NgModule。
import { HttpClientModule } from '@angular/common/http';

然後我們寫一個新的 describe() 如下:
describe('MyhttpService by SpyOn', () => { let myhttpServ: MyhttpService;
beforeEach( () => { TestBed.configureTestingModule({ imports: [HttpClientModule], providers: [MyhttpService] }).compileComponents(); });
beforeEach( () => { myhttpServ = TestBed.get(MyhttpService); spyOn(myhttpServ, 'getUsers').and.returnValue( of([{id: 1, name: 'clover'}]) // Observable ); });
it('should be created', () => { const service: MyhttpService = TestBed.get(MyhttpService); expect(service).toBeTruthy(); });
it('should get id:1 name: clover.', () => { myhttpServ.getUsers().subscribe( res => { expect(res[0].id).toBe(1); expect(res[0].name).toBe('clover'); }); }); });

spyOn 其實就是把原本的 myhttpServ 裡的 getUsers() 原本要回傳的東西改成我們指定的內容做測試。當然你或許會覺得『回傳你自己指定的內容這樣的測試好像有測跟沒測一樣但是其實我們Http 測試本身就是在沒有伺服器的狀況下做測試。所以比較像是做了一個『假的回傳資料』來驗證這個 service 有沒有正常運行。
而且 spyOn 的優點是不只可以用在 Http Service 只要是 angular 的 Service 都可以用這個方式來測試,算是我最常用的方式吧!
------ 待續

相關程式碼放在我的個人 github ,有需要的可以下載來玩玩。



REF:

留言