ComponentFixture 詳解

ComponentFixture 是什麼?

ComponentFixture 是 Angular 測試框架提供的專門工具,用於控制和操作測試中的元件。它不是一般的物件,而是連接測試程式碼和 Angular 元件之間的橋樑,提供了完整的測試控制能力。

為什麼需要 ComponentFixture?

在 Angular 的元件測試中,我們不能直接用 new ComponentName() 來建立元件,因為:

  • 缺少 Angular 環境 - 沒有依賴注入、變更檢測等機制

  • 沒有 DOM 渲染 - 無法測試 template 和使用者互動

  • 缺少生命週期 - ngOnInit、ngOnDestroy 等不會執行

  • 無法測試綁定 - 輸入屬性、輸出事件無法正常運作

ComponentFixture 解決了這些問題,提供完整的 Angular 測試環境。

ComponentFixture 的核心屬性

介面定義

interface ComponentFixture<T> {
  componentInstance: T;              // 元件實例
  nativeElement: any;                // 原生 DOM 元素
  debugElement: DebugElement;        // Angular 除錯元素
  changeDetectorRef: ChangeDetectorRef; // 變更檢測器
  detectChanges(): void;             // 觸發變更檢測
  whenStable(): Promise<void>;       // 等待穩定狀態
  checkNoChanges(): void;            // 檢查沒有變更
  autoDetectChanges(autoDetect?: boolean): void; // 自動變更檢測
}

核心屬性詳解

1. componentInstance - 元件實例

let fixture: ComponentFixture<YourComponent>;
let component: YourComponent;

beforeEach(() => {
  fixture = TestBed.createComponent(YourComponent);
  component = fixture.componentInstance; // 取得元件實例
});

it('should access component properties', () => {
  // 直接存取元件的屬性和方法
  component.title = 'Test Title';
  component.isVisible = true;
  component.loadData();
  
  expect(component.title).toBe('Test Title');
  expect(component.isVisible).toBe(true);
});

2. nativeElement - 原生 DOM 元素

it('should query DOM using nativeElement', () => {
  // nativeElement 是 HTMLElement,可以使用原生 DOM API
  const buttonElement = fixture.nativeElement.querySelector('button');
  const allButtons = fixture.nativeElement.querySelectorAll('button');
  const titleElement = fixture.nativeElement.querySelector('.title');
  
  expect(buttonElement).toBeTruthy();
  expect(allButtons.length).toBeGreaterThan(0);
  expect(titleElement.textContent).toContain('Expected Text');
});

3. debugElement - Angular 除錯元素

import { By } from '@angular/platform-browser';

it('should query DOM using debugElement', () => {
  // debugElement 提供 Angular 特定的查詢功能
  const buttonDebugEl = fixture.debugElement.query(By.css('button'));
  const allButtonsDebugEl = fixture.debugElement.queryAll(By.css('button'));
  
  // 查詢特定的 directive 或 component
  const directiveEl = fixture.debugElement.query(By.directive(YourDirective));
  const componentEl = fixture.debugElement.query(By.directive(ChildComponent));
  
  // 觸發事件
  buttonDebugEl.triggerEventHandler('click', null);
  
  // 取得原生元素
  const nativeButton = buttonDebugEl.nativeElement;
});

建立和使用 ComponentFixture

基本建立流程

describe('YourComponent', () => {
  let fixture: ComponentFixture<YourComponent>;
  let component: YourComponent;

  beforeEach(async () => {
    // 1. 配置測試模組
    await TestBed.configureTestingModule({
      imports: [YourComponent, /* 其他依賴 */],
    }).compileComponents();

    // 2. 建立 ComponentFixture
    fixture = TestBed.createComponent(YourComponent);
    
    // 3. 取得元件實例
    component = fixture.componentInstance;
    
    // 4. 觸發初始變更檢測
    fixture.detectChanges();
  });
});

為什麼需要 detectChanges()?

it('should understand detectChanges timing', () => {
  // 修改元件屬性
  component.title = 'New Title';
  
  // ❌ 這時候 DOM 還沒更新
  let titleElement = fixture.nativeElement.querySelector('.title');
  console.log(titleElement.textContent); // 還是舊的值
  
  // ✅ 手動觸發變更檢測
  fixture.detectChanges();
  
  // ✅ 現在 DOM 已經更新
  titleElement = fixture.nativeElement.querySelector('.title');
  expect(titleElement.textContent).toBe('New Title');
});

變更檢測控制

手動變更檢測

這是最常用的模式,完全控制何時更新 DOM:

it('should control change detection manually', () => {
  // 1. 修改多個屬性
  component.title = 'New Title';
  component.isVisible = true;
  component.count = 10;
  
  // 2. 一次性觸發變更檢測
  fixture.detectChanges();
  
  // 3. 驗證所有變更都已反映到 DOM
  const titleEl = fixture.nativeElement.querySelector('.title');
  const visibleEl = fixture.nativeElement.querySelector('.visible-content');
  const countEl = fixture.nativeElement.querySelector('.count');
  
  expect(titleEl.textContent).toBe('New Title');
  expect(visibleEl).not.toBeNull();
  expect(countEl.textContent).toBe('10');
});

