Angular Jasmine 測試指南 - Directive 測試與 Spy 使用
基本測試設定
測試檔案結構
在測試 Angular directive 時,需要建立一個 Host Component 來包裝要測試的 directive:
import { Component } from '@angular/core';
import { FollowCursorTooltipDirective } from './follow-cursor-tooltip.directive';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { OverlayModule, Overlay } from '@angular/cdk/overlay';
import { CustomTooltipComponent } from './custom-tooltip.component';
import { By } from '@angular/platform-browser';
@Component({
standalone: true,
imports: [FollowCursorTooltipDirective],
template: `<div [followCursorTooltip]="testTooltipMsg">Test</div>`,
})
class HostComponent {
testTooltipMsg: string | null = 'test tooltip message';
}
describe('FollowCursorTooltipDirective', () => {
let fixture: ComponentFixture<HostComponent>;
let host: HostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
HostComponent,
NoopAnimationsModule,
OverlayModule,
CustomTooltipComponent,
],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
});
常見設定錯誤
❌ 錯誤的變數宣告:
let fixture = ComponentFixture<HostComponent>; // 錯誤:這是型別,不是實例
let host = HostComponent; // 錯誤:這是類別,不是實例
✅ 正確的變數宣告:
let fixture: ComponentFixture<HostComponent>; // 正確:宣告為型別
let host: HostComponent; // 正確:宣告為型別
兩種測試方法比較
方法 1:DOM 查詢法
直接查詢 DOM 元素來驗證結果:
it('should display tooltip in DOM', () => {
const divElement = fixture.debugElement.query(By.css('div'));
const mouseEvent = new MouseEvent('mouseenter', {
clientX: 100,
clientY: 200
});
divElement.triggerEventHandler('mouseenter', mouseEvent);
fixture.detectChanges();
const tooltipElement = document.querySelector('.custom-tooltip');
expect(tooltipElement).toBeTruthy();
});
方法 2:Spy 監控法
監控服務方法是否被呼叫:
it('should call overlay.create on mouseenter', () => {
const overlay = TestBed.inject(Overlay);
const overlaySpy = spyOn(overlay, 'create');
const divElement = fixture.debugElement.query(By.css('div'));
const mouseEvent = new MouseEvent('mouseenter');
divElement.triggerEventHandler('mouseenter', mouseEvent);
expect(overlaySpy).toHaveBeenCalled();
});
兩種方法的差異
方面
DOM 查詢法
Spy 監控法
測試層級
整合測試
單元測試
測試範圍
測試最終結果
測試程式碼邏輯
執行速度
較慢(需要渲染)
較快(只檢查呼叫)
穩定性
可能受 CSS/timing 影響
更穩定
真實性
測試真實使用者體驗
只測試邏輯流程
Spy 的使用時機
應該使用 Spy 的情況
測試服務方法呼叫:
const httpSpy = spyOn(httpClient, 'get');
service.getData();
expect(httpSpy).toHaveBeenCalledWith('/api/data');
測試條件邏輯:
const consoleSpy = spyOn(console, 'error');
service.handleError('test');
expect(consoleSpy).toHaveBeenCalled();
Mock 外部依賴:
spyOn(service, 'getValue').and.returnValue('mocked value');
不應該使用 Spy 的情況
測試 UI 互動結果 - 用 DOM 查詢
測試資料綁定 - 用
fixture.detectChanges()
+ DOM 查詢測試樣式或佈局 - 用真實渲染
Spy 的重要設定
// 預設:攔截並阻止真實執行
const spy = spyOn(service, 'method');
// 攔截但仍執行真實方法
const spy = spyOn(service, 'method').and.callThrough();
// 攔截並回傳指定值
const spy = spyOn(service, 'method').and.returnValue('fake result');
事件測試中的座標設定
什麼時候需要設定 clientX/clientY
需要座標的情況:
it('should set correct position to tooltip', () => {
// 真實執行會用到座標值
const mouseEvent = new MouseEvent('mouseenter', {
clientX: 100, // tooltip 會使用這個值
clientY: 200 // tooltip 會使用這個值
});
divElement.triggerEventHandler('mouseenter', mouseEvent);
// 驗證 tooltip 位置設定正確
const tooltipElement = document.querySelector('.custom-tooltip');
expect(tooltipElement).toBeTruthy();
});
不需要座標的情況:
it('should call overlay.create', () => {
// Spy 攔截真實執行,不會用到座標
const overlaySpy = spyOn(overlay, 'create');
const mouseEvent = new MouseEvent('mouseenter'); // 簡單即可
divElement.triggerEventHandler('mouseenter', mouseEvent);
expect(overlaySpy).toHaveBeenCalled();
});
判斷規則
// 邏輯分析:程式碼會不會真的執行到使用座標的地方?
// 情況 1:Spy 攔截 → 不會真實執行 → 不需要座標
const spy = spyOn(service, 'method');
// 情況 2:條件不成立 → 不會執行相關程式碼 → 不需要座標
if (this.tooltipMessage) { // 空字串 = falsy
this.showTooltip(event); // 不會執行
}
// 情況 3:真實執行且會用到座標 → 需要座標
this.tooltipComponent.instance.x = event.clientX; // 會真的用到
實際測試範例
基本功能測試
describe('Basic functionality', () => {
it('should create directive instance', () => {
expect(host).toBeTruthy();
});
it('should not show tooltip when followCursorTooltip is null', () => {
const overlaySpy = spyOn(TestBed.inject(Overlay), 'create');
host.testTooltipMsg = null;
fixture.detectChanges();
const divElement = fixture.debugElement.query(By.css('div'));
const mouseEvent = new MouseEvent('mouseenter');
divElement.triggerEventHandler('mouseenter', mouseEvent);
expect(host.testTooltipMsg).toBeNull();
expect(overlaySpy).not.toHaveBeenCalled();
});
});
滑鼠事件測試
describe('Mouse event interactions', () => {
it('should show tooltip on mouseenter when tooltip has value', () => {
const divElement = fixture.debugElement.query(By.css('div'));
const mouseEvent = new MouseEvent('mouseenter', {
clientX: 100,
clientY: 200
});
divElement.triggerEventHandler('mouseenter', mouseEvent);
fixture.detectChanges();
const tooltipElement = document.querySelector('.custom-tooltip');
expect(tooltipElement).toBeTruthy();
});
it('should hide tooltip on mouseleave', () => {
// 先顯示 tooltip
const divElement = fixture.debugElement.query(By.css('div'));
divElement.triggerEventHandler('mouseenter', new MouseEvent('mouseenter'));
fixture.detectChanges();
// 再隱藏 tooltip
divElement.triggerEventHandler('mouseleave', new MouseEvent('mouseleave'));
fixture.detectChanges();
const tooltipElement = document.querySelector('.custom-tooltip');
expect(tooltipElement).toBeFalsy();
});
});
重要注意事項
CDK Overlay 特殊處理
// ❌ 錯誤:Overlay 元件不在 fixture 的 DOM 樹中
const tooltipElement = fixture.debugElement.query(By.css('.custom-tooltip'));
// ✅ 正確:Overlay 會附加到 document.body
const tooltipElement = document.querySelector('.custom-tooltip');
測試清理
afterEach(() => {
// 清理可能殘留的 DOM 元素
const tooltips = document.querySelectorAll('.custom-tooltip');
tooltips.forEach(tooltip => tooltip.remove());
});
非同步處理
it('should handle async tooltip creation', async () => {
divElement.triggerEventHandler('mouseenter', mouseEvent);
fixture.detectChanges();
// 等待非同步操作完成
await fixture.whenStable();
const tooltipElement = document.querySelector('.custom-tooltip');
expect(tooltipElement).toBeTruthy();
});
最佳實踐建議
分離關注點:單一測試只測試一個行為
命名清晰:測試名稱要明確說明測試目標
組織結構:使用
describe
將相關測試分組選擇合適方法:根據測試目標選擇 Spy 或 DOM 查詢
處理依賴:確保所有必要的模組都已 import
清理資源:避免測試間互相影響
這個測試指南涵蓋了 Angular directive 測試的核心概念,特別是 Spy 的使用時機和事件測試中座標設定的考量。透過理解這些概念,可以寫出更穩定、更有意義的測試程式碼。
Last updated