[從 0 開始的 Angular 生活]No.41 在 Angular 內進行測試(一) - 服務元件

前言

測試對於一個公司的專案有多重要自然不言而喻,如何在 Angular 中進行測試呢?讓我們一起學習吧。

關於測試

相信測試的好處以及壞處網路上隨便搜尋能夠找到相當大量的文章,所以這部份我想應該也不過多著墨在這裡了,以下附上一些文章,目的在於建立對於那些陌生名詞的認知:

Angular 中的測試

根據官方的文件描述 Angular CLI 會下載並安裝 Jasmine 測試框架,測試 Angular 應用時所需的一切。

而每個新開的 Angular 專案都可以直接運行 ng test 指令立即進行測試。

建立新的 Angular 專案

接著運行 ng test 指令,會看到一些測試的過程

而測試執行完畢後,也會開啟 chrome 瀏覽器並在 Jasmine HTML 報告器中顯示測試結果。

可以直接點選 AppComponent 連結,重新跑過 AppComponent 底下的測試,或者是點擊掛在 AppComponent 下的連結也可以進行單獨的測試。

而這個由測試指令開啟的 chrome 瀏覽器會持續監聽程式碼的變化,因此可以對 app.component.ts 做一個小修改,並儲存它。

這些測試就會重新執行,瀏覽器也會重新整理,然後新的測試結果就出現了。

直接關閉由測試指令開啟的 chrome 瀏覽器會馬上又被開啟,原因是必須回到終端機按下 CTRL + C 終止指令運行才可以正確關閉。

測試檔案的配置

通常的情況下 Angular CLI 會自動的產生 Jasmine 和 Karma 的配置檔案。

也可以透過編輯 src/ 目錄下的 karma.conf.js 和 test.ts 檔案來微調很多選項。

Angular CLI 會基於 angular.json 檔案中指定的專案結構和 karma.conf.js 檔案,來在記憶體中構建出完整的執行時配置。

而如果要調整這些預設的設定,可能就要到 JasmineKarma 的官方文件找找了。

啟用程式碼覆蓋率報告

在 Angular CLI 中, ng test 指令後面可以額外追加一些參數產生程式碼覆蓋率報告。

而什麼是程式碼覆蓋率呢?

根據 WIKI 的解釋,程式碼覆蓋(英語:Code coverage)是軟體測試中的一種度量,描述程式中原始碼被測試的比例和程度,所得比例稱為程式碼覆蓋率

白話來說大概可以當成是專案中的健康指標之類的吧,越高越好的那種。

附上一篇雖然年代久遠,但對於名詞的了解上仍有一定的幫助:

試著產生第一份程式碼覆蓋率報告

指令 ng test 後面可以接的參數可參考

執行下列指令,觀察檔案結構有何異動:

1
ng test --no-watch --code-coverage

當測試完成時,該命令會在專案中建立一個新的 /coverage 目錄。

這裡我使用 VS Code 的插件 - Live Server 開啟其 index.html 檔案以檢視帶有原始碼和程式碼覆蓋率值的報告。

除了每次在 ng test 指令後方加入參數的作法外,也可以透過 Angular CLI 的 angular.json 中設定:

1
2
3
4
5
"test": {
"options": {
"codeCoverage": true
}
}

刪除 coverage 資料夾,執行 ng test 指令觀察是否仍產出 coverage 資料夾。

測試 OK

服務元件的測試

服務元件的測試算是比較單純的,因為服務元件建立後,不像普通的元件含有 Template 的部分。

使用 ng g s demo 指令建立服務元件,並且在 demo.service 寫一些程式。

demo.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})
export class DemoService {

constructor() { }
getArray() {
return ['en', 'es', 'fr'];
}
getString(str) {
return str;
}
}

接著在 demo.service.spec 寫測試案例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { TestBed } from '@angular/core/testing';

import { DemoService } from './demo.service';

describe('DemoService', () => {
let demoService: DemoService;
it('檢查陣列是否包含特定語系', () => {
demoService = new DemoService();
const languages = demoService.getArray();
expect(languages).toContain('en');
expect(languages).toContain('es');
expect(languages).toContain('fr');
expect(languages.length).toBe(3);
});

it('檢查輸入字串是否等於輸出字串', () => {
demoService = new DemoService();
expect(demoService.getString('o')).toBe('o');
});
});

Angular CLI 預設會幫我們把服務元件 import 進該服務元件的測試檔中,方便進行測試。

第一個測試利用了 new 讓 DemoService 實體化,並且得以取用 DemoService 內的方法:

  • getArray()
  • getString()

而這段測試 Code 使用的方法可以從 jasmine 的文件中找到詳細描述。

  • describe(description, specDefinitions)

    • 建立一測試規範 (spec),通常會把性質相近的測試放在一起
    • description - 這組測試的描述
    • specDefinitions - 等待被測試的函式
  • it(description, testFunction, timeout)

    • 定義一測試規範
    • description - 這個測試的描述
    • testFunction - 包含測試代碼的函式
    • timeout - 自訂非同步時的規範
  • expect(actual) → {matchers}

    • 為這個規範建立期望
    • actual - 用於測試預期的實際值
  • toContain(expected)

    • expect 包含特定值的實際值
    • expect(languages).toContain('en'); 為 languages 陣列是否有包含 en 字串在某個陣列元素內
  • toBe(expected)

    • 將對 expected 進行三個等號的比較

