Angular Testing 筆記 --- 5 使用 Mock 與 Spy。

這章節會結合之前我們建立的 Login Component 來做測試。首先我們在 Demo.service.ts 裡已經有撰寫好的 isAuthenticated() 這個用來驗證登入的方法,現在Login Component 就會使用這個方法來當作認證使用者的登入辨識(雖然這個方法根本沒有真實的跟後端做確認!)所以我們 login.component.ts 內容如下:
import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators, FormArray } from '@angular/forms'; import { DemoService } from './../demo.service'; @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.scss'] }) export class LoginComponent implements OnInit { public loginForm: FormGroup; constructor( private fb: FormBuilder, private demo: DemoService ) { } 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}`); if (this.demo.isAuthenticated()) { alert('login success'); } else { alert('login failed!'); } } }

上述程式碼中我們使用 demo service 裡的 isAuthenticated() 來做簡單的驗證。有 token 就顯示登入成功,沒有 token 就顯示登入失敗。

接著我們來看看預設login.component.spec.ts
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { LoginComponent } from './login.component'; describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture<LoginComponent>; beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ LoginComponent ], imports: [ReactiveFormsModule] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); });

以上的這個測試會發生問題,錯誤的地方是在我們使用了 Reactive Form 在 login component 所以測試時會出現認不得有關 Reactive Form 在 DOM 上面相關的 tag。

ps:關於這個預設測試檔中的 async 這個 keyword 我們之後在另外討論。

這邊我們 import Reactive Form Module 進來,並且在 Test.configureTestingModule 這個地方也把 Reactive Form Module 加進來。
// for reactive form test import { ReactiveFormsModule } from '@angular/forms';
beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ LoginComponent ], imports: [ReactiveFormsModule] // for Reactive Form }) .compileComponents(); }));
....... 略 .......

這樣基礎的測試應該就會 pass了!

createComponent(LoginComponent):

在我們設定好測試環境後(TestBed) 我們呼叫 createComponent來建立 LoginComponent instance 與增加相關的測試元件與 Dom 物件,並且回傳 ComponentFixture

這邊要注意的事~不要在執行 createComponent() 之後又修改 TestBed 的相關定義,因為createComponent() 會凍結目前的 TestBed 定義除非關閉它才能進行新的配置。
此外~你不能再使用任何 TestBed 的後續配置方法,如: configureTestingModule()、get()、也不能使用任何 overriding 方法。如果這樣做, TestBed 就會丟出錯誤。

ComponetFixture:

這是一個測試我們剛剛建立的Component與它相關元件的互動界面。
要訪問這個 instance 可以透過 componentInstance 來訪問。
fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance;


detectChanges():

我們透過 fixture.detectChanges() 告訴 TestBed 去做 data binding 。
例如:
我們常用變數來放置 Title 實際要顯示的字串,但是要在detectChanges()之後 angular 才會把typescript 那邊的變數內容跟binding到 Dom上面

此外他給測試人員一個機會在Angular 初始化 data binding 以及 lifecycle hooks 之前探查並改變component的狀態。

例如:
下面這個測試會在 fixture.detectChanges() 之前修改原本的 title 屬性。
it('should display a different test title', () => { component.title = 'Test Title'; fixture.detectChanges(); expect(h1.textContent).toContain('Test Title'); });

真實使用 Service

首先我們來看看真實使用 isAuthenticated() 的寫法。
首先一樣 import Demo service 並且修改 TestBed.configureTestingModule 這邊的設定。
此外我因為懶的一直宣告變數去拿 demo service 所以多用了一個 service 變數在第二個 beforeEach 內先assign 好 demo service。
// our isAuthenticated service. import { DemoService } from './../demo.service';

describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture<LoginComponent>; let service: DemoService; // for isAuthenticated test beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ LoginComponent ], imports: [ReactiveFormsModule], // for Reactive Form providers: [DemoService] // for isAuthenticated }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; service = TestBed.get(DemoService); // for isAuthenticated test fixture.detectChanges(); });

由於測試的時候我們必須產生一個 localStorage 的 token 所以每次結束測試的時候必須要把 token 刪除。這邊我們使用 afterEach() 來實做。
afterEach(() => { localStorage.removeItem('token'); });

開始撰寫 isAuthenticated 的測試。

// 使用真實的 isAuthenticated it('should login failed when user is not authenticated', () => { expect(service.isAuthenticated()).toBeFalsy(); });
// 使用真實的 isAuthenticated it('should login success when token object exist.', () => { localStorage.setItem('token', '12345'); expect(service.isAuthenticated()).toBeTruthy(); });

