上一篇文章我們分享了如何導入Jest到Angular專案中,今天我們要分享如何規劃單元測試。
在分享測試金字塔的時候有提到單元測試的目的是驗證應用中的每一個最小單元,確保邏輯和語法如想像中的運作。
而這個最小單元通常是 function
或是 method
。所以,對沒有錯,有 function
的檔案基本上都要寫。
需要寫單元測試的檔案包含:
- Component
- Service
- Directive
- Pipe
- Guard
- Interceptor
- Store (若使用 NgRx 等狀態管理)
了解檔案的範圍之後是每隻檔案的測試案例的力度。這邊我們先提到兩個Jest的語法,describe
和 it
,這是兩個用來組織測試案例的語法。
describe
是一個用來組織測試案例的函數,裡面可以擺放多個測試案例。it
是一個用來定義具體測試案例的函數,裡面描述該測試案例具體的邏輯。
describe('Array methods', () => {
// 在這裡可以撰寫多個測試案例
it('should add a new element to the array', () => {
// 測試邏輯
});
it('should remove the last element from the array', () => {
// 測試邏輯
});
});
上述範例中 Array methods
描述了一組測試案例的主題,內部有兩個測式案例。
以一個 component
為例, 通常我習慣將 component
本身透過一個 describe
描述,代表內部的所有內容皆屬於此 component
,接著每一個 function
都會對應到一個 describe
,而 describe
內部則是該 function
的所有測試案例。
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
templateUrl: './counter.component.html',
styleUrls: ['./counter.component.css']
})
export class CounterComponent {
count: number = 0;
increment() {
this.count++;
}
decrement() {
this.count--;
}
reset() {
this.count = 0;
}
}
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ CounterComponent ]
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
// 確認component成功實作,因為對應的是component本身,故不再額外包describe
it('should create the component', () => {
expect(component).toBeTruthy();
});
describe('increment()', () => {
it('should increment count by 1', () => {
//測試內容
});
});
describe('decrement()', () => {
it('should decrement count by 1', () => {
//測試內容
});
it('should not decrement below 0 (if needed)', () => {
//測試內容
});
});
describe('reset()', () => {
it('should reset count to 0', () => {
//測試內容
});
});
//如果需要,可以針對變數的初始值進行驗證。
describe('Initial State', () => {
// count變數
it('should have initial count as 0', () => {
//測試內容
});
});
});
以範例 component
來說,有三個 function
,故在描述 CounterComponent
的 describe
中就會有三個 describe
分別代表每一個 function
。值得一提的是,通常會在最上方加入一個驗證 component
本身是否成功實作的測案,確保整個測式的基礎。
接著我們要來分享一隻測試檔案的架構,測試檔案通常會用 .spec
prefix進行命名 (ex: counter.component.spec.ts)。並且可以大致分成三個部分。
這個區塊包含測試所需的設置和初始化邏輯,如匯入必要的模組、配置測試環境、創建測試實例等。
- 匯入模組與依賴:首先匯入要測試的組件、服務以及測試工具(如 Jest、Angular 的
TestBed
等)。beforeEach()
區塊:在每次測試之前運行,用來初始化測試環境,如創建組件或模擬依賴注入。```tsx import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CounterComponent } from './counter.component'; let component: CounterComponent; let fixture: ComponentFixture<CounterComponent>; beforeEach(() => { TestBed.configureTestingModule({ declarations: [CounterComponent], }).compileComponents(); fixture = TestBed.createComponent(CounterComponent); component = fixture.componentInstance; fixture.detectChanges(); }); ```
2. 測試數據與 Mock 區塊
這個區塊用來定義測試中會使用到的假數據(如模擬的 API 回應)或 mock(模擬)依賴的部分。
- 模擬依賴:通常使用 Jest 的
jest.mock()
來模擬外部依賴的模塊,或者使用 Angular 的spyOn
來模擬組件或服務中的方法。```tsx // 宣告 let mockSomeService: any; // 測試數據 const mockData = { id: 1, name: 'Mocked Data' }; beforeEach(() => { // 使用 Jest 來模擬服務,避免在測試中使用實際的服務 mockSomeService = { getData: jest.fn(() => mockData) }; // 注入 mock 服務到測試環境 TestBed.configureTestingModule({ declarations: [CounterComponent], providers: [{ provide: SomeService, useValue: mockSomeService }] // 使用 mock 服務取代真實的服務 }).compileComponents(); fixture = TestBed.createComponent(CounterComponent); component = fixture.componentInstance; fixture.detectChanges(); }); ```
3. 測試區塊
測試檔案的核心部分,包含具體的測試案例。根據測試需求,這一區塊通常使用
describe
和it
函數來進行組織。```tsx describe('CounterComponent', () => { it('should create the component', () => { expect(component).toBeTruthy(); }); describe('increment()', () => { it('should increment count by 1', () => { component.increment(); expect(component.count).toBe(1); }); }); }); ```
4. 清理區塊
如果有需要清理測試環境的操作,可以放在這個區塊,通常會使用
afterEach()
或afterAll()
來執行。
afterEach()
:在每個測試完成後執行,常用於清除測試中的副作用(如模擬請求、時間等)。afterAll()
:在所有測試結束後執行,一般用於較大的資源釋放(如關閉連接或文件)。
按照上述四個區塊來看,一隻常見的測試檔案長相大致如下。
// 1. 設定與初始化
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
import { SomeService } from './some-service'; // 假設組件依賴於某個服務
let component: CounterComponent;
let fixture: ComponentFixture<CounterComponent>;
let mockSomeService: any;
// 2. 測試數據與 Mock 區塊
// 測試數據
const mockData = { id: 1, name: 'Mocked Data' };
beforeEach(() => {
// 使用 Jest 來模擬服務,避免在測試中使用實際的服務
mockSomeService = {
getData: jest.fn(() => mockData)
};
// 注入 mock 服務到測試環境
TestBed.configureTestingModule({
declarations: [CounterComponent],
providers: [{ provide: SomeService, useValue: mockSomeService }] // 使用 mock 服務取代真實的服務
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
// 3. 測試區塊
describe('CounterComponent', () => {
describe('Initial State', () => {
it('should have count initialized to 0', () => {
// 測試邏輯
});
});
describe('increment()', () => {
it('should increment count by 1', () => {
// 測試邏輯
});
});
describe('decrement()', () => {
it('should decrement count by 1', () => {
// 測試邏輯
});
});
describe('reset()', () => {
it('should reset count to 0', () => {
// 測試邏輯
});
});
describe('SomeService integration', () => {
it('should use SomeService to get data', () => {
// 測試邏輯
});
});
});
// 4. 清理區塊
// 清理 mock,避免影響後續測試
afterEach(() => {
jest.clearAllMocks();
});
測試命名的部分,代表 component
,文章開頭提到的任何需要測式的檔案的 describe
,可以直接用component
名稱進行命名,在執行測試時也較容易閱讀。
代表 function
的 describe
也建議直接使用 function
名稱進行命名。
最後是測試案例 it
,建議使用 should
作為開頭的因果句進行命名,確保每一個測試案例專注完成一個測式情境。且也能夠透過命名來避免重複測式或是遺漏情境。
// 符合的命名,能明確看出該測式要驗證的情境
it('should increase count with 1', () => {
// 測試邏輯
});
// 不容易管理的命名
it('add 1', () => {
// 測試邏輯
});
it('2023-01-01,購物車增加功能', () => {
// 測試邏輯
});
而測試中常用到的變數種類大概有以下幾種。
1. 模擬依賴(Mocks/Spies)
- 用來模擬對外部依賴的行為,如服務、API 調用或第三方庫的功能。建議使用
mock
作為prefix命名。let mockSomeService: any; beforeEach(() => { mockSomeService = { getData: jest.fn(() => mockData) }; });
2. 測試數據
- 測試時用來替代實際應用數據的假數據。建議使用
mock
、fake
等prefix 進行命名。const mockData = { id: 1, name: 'Mocked Data' };
3. 期望值(Expected Values)
- 這些變數用來存儲測試的期望輸出或結果。建議使用
expected
prefix 進行命名。const expectedCount = 1;
4. 輸入變數(Input Variables)
- 測試中用來模擬用戶輸入或函數調用的參數。這些變數通常會當成
params
傳給被測方法。建議使用input
、test
等prefix進行命名。const inputValue = 5;
5. 狀態變數
- 這些變數用來追蹤被測對象的狀態,例如某個函數是否被調用、調用的次數或調用的順序,這通常使用
jest.spyOn()
或jest.fn()
來模擬。建議使用spy
prefix進行命名。const spyIncrement = jest.spyOn(component, 'increment');
最後一部分是關於 it
測案中的撰寫規範。建議使用 Arrange-Act-Assert Pattern (AAA Pattern)。
這種模式將測試過程分成三個部分:Arrange(安排)、Act(執行)、和 Assert(斷言),確保測試過程條理分明,幫助我們更好地理解測試目標和預期結果。
Arrange(安排):
- 這是測試的準備階段,負責設置測試環境、初始化變數、模擬依賴或假數據,確保系統處於適當的狀態以執行測試。
- 在這一步中,會做所有測試前的準備工作,如創建對象、設置狀態、模擬服務、準備輸入參數等。
const component = new CounterComponent(); component.count = 0; // 設置初始狀態
Act(執行):
- 這是測試的執行階段,主要進行具體的操作或調用。這一步調用待測的函數或方法,執行測試對象的核心邏輯。
- 簡單來說,這一步就是對系統進行操作,模擬實際的行為或流程。
component.increment(); // 執行待測的方法
Assert(斷言):
- 這是測試的驗證階段,用來檢查結果是否符合預期。這一步通過斷言來驗證系統在執行後的狀態或結果是否與預期一致。
- 在此階段會使用測試框架提供的
expect
或其他斷言工具來比對實際結果和期望結果。範例:
expect(component.count).toBe(1); // 斷言 count 是否符合預期結果
用上述的測式component做為範例來撰寫的話,一個測案會長這樣。
it('should increment the count by 1', () => {
// Arrange: 初始化測試環境和數據
const component = new CounterComponent();
component.count = 0; // 設置初始狀態
// Act: 執行待測方法
component.increment();
// Assert: 驗證結果是否符合預期
expect(component.count).toBe(1);
});
下一篇文章我們就來實戰演練,使用一個component來一步步撰寫測式!