# Angular Unit Testing ## 測試程式的架構邏輯 下方是我們要測試的function ```javascript= function helloWorld() { return 'Hello World'; } ``` 接著是我們的測試程式碼: ```javascript= describe('Hello World', () => { it('show hello world', () => { expect(helloWorld()).toEqual('Hello World'); }); }); ``` 1.從測試程式碼中可以看到 describe(string, function); 這個開頭。 後方的 function 這是Test Suite 裡面會有許多我們的關於 string (Hello World)的獨立單元測試,所以可說function是單元測試的集合。 2.it(string, function) 這個定義了我們每一個獨立的單元測試,這會包含一個或是多個的Expectation(期望)。 3.expect(actual) 這個描述我們稱之為 Expectation (期望)後方會接著呼應的結果(Matcher)來描述我們這個測試『期望的結果』是如何,通常這個matcher(expected)的描述就是我們說的呼應結果(Matcher)其值為boolean。 4.期待值(expected)與實際值(actual value)被丟到expect這個function內做比較,如果出來的值為false就代表這個測試失敗。 所以上述的那段測試碼就是說:這一段是做 Hello World的相關測試,然後 show hello world 這個單元測試就是~期望(expect)helloWorld()這個function 會等於(toEqual) 'Hello World'這個字串。 有時候在測試某些功能時我們會預先建立一些物件、服務之類的或是在單元測試結束後要求清除一些檔案或是變數因此Jasmine提供了下列幾個特殊Function供我們測試時做『預處理』或是『後處理』。 ### beforeAll 這個function只會在describe()內的所有測試之前被呼叫一次。 ### afterAll 這個function會在所有的 describe() 都做完之後被呼叫一次。 ### beforeEach 這個function會在每一個 it function執行之前被執行。 ### afterEach 這個function會在每一個單元測試後被呼叫。 #### 2019-01-07 補充: 忽略某些測試的方法 如果想要 Jasmine 在測試的時候忽略某些已經寫好的測試碼時,除了註解掉之外另外一種就是在 describe() 或是 it() 的前面加上 'x' 也就是變成 xdescribe() 或是 xit() 。這樣 Jasmine 一樣會列出這些測試的名稱但是不會真的進行測試,而且結果的圈圈會打上灰色的圓圈。 #### 專注某些測試 同樣專注某些測試的方法就是在 describe() 與 it() 前面加上 'f' ,這樣在測試結果的 report 中會把這些需要專注的測試清單列在最前面,連同失敗或是成功的綠色圈圈也會被列在最前方。 ## 測試 service 元件 Service 元件通常在前端比較常見的功能就是向後端要求資料,所以 API 的功能通常都會寫在 service 元件裡面。這邊我們會建立一個測試 service 元件的專案。一樣先提供我的開發環境。 ## TestBed : 可以說是 Angular 測試工具最重要的東西,它會建立一個動態的、虛擬的、可初始化的 Angular @NgModule 來當作我們測試的平台。 ## TestBed.configureTestingModule(): 這個 method 會設定一個擁有@NgModule 的物件。 所以我們可以看出程式就是在'DemoService' 內 it 這個測試之前,我們會產生一個@NgModule的物件。這個物件其實就是我們拿來模擬 app.module.ts 的功用。但是由於我們是作單元測試因此不像我們的app.module.ts 一樣 declaretions 跟 import 一堆東西。 # Angular Testing - Angular Taiwan ## 為什麼要寫測試 1.程式發生錯誤,怎麼模擬出當時的狀況及 Debug? 2.改了某個 function,會不會導致別的程式掛掉? 3.到底測過了哪些功能,有沒有漏掉的沒測? ## 目的 1.能快速提供反饋。 2.提升程式碼品質。 3.節省測試與除錯時間。 4.整體專案時程縮短。 ## 種類 1.單元測試 2.整合測試 3.驗收測試 ### 單元測試 1.用來模擬外部如何使用廁是目標物件,驗證其行為是否符合預期。 2.從程式最小的功能開始。 3.單元指的是一個類別或模組。 ### 整合測試 將兩個以上的類別做整合,測試它們之間的運作關係是不是正確的。 ### 驗收測試 1.系統行為與功能面的規範。 2.用來說明某一個 user story。 3.從使用者的角度來檢視 (是否符合使用者的期望)。 ## 心法 1.在編寫某個功能的代碼之前先編寫測試代碼,然後只編寫使測試通過的功能代碼。 2.所有的實現都是測試「逼」出來的,所有的實現代碼都是為了讓測試通過而編寫的。 3.先以調用方的角度來調用代碼,並且從調用方的角度說出所期望的結果。 4.編寫測試時就僅僅關注測試,不想去如何實現。 ```javascript= //先寫好測試範例 it('應該說 Him Leo', () => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app.hi('Leo').toEqual('Hi, Leo')); }); //從測試知道應該是寫一個 hi function 來帶入字串參數 hi(name: string):string { return `Hi, ${name}`; } ``` 5.絕不跳過重構。 6.出錯後放慢腳步。 ## 覆蓋率 1.測試程式碼的涵蓋範圍。 2.請把覆蓋率的數字當作一個健康指標,用來檢查: - 重要的 Scenario 有沒有涵蓋多一點情境? - 現在有哪些程式碼沒有被測試過? - 發生測試失敗時,失敗的原因點是否有被測試案例涵蓋到? ## TDD vs ATDD - TDD:測試驅動開發(Test-Driven Development) - ATDD:驗收測試驅動開發(Acceptance Test Driven Development) ### 軟體開發過程中最常見的問題 - 用戶想要的功能沒有開發。 - 開發的功能並非用戶想要。 - 用戶和開發人員所說語言不同。 ![](https://i.imgur.com/a5iL1aW.png) ### ```javascript= // describe 描述測試的 component describe('PrivateCallComponent', () => { let component: PrivateCallComponent; let fixture: ComponentFixture<PrivateCallComponent>; // beforeEach 是在每一個 it 之前先執行的事情 beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ PrivateCallComponent ] }) .compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(PrivateCallComponent); component = fixture.componentInstance; fixture.detectChanges(); }); // beforeALL 在所有的 it 之前只執行這一次 beforeALL(); // afterEach 在每一個 it 之後做的事情 afterEach(); // it 是測試案例 it('should create', () => { expect(component).toBeTruthy(); expect(true).toBeTruthy(); }); // 在 function 前面加上 x 可以忽略不測試該功能,或是加上 f 只測試該功能 // 例如 xit 、 fit // 也可以巢狀寫法,describe 裡面在有 describe, beforeEach 也能寫巢狀,只會影響同一層範圍。 describe('By Leo', () => { it('應該說 Hi, Leo', () => { except(app.hi('Leo')).toEqual('Hi, Leo'); }); }); }); ``` # Jasmine & Protractor 簡介 - Angular Taiwan Jasmine & Protractor 這兩個前端測式框架,可以被使用在任何的前端網站開發中,在 angular-cli 中也有被應用。 ## Jasmine 一套 JS 的測試 framework ,從 2009 年開始至今,是一套非常老牌的框架。 ```javascript= // install jasmine npm install --save-dev jasmine // initialize Jasmine in your project ./node_modules/.bin/jasmine init ``` ```javascript= // describe 描述測試 describe('first test', () =>{ // it 是單一測試規格 it('hello world'. () => { // expect 是預期的期望值 expect(true).toBe(true); }); // toEqual 值必須是相等的 it('toEqual', () => { let a = 11; expect(a).toEqual(11); // true expect(a).toeEqual(12); //fail expect(a).not.toeEqual(12); //true }); // less & greater it('less & greater', () => { let pi = 3.14159; let e = 2.78; expect(e).toBeLessThan(pi); //true expect(e).not.toBeGreaterThan(pi); //true }); // throw 呼叫 function時候,拋出指定的值 it('throw', () => { var foo = function () { throw new TypeError ('foo bar baz'); }; expect(foo).toThrow(); expect(foo).toThrowError('foo bar baz'); expect(foo).toThrowError(/bar/); expect(foo).toThrowError(TypeError); expect(foo).toThrowError(TypeError, 'foo bar baz'); }); }); ``` ### Spies 在測試中,mock 的手法經常用到(在 jasmine 中叫做 spy),尤其是要呼叫 api或是使用第三方套件時,那些 function 我們認為是正確的,所以不希望包進來測試,就做模擬的方式取代掉真實的呼叫,專注於我們自己所要測試的功能上。 ```javascript= describe('a spy', function(){ var foo, bar = null; beforeEach(function() { foo = { setBar: function(value) { bar = value; } }; spyOn(foo, 'setBar'); foo.setBar(123); foo.setBar(456, 'another param'); }); it('tracks that the spy was called', function() { expect(foo.setBar).toHaveBeenCalled(); }); it('tracks all the arguments of its calls', function() { expect(foo.seBar).toHaveBeenCalled(123); expect(foo.seBar).toHaveBeenCalled(456, 'another param'); }); it('stops all execution on a function', function() { expect(bar).toBeNull(); }); }); ``` ```javascript= describe('a spy', function(){ var foo, bar = null; beforeEach(function() { foo = { getBar: function() { return bar; }, getBar1: function() { return bar; } }; spyOn(foo, 'getBar').and.returnValue(789); spyOn(foo, 'getBar1').and.callFake(() => { return 0; }); }); it('return value', function() { expect(foo.getBar()).toEqual(456); //error value是789 }); it('call fake', () => { expect(foo.getBar1()).toEqual(0); }) }); ``` ## Protractor 由 Angular 官方出的一套 end-to-end(e2e)的 framework. 測試期間會開啟一個瀏覽器去執行所要測試的項目,如果是使用 angular-cli 所產生的專案。預設就已經包含,設定放在根目錄底下的 protractor.conf.js ```javascript= //透過一個 webdriver 去取得瀏覽器的內容 npm install -g protractor webdriver-manager update webdriver-manager start #http://localhost:4444/wd/hub ``` 接著要設定一個 conf.js 檔,並且寫好測試檔 spec.js ```javascript= exports.config = { framework: 'jasmine', seleniumAddress: 'http://localhost:4444/wd/hub', specs:['spec.js']//測試規格檔 } ``` ```javascript= // spec.js describe('Protractor Demo App', function() { it('should have a title', function() { browser.get('http://juliemr.github.io/protractor-demo/'); expect(browser.getTitle()).toEqual('Super Calculator'); }); beforeEach(() => { browser.get('http://juliemr.github.io/protractor-demo/'); }); it('get first input', () => { let elm = element(by.css('.input-small')); elm.sendKeys('1'); expect(elm.getAttribute('value')).toEqual('1'); }) it('get all input',() => { let els = element.all(by.css('.input-small')); //els 是 array let first = els.get(0); // 取 array 中的第一個 let first1 = els.first(); // 取 array 中的第一個 first.sendKeys('1'); let sec = els.get(1); // ley sec = els.last(); sec.sendKeys('2'); browser.sleep(1000); }) }); ``` Protractor 執行指令 ```javascript= protractor conf.js ``` # Karma 測試框架 - Angular Taiwan ## Angular 測試三劍客 - Jasmine: 提供測試撰寫所需的工具,搭配測試運行框架在瀏覽器上執行測試。 - Karma: 測試運行框架,為開發中執行 unit test 的好選擇。(紅燈、綠燈、重構) - Protractor: End to End Test 專用。模擬使用者的操作,來判斷程式在瀏覽器上正常與否。 ## Jasmine ### 簡介 - 專門用來撰寫 JavaScript 測試的框架 - 完全不一賴於其他的 JavaScript 框架 - 語法輕巧且明確,撰寫容易 ## Karma Karma是個既簡單又快速的測試框架,旨在幫助開發人員能夠迅速的進行自動化單元測試。 ### 優點 - 執行速度快 - 可在真實環境中執行,且跨平台 - 擴充性高 - 可遠端控制 - 支援CI ### 缺點 - 無 no serve 指令 ### Karma 環境設定 ```javascript= module.exports = function(config) { config.set({ basePath:'', frameworks:['jasmine', '@angular/cli'], file:['src/app/**/**.spec.ts'], plugins:[ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular/cli/plugins/karma') ], client:{ clearContext: false // leave Jasmine Spec output visible in browser }, coverageIstanbulReporter:{ reports:['html', 'lcovonly'], fixWebpackSourcePaths:true }, angularCli:{ environment:'dev' }, reporters:['progress', 'kjhtml'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch:true, browsers:['Chrome'], singleRun:false }); }; ``` ### 單元測試 - TestBed 在 Angular test 當中扮演著最基礎也是重要的角色。 - 透過 configure TestingModule 方法,即可建立測試環境。 - 當環境設置完成後,complieComponents 會以非同步方式進行編譯。 ```javascript= TestBed.configureTestingModule({ declarations: [ AppComponent ] }); ``` 將 TestBed 設定放到 beforeEach,即可確保每次測試執行前環境都回到最初預設的狀態 ```javascript= BeforeEach(() => { TestBed.configureTestingModule({ declarations: [ AppComponent ] }); }); ``` 當我們要測試 AppComponent 時,Angular 會根據 Component 是否採用 templateUrl 與 styleUrls 來決定要不要發 XHR。若有,則須採用 async 的方式來進行編譯。由於 Angular Cli 是使用 webpack 建置專案,webpack 已經把呼叫 template 和 style 這兩個動作先執行,因而不需要使用 Async 和 complieComponents(),但不是使用 webpack 做測試時,還是要執行上述步驟。 ```javascript= BeforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent ] }).compileComponents(); })); ``` ### 單元測試 - Html - 創建測試用 component,返回 ComponentFixture,其中包含了可以操作 DOM 元素的 DebugElement。 - DebugElement 其實就是元件的實例,而 nativeElement 則是針對 DOM 元素操作用。可以看下方例子: ```javascript= fixture.componentInstance == fixture.debugElement.componentInstance; fixture.nativeElement == fixture.debugElement.nativeElement; ``` - 測試範例如下: ```javascript= it('should render title in a h1 tag', async(() => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges();//detectChanges 會在執行時,去監測 angular 目前還有什麼變動,當變動完畢,才會繼續執行步驟。 const complied = fixture.debugElement.nativeElement; expect(compiled.querySelectorAll('h1')[0].textContent).toContain('Chris\ Book Store'); })); ``` ### 單元測試 - Component - 若 constructor 內有注入 service,則測試模組環境的 providers 就必須要有該 service ```javascript= TestBed.configureTestingModule({ declarations: [NovelComponent], providers:[{ provide: ExampleService, useVale: fakeService}] }); ``` - 須自行建立假的 service,取代真實的 request ```javascript= const fakeService = { getNovel: () => { return Observable.of({ 'returnCode': 200, 'data':[ { 'bookname':'Titanic', 'price':900 }, { 'bookname':'Once', 'price':550 }, { 'bookname':'Harry Potter', 'price':1380 } ] }) } } ``` - 若 template 中含有 router-outlet tag,則測試模組環境需匯入 RouterTestingModule ```javascript= TestBed.configureTestingModule({ imports: [RouterTestingModule], declarations: [ AppComponent ] }).complieComponents(); ``` 單元測試 - Service - 測試 Service 前,需import HttpClientModule 到 TestBed config 中。 - 透過 Jasmine 的 createSpy 來幫我們模擬 service method ```javascript= TestBed.configureTestingModule({ imports: [HttpClientModule], providers: [ExampleService] }); ``` - 可以依照測試情境來決定,什麼時候要注入 service ```javascript= //方法1 注入service 同時也 catch它 beforeEach(inject([ExampleService], (eService) => { fakeExampleService = eService; })); //方法2 測試時再注入 service it('should get the right response, too', inject([ExampleService], (service: ExampleService) => { let fakeResponse = null; service.getNovel().subscribe(res => { fakeResponse = res.data; expect(fakeResponse[0].bookname).toBe('Titanic'); }); })); ``` ### 單元測試 - Router - 測試 Router 前,需 import fakeAsync、RouterTestingModule、Router 與 Location ```javascript= import { TestBed, fakeAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { Router } from '@angular/router'; import { Location } from '@angular/common'; ``` - 接著進行 TestBed 的環境建置:將自訂的 router 隨著 RouterTestingModule 一起 import 到 config。配置如下: ```javascript= const routes = [ { path: '', component: null, children: [ { path: '', redirecTo: 'novel', pathMatch: 'full' }, { path: 'novel', component: NovelComponent }, { path: 'comic', component: ComicComponent }, { path: 'magazine', component: MagazineComponent}, { path: '**', redirectTo: 'novel'} ] } ]; beforeEach(() => { TestBed.configureTestingModule({ imports: [RouterTestingModule.withRoutes(routes)], declarations: [ NovelComponent, ComicComponent, MagazineComponent, AppComponent ] }); //取得注入的 Router 與 Location router = TestBed.get(Router); location = TestBed.get(Location); fixture = TestBed.createComponent(AppComponent); router.initialNavigation(); }); ``` - 利用 router.navigate 來測試 - 因返回 promise、故可直接用 then 來進行後續操作(與官方的 tick 有些出入) ```javascript it('should go to Comic book page', fakeAsync(() => { router.navigate(['comic']).then(() => { expect(location,path()).toBe('/comic'); }); })); ``` #### 覆蓋率測試 ```javascript= ng test --code-coverage ``` 生成一個 coverage資料夾,會產生一個 html 在資料夾內,可以開啟該檔案來查看。