第一個測試中我們並沒有產生一個實際的 token 所以認證會失敗也因此測試結果也會是符合我們的期待(expected)。

第二個測試中我們預先產生了一個內容為 '12345' 的 token,所以認證應該要通過測試結果也會符合我們期待(expected)。

我們會發現採用真實的 isAuthenticated() 的麻煩處就是要針對 service 去『』出他需要的東西並且最後還要知道那些生出來的東西需不需要清掉( afterEach )避免一些記憶體的 leak

所以如果我們可以用一個『假的isAuthenticated 而不用真的去生 token 會比較方便一點,這就是 Mock 的功用。

Mocking with fake Class


當然 Mock 的缺點就是要額外寫一個很像 isAuthenticated 的物件來當作 service 使用。
這邊我們另外寫一個 describe 作為 Mock 的使用並且測試。然後在這個新的 describe() 上方寫一個 Mock AuthService 如下:

Mock Auth service。
// --- Mocking with fake Class --- class MockAuthService { authenticated = false; isAuthenticated(): boolean { return this.authenticated; } }

新的 describe()
describe('LoginComponent with mock', () => { let component: LoginComponent; let fixture: ComponentFixture<LoginComponent>; let service: MockAuthService; beforeEach(async() => { TestBed.configureTestingModule({ declarations: [LoginComponent], imports: [ReactiveFormsModule], providers: [MockAuthService] }) .compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; service = TestBed.get(MockAuthService); fixture.detectChanges(); }); it('should be create.', () => { expect(component).toBeTruthy(); }); it('should login failed when user is not authenticated', () => { service.authenticated = false; expect(service.isAuthenticated()).toBeFalsy(); }); it('should login success when user is authenticated', () => { service.authenticated = true; expect(service.isAuthenticated()).toBeTruthy(); }); });

首先我們建立一個 Mock的 Authenticated 並且 method 的名稱也是取的跟 demo service 裡面的isAuthenticated() 一模一樣並且用這個 Mock 的物件取代原先的 demo service 來建立測試環境。

然後透過修改 MockAuthService 的 authenticated 來控制我們測試 isAuthenticated() 回傳的內容。

好處就是:

  • 我們取代原本的 demo service 甚至連 import 都不需要。
  • 就算後來 demo service 裡的 isAuthenticated() 做了修改,我們的測試還是可以進行。只需要修改 MockAuthService的結果與 demo service 內的結果一致就好。

Mocking by overriding functions

另一種我們透過 overriding 的方式來繼承原本的 demo service 做測試,差別於 Fake Class 的就是我們是用 extend 的方式繼承 demo service 並且修改 isAuthenticated() 這個方法。

Mocking by overriding
// ----- Mocking by overriding functions ------ class MockAuthServiceExt extends DemoService { authenticated = false; isAuthenticated() { return this.authenticated; } }

而 describe() 部份也是大同小異,只是記得 providers 內要改成 MockAuthServiceExt 這個物件。

Mocking with real service with Spy

有時候可能 service 的內容比較複雜很難用 fake class 或是 overriding 的方式去改寫,但是我們又不想去產生這個 service 需要的基礎資料,這個時候就可以改用 Spy 的方式來做測試。

首先我們跟使用真實 service 的方法一樣建立測試環境。主要差別在撰寫 it() 時後的呼叫 isAuthenticated() 的作法。
it('should login success when user is authenticated', () => { // spyOn spyOn(service, 'isAuthenticated').and.returnValue(true); expect(service.isAuthenticated()).toBeTruthy(); }); it('should login failed when user is not authenticated', () => { // spyOn spyOn(service, 'isAuthenticated').and.returnValue(false); expect(service.isAuthenticated()).toBeFalsy(); });

簡單的說 spyOn 像間諜一樣會針對你給的目標(service)內的成員(isAuthenticated)給與回傳的假資料,這樣我們不用真的去建立 token 來讓 demo service 內的 isAuthenticated() 使用。

Spy方便在於對於一些需要透過 Api 跟後端拿資料做測試的時候,就可以用 Spy 直接在程式內建立測試用 Data 而不用真的有個後端、資料庫去拿。關於 SpyOn 可以到官方網站看更多詳細的資料

總結:

上述這幾種就依需求去採用,我自己是還蠻常使用 Spy 的方法來做測試供大家參考囉。--- 待續



留言