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