輸入測試指令 ng test 並觀察。

5 個測試都通過了

而刻意營造錯誤的話則會呈現:

使用 TestBed 測試服務元件

在實務中,服務元件最終會透過 Angular 的相依注入 (DI) 來建立服務。

而 TestBed 會動態建立一個用來模擬 @NgModule 的 Angular 測試模組,因此我們可以將服務元件注入到 TestBed ,接著就可以進行測試了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { TestBed } from '@angular/core/testing';

import { DemoService } from './demo.service';

describe('DemoService', () => {
let service: DemoService;
beforeEach(() => TestBed.configureTestingModule({ providers: [DemoService]}));
it('檢查語系陣列是否包含特定語系', () => {
service = TestBed.get(DemoService);
const languages = service.getArray();
expect(languages).toContain('en');
expect(languages).toContain('es');
expect(languages).toContain('fr');
expect(languages.length).toEqual(3);
});

it('檢查輸入字串是否等於輸出字串', () => {
service = TestBed.get(DemoService);
const returnInput = service.getString;
expect(returnInput('a')).toBe('a');
});
  • beforeEach(function, timeout)
    • describe 在調用它的每個規範之前運行一些共享設置
  • TestBed.configureTestingModule()
    • 接收一個元資料物件,其中具有 @NgModule 中的絕大多數屬性。
    • 要測試某個服務,就要在元資料的 providers 屬性中指定一個將要進行測試的陣列。
  • TestBed.get()
    • 取得 TestBed 中的服務

上面那一段測試 Code 還有可以優化的地方,例如:

  • 可以把 service = TestBed.get(DemoService); 提出來放在 beforeEach() 內
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import { TestBed } from '@angular/core/testing';

    import { DemoService } from './demo.service';

    describe('DemoService', () => {
    let service: DemoService;
    beforeEach(() => {
    TestBed.configureTestingModule({ providers: [DemoService]});
    service = TestBed.get(DemoService);
    });
    it('檢查語系陣列是否包含特定語系', () => {
    const languages = service.getArray();
    expect(languages).toContain('en');
    expect(languages).toContain('es');
    expect(languages).toContain('fr');
    expect(languages.length).toEqual(3);
    });

    it('檢查輸入字串是否等於輸出字串', () => {
    const returnInput = service.getString;
    expect(returnInput('a')).toBe('a');
    });
    });

使用 jasmine.createSpyObj() 測試一個有相依關係的服務元件

新增另一個服務元件 demo2 並且把 demo 注入,使 demo2 形成對 demo 的相依關係。

demo1.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root'
})
export class DemoService {

constructor() { }
getArray() {
return ['en', 'es', 'fr'];
}
getString(str) {
return str;
}
}

demo2.service

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Injectable } from '@angular/core';
import { DemoService } from './demo.service';

@Injectable({
providedIn: 'root'
})
export class Demo2Service {

constructor(private demoService: DemoService) { }
getArray() {
return this.demoService.getArray();
}
}

撰寫 demo2.service.spec.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { TestBed } from '@angular/core/testing';
import { Demo2Service } from './demo2.service';

describe('Demo2Service', () => {
let masterService: jasmine.SpyObj<Demo2Service>;

beforeEach(() => {
const spy = jasmine.createSpyObj('ArrayService', ['getArray']);
TestBed.configureTestingModule({
providers: [
{ provide: Demo2Service, useValue: spy }
]
});
masterService = TestBed.get(Demo2Service);
console.log(masterService);
});
it('檢查語系陣列是否包含特定語系', () => {
const arr = ['en', 'es', 'fr'];
masterService.getArray.and.returnValue(arr);
const languages = masterService.getArray();
expect(languages).toContain('en');
expect(languages).toContain('es');
expect(languages).toContain('fr');
expect(languages.length).toEqual(3);
});
});

使用 jasmine.createSpyObj() 產生一個含有 getArray 方法的物件。

useValue 可以參考官方說明文件,定義物件使用 useValue 作為 key 來把該變數關聯起來。

最後需要透過 .getArray.and.returnValue() 設定

  • 當 getArray 方法被呼叫時回傳什麼值

如果少了這個步驟,呼叫 getArray 方法的話可是什麼事情都不會發生的。

透過這樣的方式使 demo2 與 demo 脫鉤,就可以單獨測試 demo2 。

小結

在學習這一部分的時候,遇到蠻明顯的卡關…。

主要是因為看不懂官方中文的文件,感覺省略了很多東西。

例如對服務的測試 Service Tests ,這邊範例感覺提供的不夠完整,讓我不知從何下手做起。

於是我轉而參考其他篇文章,如:

並與官方的程式碼交叉參考,反覆撞牆下才完成本次的範例實作。

0%