自動變更檢測

在某些情況下可以啟用自動變更檢測:

it('should use automatic change detection', () => {
  // 啟用自動變更檢測
  fixture.autoDetectChanges();
  
  // 修改屬性後自動更新 DOM
  component.title = 'Auto Updated';
  
  // 不需要手動調用 detectChanges()
  const titleEl = fixture.nativeElement.querySelector('.title');
  expect(titleEl.textContent).toBe('Auto Updated');
});

checkNoChanges() - 確保穩定性

it('should verify no unexpected changes', () => {
  fixture.detectChanges();
  
  // 驗證沒有意外的變更
  expect(() => {
    fixture.checkNoChanges();
  }).not.toThrow();
});

DOM 查詢與操作

使用 nativeElement 查詢

適用於簡單的 DOM 操作:

it('should query DOM elements', () => {
  fixture.detectChanges();
  
  // 基本查詢
  const button = fixture.nativeElement.querySelector('button');
  const allButtons = fixture.nativeElement.querySelectorAll('button');
  const form = fixture.nativeElement.querySelector('form');
  
  // 檢查元素存在
  expect(button).toBeTruthy();
  expect(allButtons.length).toBe(3);
  expect(form).not.toBeNull();
  
  // 檢查內容和屬性
  expect(button.textContent.trim()).toBe('Click Me');
  expect(button.disabled).toBe(false);
  expect(form.classList.contains('valid')).toBe(true);
});

使用 debugElement 查詢

適用於複雜的 Angular 查詢:

import { By } from '@angular/platform-browser';

it('should query using debugElement', () => {
  fixture.detectChanges();
  
  // CSS 選擇器查詢
  const buttonDebugEl = fixture.debugElement.query(By.css('button'));
  const allButtonsDebugEl = fixture.debugElement.queryAll(By.css('button'));
  
  // Directive 查詢
  const directiveEl = fixture.debugElement.query(By.directive(YourDirective));
  
  // Component 查詢
  const childComponentEl = fixture.debugElement.query(By.directive(ChildComponent));
  
  // 屬性查詢
  const requiredInputs = fixture.debugElement.queryAll(By.css('input[required]'));
  
  expect(buttonDebugEl).toBeTruthy();
  expect(allButtonsDebugEl.length).toBe(3);
  expect(directiveEl).toBeTruthy();
  expect(childComponentEl).toBeTruthy();
  expect(requiredInputs.length).toBeGreaterThan(0);
});

觸發事件

it('should trigger events', () => {
  fixture.detectChanges();
  
  // 方法 1: 使用 nativeElement
  const button = fixture.nativeElement.querySelector('button');
  button.click();
  
  // 方法 2: 使用 debugElement
  const buttonDebugEl = fixture.debugElement.query(By.css('button'));
  buttonDebugEl.triggerEventHandler('click', null);
  
  // 方法 3: 觸發自訂事件
  const input = fixture.nativeElement.querySelector('input');
  input.value = 'test value';
  input.dispatchEvent(new Event('input'));
  
  fixture.detectChanges();
  
  // 驗證事件結果
  expect(component.buttonClicked).toBe(true);
  expect(component.inputValue).toBe('test value');
});

異步操作處理

whenStable() - 等待穩定狀態

it('should handle async operations', async () => {
  // 觸發異步操作
  component.loadData();
  fixture.detectChanges();
  
  // 等待所有異步操作完成
  await fixture.whenStable();
  
  // 再次觸發變更檢測以更新 DOM
  fixture.detectChanges();
  
  // 驗證異步結果
  expect(component.data.length).toBeGreaterThan(0);
  const dataElements = fixture.nativeElement.querySelectorAll('.data-item');
  expect(dataElements.length).toBe(component.data.length);
});

結合 fakeAsync 使用

import { fakeAsync, tick, flush } from '@angular/core/testing';

it('should handle timed operations', fakeAsync(() => {
  // 觸發有延遲的操作
  component.startTimer();
  fixture.detectChanges();
  
  // 快進時間
  tick(5000);
  fixture.detectChanges();
  
  // 驗證計時器結果
  expect(component.timerFinished).toBe(true);
  
  // 清理剩餘計時器
  flush();
}));

輸入屬性測試

Angular 17 Signal-based Inputs

it('should test signal-based inputs', () => {
  // 設定輸入值
  fixture.componentRef.setInput('title', 'Test Title');
  fixture.componentRef.setInput('isVisible', true);
  fixture.componentRef.setInput('items', ['item1', 'item2']);
  
  fixture.detectChanges();
  
  // 驗證 signal 值
  expect(component.title()).toBe('Test Title');
  expect(component.isVisible()).toBe(true);
  expect(component.items()).toEqual(['item1', 'item2']);
  
  // 驗證 DOM 更新
  const titleEl = fixture.nativeElement.querySelector('.title');
  expect(titleEl.textContent).toBe('Test Title');
});

