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 的情況

  1. 測試服務方法呼叫:

const httpSpy = spyOn(httpClient, 'get');
service.getData();
expect(httpSpy).toHaveBeenCalledWith('/api/data');
  1. 測試條件邏輯:

const consoleSpy = spyOn(console, 'error');
service.handleError('test');
expect(consoleSpy).toHaveBeenCalled();
  1. Mock 外部依賴:

spyOn(service, 'getValue').and.returnValue('mocked value');

不應該使用 Spy 的情況

  1. 測試 UI 互動結果 - 用 DOM 查詢

  2. 測試資料綁定 - 用 fixture.detectChanges() + DOM 查詢

  3. 測試樣式或佈局 - 用真實渲染

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();
});

最佳實踐建議

  1. 分離關注點:單一測試只測試一個行為

  2. 命名清晰:測試名稱要明確說明測試目標

  3. 組織結構:使用 describe 將相關測試分組

  4. 選擇合適方法:根據測試目標選擇 Spy 或 DOM 查詢

  5. 處理依賴:確保所有必要的模組都已 import

  6. 清理資源:避免測試間互相影響

這個測試指南涵蓋了 Angular directive 測試的核心概念,特別是 Spy 的使用時機和事件測試中座標設定的考量。透過理解這些概念,可以寫出更穩定、更有意義的測試程式碼。

Last updated