今天要透過上一篇介紹的單元測試撰寫方式,實際來為一個 component
撰寫單元測試。
使用Jest為Angular專案撰寫UnitTest(一)
使用Jest為Angular專案撰寫UnitTest(二)
廢話不多說,上程式碼!
import { Component} from '@angular/core';
import { NgIf, NgFor, NgClass } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TodoService } from './todo.service';
interface Todo {
id: number;
title: string;
completed: boolean;
}
@Component({
selector: 'app-todo',
standalone: true,
templateUrl: './todo.component.html',
styleUrls: ['./todo.component.css'],
imports: [NgIf, NgFor, NgClass, FormsModule],
})
export class TodoComponent {
todos: Todo[] = [];
errorMessage: string = '';
constructor(private todoService: TodoService) {}
loadTodos(): void {
this.todoService.getTodos().subscribe(
(todos) => (this.todos = todos),
(error) => (this.errorMessage = 'Failed to load todos')
);
}
}
這是一個關於 Todo List 的簡單 component
。
其中注入了一個 service
,並且有一個 method
。
- 首先我們建立一支測試檔案
todo.component.spec.ts
,記得.spec prefix
副檔名。- 在檔案中加入描述
TodoComponent
的第一層describe
。describe('TodoComponent', () => {})
- 在檔案中加入初始設定。
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TodoComponent } from './todo.component'; describe('TodoComponent', () => { let component: TodoComponent; let fixture: ComponentFixture<TodoComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [TodoComponent], }).compileComponents(); fixture = TestBed.createComponent(TodoComponent); component = fixture.componentInstance; fixture.detectChanges(); }); });
beforeEach
在每一次執行測案前,執行其中的程式碼。
TestBed.configureTestingModule
是Angular提供給我們設定測試模組用的方法。像是測試的app.config
檔案的概念。我們會在其中匯入各種需要用到的模組和注入服務。
compileComponents
是編譯component
的非同步方法,確保測試前所有資源已經編譯完成。
component
代表我們要在測試期間使用的TodoComponent
的實例。
fixture
代表ComponentFixture
的實例,讓我們可以訪問template
的部分。
detectChanges
會手動觸發 Angular 的變更檢測機制,以觸發元件生命週期。
- 隔離外部服務,我們需要確保測試檔案式獨立不會被外部因素影響。可以看到
TodoComponent
中注入了TodoService
,所以我們要透過mock
的方式來隔離。import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TodoComponent } from './todo.component'; import { TodoService } from './todo.service'; describe('TodoComponent', () => { let component: TodoComponent; let fixture: ComponentFixture<TodoComponent>; let mockTodoService: any; const mockTodos = [ { id: 1, title: 'Learn Angular', completed: false }, { id: 2, title: 'Write Jest Tests', completed: false }, ]; beforeEach(async () => { mockTodoService = { getTodos: jest.fn().mockReturnValue(of(mockTodos)) }; await TestBed.configureTestingModule({ imports: [TodoComponent], providers: [{ provide: TodoService, useValue: mockTodoService }] }).compileComponents(); fixture = TestBed.createComponent(TodoComponent); component = fixture.componentInstance; fixture.detectChanges(); }); });
首先宣告仿造的
service
,並在 provide 的階段使用仿造service
來進行注入。
- 加入驗證
component
是否成功渲染的防呆驗證。it('should create the component', () => { expect(component).toBeTruthy(); });
- 針對
component
中的每一個function
建立相符的describe
。describe('loadTodos()', () => { });
發想測試情境,針對函數中的正常系和異常系進行測試案例設計,確保每一個
it
只完成一個驗證,以及不相互耦合。以
loadTodos()
為範例,可以看到,串流本身依據服務的回傳分成正常和異常處理,要涵蓋這兩個情境基本就需要兩個it
。describe('loadTodos()', () => { it('should load todos successfully', () => {}) it('should handle error when loading todos', () => {}); });
- 使用 AAA Pattern 撰寫測試。
should load todos successfully
因為我們在beforeEach
中已經宣告了正常系的假資料,所以 Arrange 中不需要再次定義。在Act中直接執行loadTodos()
。最後在 Assert 中 撰寫期望的結果。it('should load todos successfully', () => { // 正常情境測試 // Arrange // Act component.loadTodos(); // Assert expect(component.todos.length).toBe(2); expect(component.todos[0].title).toBe(mockTodos[0].title); });
接著在異常系測試中,我們就需要調整
service
回傳的內容,使其觸發異常系的流程。Arrange階段我們可以利用jest.spyOn
來監聽,並修改回傳內容。it('should handle error when loading todos', () => { // Arrange // 使用 spyOn 來模擬 TodoService 的 getTodos 方法並返回錯誤 const todoServiceSpy = jest.spyOn((component as any)['todoService'] , 'getTodos').mockReturnValue(throwError(() => new Error('Failed to load'))); // Act component.loadTodos(); // 觸發 loadTodos // Assert expect(component.errorMessage).toBe('Failed to load todos'); expect(todoServiceSpy).toHaveBeenCalled(); });
值得一提的是,因為
todoService
在component中是 private 的,我們可以透過as any
的方式使之繞過型別檢核,讓我們能在外部取得service
。
- 在每個測案後進行
mock
的還原。確保測案之間的資料狀態獨立。afterEach(() => { jest.clearAllMocks(); // 每次測試後清除所有的 mock 狀態 });
- 執行測試,透過 npm 中設定好的 script 執行 jest 的測試。
npm run test-jest:coverage
最後補充一下,在單元測試中,我們經常使用四個主要指標來衡量測試的質量、覆蓋範圍以及系統的健全性。以確保確保測試的有效性和完整性,並且幫助開發者找出測試中的不足之處。分別是:
1. 測試覆蓋率(Test Coverage)
測試覆蓋率 是指測試用例覆蓋代碼的比例,通常會分為以下幾種覆蓋率:
- 行覆蓋率(Line Coverage):測量測試執行時,實際執行的代碼行數相對於總行數的比例。這是最直觀的覆蓋率指標。
- 函數覆蓋率(Function Coverage):檢查測試是否執行了每個函數或方法。
- 分支覆蓋率(Branch Coverage):測試是否涵蓋了所有條件分支(如
if
或switch
),確保每個邏輯分支都被測試過。- 路徑覆蓋率(Path Coverage):測試是否執行了代碼中所有可能的執行路徑。
測試覆蓋率的重要性:
- 測試覆蓋率高表明更多的代碼行被測試,減少了遺漏 bug 的可能性。
- 但高覆蓋率並不一定等於高質量的測試,因為覆蓋率並不衡量測試的深度或質量,只能確保代碼在測試時有被執行。
2. 測試通過率(Test Pass Rate)
基本上請確保測試通過率是100%。
3. 測試執行速度(Test Execution Time)
測試執行速度 是指執行測試所花費的時間。這是一個非常重要的指標,特別是在 CI/CD (持續集成/持續交付)環境中,測試過程的效率直接影響整體開發流程的效率。
- 單個測試用例的執行時間:確保每個測試用例運行時間短,避免測試過程過於耗時。
- 整體測試套件的執行時間:隨著測試數量的增加,整個測試套件的執行時間可能會變長,需要持續關注這個指標。
測試執行速度的重要性:
- 測試執行時間越短,開發過程中的反饋循環就越快,開發者能夠更快地發現問題。
- 如果測試過程過於緩慢,可能會導致開發者減少執行測試的頻率,進而降低測試的價值。
如何提高測試執行速度:
- 減少依賴於外部服務的測試(例如,使用 mock 來取代實際的 API 調用)。
- 優化測試策略,確保每個測試是獨立且快速執行的。
4. 測試穩定性(Test Stability)
測試穩定性 是指測試的結果是否一致、可預測。穩定的測試應該每次運行結果都一致,即使在不同的環境下執行也應該得到相同的結果。
- 不穩定測試(Flaky Tests):這些測試有時會通過,有時會失敗,通常是由於測試環境的不確定性、隨機因素、競態條件等引起的。
- 穩定測試:測試的結果應該是確定的,無論在何種環境下運行都應該給出一致的結果。
測試穩定性的重要性:
- 測試的不穩定性會導致測試結果不可靠,讓開發者難以分辨是代碼問題還是測試本身的問題。
- 稳定的測試可以提高開發者對測試結果的信心,有助於發現真實的問題。
如何提高測試穩定性:
- 減少測試對外部依賴(如網絡、文件系統、時鐘等),可以使用 mock 和 stub 來模擬這些外部依賴。
以上就是我們的單元測試分享,大家也可以依照自己的專案狀況來衡量撰寫單元測試時的目標設定,如果是時間緊迫人數又少,也可以先針對重要的檔案撰寫,並且涵蓋率可以設定的較低一些,未來在逐漸補齊也是沒有問題的,最重要的是將測試的撰寫加入開發流程中,確保持續的維護。