傳統 @Input() 屬性

it('should test traditional inputs', () => {
  // 直接設定屬性
  component.title = 'Test Title';
  component.isVisible = true;
  
  fixture.detectChanges();
  
  expect(component.title).toBe('Test Title');
  expect(component.isVisible).toBe(true);
});

輸出事件測試

訂閱 EventEmitter

it('should test output events', () => {
  let emittedValue: any;
  const subscription = component.valueChange.subscribe((value: any) => {
    emittedValue = value;
  });
  
  // 觸發事件
  component.updateValue('new value');
  
  expect(emittedValue).toBe('new value');
  
  // 清理訂閱
  subscription.unsubscribe();
});

使用 Spy 監聽事件

it('should test events with spy', () => {
  const spy = jasmine.createSpy('valueChangeSpy');
  const subscription = component.valueChange.subscribe(spy);
  
  component.updateValue('test value');
  
  expect(spy).toHaveBeenCalledWith('test value');
  expect(spy).toHaveBeenCalledTimes(1);
  
  subscription.unsubscribe();
});

ComponentFixture 最佳實踐

1. 適當的生命週期管理

describe('YourComponent', () => {
  let fixture: ComponentFixture<YourComponent>;
  let component: YourComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [YourComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(YourComponent);
    component = fixture.componentInstance;
    fixture.detectChanges(); // 觸發 ngOnInit
  });

  afterEach(() => {
    // 清理資源
    fixture.destroy();
  });
});

2. 有效的變更檢測策略

it('should batch property changes', () => {
  // ✅ 好的做法 - 批次更新後一次 detectChanges
  component.title = 'New Title';
  component.isVisible = true;
  component.count = 10;
  fixture.detectChanges(); // 一次更新所有變更
  
  // 驗證結果...
});

// ❌ 避免的做法 - 每次變更都 detectChanges
it('should avoid excessive detectChanges', () => {
  component.title = 'New Title';
  fixture.detectChanges(); // 不必要
  
  component.isVisible = true;
  fixture.detectChanges(); // 不必要
  
  component.count = 10;
  fixture.detectChanges(); // 只有最後這次是必要的
});

3. 錯誤處理和邊界測試

it('should handle null values gracefully', () => {
  fixture.componentRef.setInput('data', null);
  
  expect(() => {
    fixture.detectChanges();
  }).not.toThrow();
  
  // 驗證元件如何處理 null 值
  expect(component.data()).toBeNull();
});

it('should handle empty arrays', () => {
  fixture.componentRef.setInput('items', []);
  fixture.detectChanges();
  
  const listItems = fixture.nativeElement.querySelectorAll('.list-item');
  expect(listItems.length).toBe(0);
  
  const emptyMessage = fixture.nativeElement.querySelector('.empty-message');
  expect(emptyMessage).toBeTruthy();
});

常見問題與解決方案

1. 忘記 detectChanges()

// ❌ 常見錯誤
it('should display updated title', () => {
  component.title = 'New Title';
  // 忘記 detectChanges()
  
  const titleEl = fixture.nativeElement.querySelector('.title');
  expect(titleEl.textContent).toBe('New Title'); // 可能失敗
});

// ✅ 正確做法
it('should display updated title', () => {
  component.title = 'New Title';
  fixture.detectChanges(); // 必要的變更檢測
  
  const titleEl = fixture.nativeElement.querySelector('.title');
  expect(titleEl.textContent).toBe('New Title');
});

2. 異步操作時機問題

// ❌ 錯誤 - 沒有等待異步操作
it('should load data', () => {
  component.loadData();
  fixture.detectChanges();
  
  expect(component.data).toBeDefined(); // 可能失敗
});

// ✅ 正確 - 等待異步操作完成
it('should load data', async () => {
  component.loadData();
  fixture.detectChanges();
  
  await fixture.whenStable();
  fixture.detectChanges();
  
  expect(component.data).toBeDefined();
});

3. DOM 查詢失敗

it('should find DOM elements correctly', () => {
  component.showContent = true;
  fixture.detectChanges(); // 確保 DOM 已更新
  
  // 使用更具體的選擇器
  const content = fixture.nativeElement.querySelector('[data-testid="content"]');
  
  // 檢查元素是否存在再進行斷言
  expect(content).not.toBeNull();
  if (content) {
    expect(content.textContent.trim()).toBe('Expected Content');
  }
});

總結

ComponentFixture 是 Angular 測試的核心工具,提供:

  • 完整的元件控制 - componentInstance、nativeElement、debugElement

  • 變更檢測管理 - detectChanges()、whenStable()、autoDetectChanges()

  • DOM 查詢功能 - 原生查詢和 Angular 特定查詢

  • 事件處理能力 - 觸發和監聽各種事件

  • 異步操作支援 - 處理 Promise、Observable 等

掌握 ComponentFixture 的正確使用方式,是寫出穩定可靠測試的關鍵基礎。

Last updated