Component 測試策略
測試分層與分類
在 Angular 17 中,特別是使用 standalone 元件時,我們需要針對不同的功能層面制定測試策略。一個完整的元件測試應該涵蓋多個面向,但又要避免過度測試。
測試金字塔在 Angular 中的應用
E2E 測試
──────────
整合測試
──────────────
單元測試
──────────────────
單元測試 - 元件的個別功能(Input、Output、Methods)
整合測試 - 元件與其他元件/服務的互動
E2E 測試 - 完整的使用者流程
CVA vs Attribute Binding 測試策略
為什麼需要區分測試?
現代 Angular 元件通常支援兩種使用模式:
CVA (ControlValueAccessor) 模式 - 與 Reactive Forms 整合
Attribute Binding 模式 - 透過 Input/Output 屬性使用
這兩種模式有不同的資料流和行為,需要分別測試。
CVA 模式測試
基本 CVA 介面測試
describe('CVA Mode', () => {
it('should implement ControlValueAccessor interface', () => {
// 驗證必要方法存在
expect(typeof component.writeValue).toBe('function');
expect(typeof component.registerOnChange).toBe('function');
expect(typeof component.registerOnTouched).toBe('function');
expect(typeof component.setDisabledState).toBe('function');
});
it('should write value to internal control', () => {
const testValue = 'test-value';
component.writeValue(testValue);
expect(component.internalFormControl.value).toBe(testValue);
});
it('should not emit events when writeValue is called', () => {
const spy = jasmine.createSpy('selectionChangeSpy');
subscription = component.selectionChange.subscribe(spy);
// writeValue 不應該觸發事件(emitEvent: false)
component.writeValue('test-value');
expect(spy).not.toHaveBeenCalled();
});
});
CVA 回調函數測試
describe('CVA Callbacks', () => {
it('should register onChange callback', () => {
const onChangeSpy = jasmine.createSpy('onChange');
component.registerOnChange(onChangeSpy);
// 觸發內部值變更
component.internalFormControl.setValue('new-value');
expect(onChangeSpy).toHaveBeenCalledWith('new-value');
});
it('should register onTouched callback', () => {
const onTouchedSpy = jasmine.createSpy('onTouched');
component.registerOnTouched(onTouchedSpy);
// 觸發 touch 事件
component.onBlur();
expect(onTouchedSpy).toHaveBeenCalled();
});
it('should handle setDisabledState', () => {
component.setDisabledState(true);
expect(component.internalFormControl.disabled).toBe(true);
component.setDisabledState(false);
expect(component.internalFormControl.enabled).toBe(true);
});
});
CVA 與 FormControl 整合測試
describe('CVA FormControl Integration', () => {
it('should integrate correctly with FormControl', () => {
const testValue = 'integration-test';
const formControl = new FormControl(testValue);
// 模擬 Angular 如何使用 CVA
component.writeValue(testValue);
component.registerOnChange((value) => formControl.setValue(value));
component.registerOnTouched(() => formControl.markAsTouched());
fixture.detectChanges();
expect(component.internalFormControl.value).toBe(testValue);
expect(formControl.value).toBe(testValue);
});
it('should reflect disabled state in UI', () => {
component.setDisabledState(true);
fixture.detectChanges();
const formField = fixture.nativeElement.querySelector('mat-form-field');
expect(formField.classList).toContain('mat-form-field-disabled');
});
it('should handle validation states', () => {
const formControl = new FormControl('', Validators.required);
component.registerOnChange((value) => formControl.setValue(value));
component.registerOnTouched(() => formControl.markAsTouched());
// 觸發驗證
component.internalFormControl.setValue('');
component.onBlur();
expect(formControl.invalid).toBe(true);
expect(formControl.touched).toBe(true);
});
});
Attribute Binding 模式測試
基本 Input/Output 測試
describe('Attribute Binding Mode', () => {
it('should set value through input properties', () => {
const testValue = 'test-value';
fixture.componentRef.setInput('value', testValue);
fixture.detectChanges();
expect(component.value()).toBe(testValue);
expect(component.internalFormControl.value).toBe(testValue);
});
it('should emit events through output properties', () => {
const spy = jasmine.createSpy('selectionChangeSpy');
subscription = component.selectionChange.subscribe(spy);
// 在 Attribute Binding 模式下觸發變更
component.internalFormControl.setValue('new-value');
expect(spy).toHaveBeenCalledWith('new-value');
});
it('should handle disabled state through input', () => {
const testValue = 'test-value';
// 需要先設定值,因為元件的 effect 有這個條件
fixture.componentRef.setInput('value', testValue);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
expect(component.disabled()).toBe(true);
expect(component.internalFormControl.disabled).toBe(true);
});
});
雙向資料綁定模擬
describe('Two-way Data Binding Simulation', () => {
it('should simulate ngModel behavior', () => {
let modelValue = 'initial';
// 模擬 [(ngModel)] 的行為
const subscription = component.selectionChange.subscribe((value) => {
modelValue = value; // 模擬父元件更新模型
fixture.componentRef.setInput('value', value); // 模擬值回傳
});
// 初始設定
fixture.componentRef.setInput('value', modelValue);
fixture.detectChanges();
// 觸發變更
component.internalFormControl.setValue('updated');
fixture.detectChanges();
expect(modelValue).toBe('updated');
expect(component.value()).toBe('updated');
subscription.unsubscribe();
});
});
Input Properties 測試策略
基本屬性測試模板
describe('Input Properties', () => {
describe('title', () => {
it('should have empty string as default value', () => {
expect(component.title()).toBe('');
});
it('should update title when input value changes', () => {
const testTitle = 'Test Title';
fixture.componentRef.setInput('title', testTitle);
fixture.detectChanges();
expect(component.title()).toBe(testTitle);
});
it('should display title when provided', () => {
const testTitle = 'Test Title';
fixture.componentRef.setInput('title', testTitle);
fixture.detectChanges();
const titleElement = fixture.nativeElement.querySelector('.app-select-label');
expect(titleElement).not.toBeNull();
expect(titleElement.textContent).toContain(testTitle);
});
it('should not display title section when title is empty', () => {
fixture.componentRef.setInput('title', '');
fixture.detectChanges();
const titleSection = fixture.nativeElement.querySelector('.app-select-label');
expect(titleSection).toBeNull();
});
});
});
複雜屬性測試
物件型別的 Input
describe('options property', () => {
it('should have empty array as default value', () => {
expect(component.options()).toEqual([]);
});
it('should accept and process options correctly', () => {
const testOptions = [
{ value: 'option1', text: 'Option 1' },
{ value: 'option2', text: 'Option 2' }
];
fixture.componentRef.setInput('options', testOptions);
fixture.detectChanges();
expect(component.options()).toEqual(testOptions);
expect(component.groupedOptions.length).toBeGreaterThan(0);
});
it('should handle grouped options', () => {
const testOptions = [
{ value: 'apple', text: 'Apple', group: { name: 'Fruits', order: 1 } },
{ value: 'carrot', text: 'Carrot', group: { name: 'Vegetables', order: 2 } }
];
fixture.componentRef.setInput('options', testOptions);
fixture.detectChanges();
expect(component.hasGroups).toBe(true);
expect(component.groupedOptions).toHaveSize(2);
expect(component.groupedOptions[0].name).toBe('Fruits');
expect(component.groupedOptions[1].name).toBe('Vegetables');
});
it('should handle options with disabled items', () => {
const testOptions = [
{ value: 'option1', text: 'Option 1', disabled: true },
{ value: 'option2', text: 'Option 2', disabled: false }
];
fixture.componentRef.setInput('options', testOptions);
fixture.detectChanges();
const result = component.convertToGroupedOptions(testOptions);
expect(result[0].options[0].disabled).toBe(true);
expect(result[0].options[1].disabled).toBe(false);
});
});
條件顯示測試
describe('required property', () => {
it('should be false by default', () => {
expect(component.required()).toBe(false);
});
it('should display required indicator when both title and required are set', () => {
fixture.componentRef.setInput('title', 'Test Title');
fixture.componentRef.setInput('required', true);
fixture.detectChanges();
const requiredMark = fixture.nativeElement.querySelector('.required-mark');
expect(requiredMark).not.toBeNull();
expect(requiredMark.textContent).toBe('*');
});
it('should not display required mark when title is empty', () => {
fixture.componentRef.setInput('title', '');
fixture.componentRef.setInput('required', true);
fixture.detectChanges();
const requiredMark = fixture.nativeElement.querySelector('.required-mark');
expect(requiredMark).toBeNull();
});
});
錯誤處理和邊界測試
describe('Edge Cases and Error Handling', () => {
it('should handle null values gracefully', () => {
expect(() => {
fixture.componentRef.setInput('options', null);
fixture.detectChanges();
}).not.toThrow();
});
it('should handle undefined values', () => {
expect(() => {
fixture.componentRef.setInput('title', undefined);
fixture.detectChanges();
}).not.toThrow();
});
it('should handle empty string values', () => {
fixture.componentRef.setInput('placeholder', '');
fixture.detectChanges();
expect(component.placeholder()).toBe('');
});
it('should handle malformed option data', () => {
const malformedOptions = [
{ value: null, text: 'Invalid Option' },
{ value: 'valid', text: null },
{ /* missing required properties */ }
] as any;
expect(() => {
fixture.componentRef.setInput('options', malformedOptions);
fixture.detectChanges();
}).not.toThrow();
});
});
Output Events 測試策略
基本事件測試模板
describe('Output Events', () => {
let subscription: any;
afterEach(() => {
if (subscription) {
subscription.unsubscribe();
subscription = null;
}
});
describe('selectionChange', () => {
it('should emit selectionChange when value changes', () => {
const testValue = 'option1';
const spy = jasmine.createSpy('selectionChangeSpy');
subscription = component.selectionChange.subscribe(spy);
component.internalFormControl.setValue(testValue);
fixture.detectChanges();
expect(spy).toHaveBeenCalledWith(testValue);
expect(spy).toHaveBeenCalledTimes(1);
});
it('should not emit when writeValue is called', () => {
const spy = jasmine.createSpy('selectionChangeSpy');
subscription = component.selectionChange.subscribe(spy);
// CVA writeValue 不應該觸發事件
component.writeValue('test-value');
expect(spy).not.toHaveBeenCalled();
});
it('should emit correct data structure', () => {
const spy = jasmine.createSpy('selectionChangeSpy');
subscription = component.selectionChange.subscribe(spy);
component.internalFormControl.setValue('test-value');
// 驗證事件資料結構
expect(spy).toHaveBeenCalledWith(jasmine.any(String));
expect(spy).toHaveBeenCalledWith('test-value');
});
});
});
防抖事件測試
describe('searchChange (Debounced Events)', () => {
it('should debounce search changes', fakeAsync(() => {
const spy = jasmine.createSpy('searchChangeSpy');
subscription = component.searchChange.subscribe(spy);
// 快速連續輸入
component.searchFilterCtrl.setValue('a');
tick(100);
component.searchFilterCtrl.setValue('ab');
tick(100);
component.searchFilterCtrl.setValue('abc');
// 還沒到防抖時間,不應該觸發
expect(spy).not.toHaveBeenCalled();
// 等待防抖時間完成
tick(300);
// 只應該觸發一次,且是最後的值
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('abc');
flush();
}));
it('should handle rapid changes correctly', fakeAsync(() => {
const spy = jasmine.createSpy('searchChangeSpy');
subscription = component.searchChange.subscribe(spy);
// 模擬使用者快速輸入和刪除
component.searchFilterCtrl.setValue('search');
tick(150);
component.searchFilterCtrl.setValue('');
tick(150);
component.searchFilterCtrl.setValue('new search');
tick(300);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('new search');
flush();
}));
});
元件生命週期事件測試
describe('Component Lifecycle Events', () => {
it('should emit opened when dropdown opens', () => {
const spy = jasmine.createSpy('openedSpy');
subscription = component.opened.subscribe(spy);
// 使用元件的公開方法
component.open();
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
});
it('should emit closed when dropdown closes', async () => {
const spy = jasmine.createSpy('closedSpy');
subscription = component.closed.subscribe(spy);
// 透過 MatSelect 的 API 觸發關閉事件
const matSelectDebugElement = fixture.debugElement.query(By.directive(MatSelect));
// 開啟下拉選單
component.open();
fixture.detectChanges();
await fixture.whenStable();
// 觸發關閉事件
matSelectDebugElement.triggerEventHandler('closed', {});
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
});
});
Public Methods 測試策略
元件公開 API 測試
describe('Public Methods', () => {
it('should focus the select element', () => {
spyOn(component.matSelect, 'focus');
component.focus();
expect(component.matSelect.focus).toHaveBeenCalledTimes(1);
});
it('should clear the value', () => {
// 先設定一個值
component.internalFormControl.setValue('test-value');
expect(component.internalFormControl.value).toBe('test-value');
// 清除值
component.clear();
expect(component.internalFormControl.value).toBeNull();
});
it('should open the dropdown and emit opened event', () => {
const spy = jasmine.createSpy('openedSpy');
subscription = component.opened.subscribe(spy);
spyOn(component.matSelect, 'open');
component.open();
expect(component.matSelect.open).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
it('should handle method calls when component is not ready', () => {
// 測試元件未初始化時的行為
component.matSelect = undefined as any;
expect(() => {
component.focus();
}).not.toThrow();
});
});
錯誤處理測試
describe('Error Handling in Public Methods', () => {
it('should handle focus when matSelect is not available', () => {
const originalMatSelect = component.matSelect;
component.matSelect = null as any;
expect(() => {
component.focus();
}).not.toThrow();
component.matSelect = originalMatSelect;
});
it('should handle clear with null form control', () => {
const originalFormControl = component.internalFormControl;
component['_internalFormControl'] = null as any;
expect(() => {
component.clear();
}).not.toThrow();
component['_internalFormControl'] = originalFormControl;
});
});
Business Logic 測試策略
資料轉換邏輯測試
describe('Business Logic Methods', () => {
describe('convertToGroupedOptions', () => {
it('should group options correctly', () => {
const inputOptions = [
{ value: 'a', text: 'Apple', group: { name: 'Fruits' } },
{ value: 'c', text: 'Carrot', group: { name: 'Vegetables' } }
];
const result = component.convertToGroupedOptions(inputOptions);
expect(result).toHaveSize(2);
expect(result.map(g => g.name)).toEqual(['Fruits', 'Vegetables']);
});
it('should handle ungrouped options', () => {
const inputOptions = [
{ value: 'a', text: 'Apple' },
{ value: 'b', text: 'Banana' }
];
const result = component.convertToGroupedOptions(inputOptions);
expect(result).toHaveSize(1);
expect(result[0].name).toBe('uncategorized');
expect(result[0].options).toHaveSize(2);
});
it('should sort groups by order', () => {
const inputOptions = [
{ value: 'c', text: 'Carrot', group: { name: 'Vegetables', order: 2 } },
{ value: 'a', text: 'Apple', group: { name: 'Fruits', order: 1 } }
];
const result = component.convertToGroupedOptions(inputOptions);
expect(result.map(g => g.name)).toEqual(['Fruits', 'Vegetables']);
});
it('should handle mixed grouped and ungrouped options', () => {
const inputOptions = [
{ value: 'a', text: 'Apple', group: { name: 'Fruits' } },
{ value: 'b', text: 'Banana' }, // 沒有 group
{ value: 'c', text: 'Carrot', group: { name: 'Vegetables' } }
];
const result = component.convertToGroupedOptions(inputOptions);
expect(result).toHaveSize(3);
expect(result.map(g => g.name)).toContain('Fruits');
expect(result.map(g => g.name)).toContain('Vegetables');
expect(result.map(g => g.name)).toContain('uncategorized');
});
it('should set hasGroups flag correctly', () => {
const ungroupedOptions = [
{ value: 'a', text: 'Apple' },
{ value: 'b', text: 'Banana' }
];
component.convertToGroupedOptions(ungroupedOptions);
expect(component.hasGroups).toBe(false);
const groupedOptions = [
{ value: 'a', text: 'Apple', group: { name: 'Fruits' } },
{ value: 'b', text: 'Banana' }
];
component.convertToGroupedOptions(groupedOptions);
expect(component.hasGroups).toBe(true);
});
});
});
過濾邏輯測試
describe('filterOptions', () => {
beforeEach(() => {
const testOptions = [
{ value: 'apple', text: 'Apple' },
{ value: 'apricot', text: 'Apricot' },
{ value: 'banana', text: 'Banana' }
];
fixture.componentRef.setInput('options', testOptions);
component.ngOnInit();
fixture.detectChanges();
});
it('should filter options based on search term', fakeAsync(() => {
let filteredResult: any[] = [];
component.filteredOptions.subscribe(options => {
filteredResult = options;
});
component.filterOptions('app');
tick();
expect(filteredResult[0].options).toHaveSize(2);
expect(filteredResult[0].options.map(o => o.value)).toEqual(['apple', 'apricot']);
flush();
}));
it('should be case insensitive', fakeAsync(() => {
let filteredResult: any[] = [];
component.filteredOptions.subscribe(options => {
filteredResult = options;
});
component.filterOptions('APPLE');
tick();
expect(filteredResult[0].options).toHaveSize(1);
expect(filteredResult[0].options[0].value).toBe('apple');
flush();
}));
it('should restore all options when search term is empty', fakeAsync(() => {
let filteredResult: any[] = [];
component.filteredOptions.subscribe(options => {
filteredResult = options;
});
component.filterOptions('');
tick();
expect(filteredResult[0].options).toHaveSize(3);
flush();
}));
it('should emit searchChange event', () => {
const spy = jasmine.createSpy('searchChangeSpy');
subscription = component.searchChange.subscribe(spy);
component.filterOptions('apple');
expect(spy).toHaveBeenCalledWith('apple');
});
});
選擇邏輯測試
describe('getSelectedOption', () => {
beforeEach(() => {
const testOptions = [
{ value: 'apple', text: 'Apple', optionIcon: 'fruit-icon' },
{ value: 'banana', text: 'Banana' }
];
fixture.componentRef.setInput('options', testOptions);
fixture.detectChanges();
});
it('should return selected option details', () => {
component.internalFormControl.setValue('apple');
const selectedOption = component.getSelectedOption();
expect(selectedOption).toEqual({
value: 'apple',
text: 'Apple',
disabled: false,
optionIcon: 'fruit-icon'
});
});
it('should return null when no option is selected', () => {
component.internalFormControl.setValue(null);
const selectedOption = component.getSelectedOption();
expect(selectedOption).toBeNull();
});
it('should return null when selected value does not exist in options', () => {
component.internalFormControl.setValue('non-existent');
const selectedOption = component.getSelectedOption();
expect(selectedOption).toBeNull();
});
});
UI 渲染與互動測試
條件渲染測試
describe('UI Rendering', () => {
it('should display error message instead of help text when error exists', () => {
const errorMessage = 'This field is required';
const helpText = 'This is help text';
fixture.componentRef.setInput('errorMessage', errorMessage);
fixture.componentRef.setInput('helpText', helpText);
fixture.detectChanges();
const errorElement = fixture.nativeElement.querySelector('.error-message');
const helpElement = fixture.nativeElement.querySelector('.help-text');
expect(errorElement).not.toBeNull();
expect(errorElement.textContent).toContain(errorMessage);
expect(helpElement).toBeNull(); // help text 不應該顯示
});
it('should apply error styling when errorMessage is provided', () => {
fixture.componentRef.setInput('errorMessage', 'Error occurred');
fixture.detectChanges();
const formField = fixture.nativeElement.querySelector('mat-form-field');
expect(formField.classList).toContain('--error-state');
});
it('should show search component when searchable is enabled', async () => {
fixture.componentRef.setInput('searchable', true);
fixture.detectChanges();
const selectElement = fixture.nativeElement.querySelector('mat-select');
selectElement.click();
fixture.detectChanges();
await fixture.whenStable();
const searchComponent = document.querySelector('ngx-mat-select-search');
expect(searchComponent).not.toBeNull();
});
});
使用者互動測試
describe('User Interactions', () => {
it('should handle click events', () => {
const spy = jasmine.createSpy('clickSpy');
subscription = component.opened.subscribe(spy);
const selectElement = fixture.nativeElement.querySelector('mat-select');
selectElement.click();
fixture.detectChanges();
expect(spy).toHaveBeenCalled();
});
it('should handle keyboard navigation', () => {
const selectElement = fixture.nativeElement.querySelector('mat-select');
// 模擬鍵盤事件
const event = new KeyboardEvent('keydown', { key: 'ArrowDown' });
selectElement.dispatchEvent(event);
fixture.detectChanges();
// 驗證鍵盤導航沒有出錯
expect(selectElement).toBeTruthy();
});
});
測試策略最佳實踐
1. 測試結構組織
describe('ComponentName', () => {
describe('Component Creation', () => {
it('should create', () => {});
});
describe('CVA Mode', () => {
// CVA 相關測試
});
describe('Attribute Binding Mode', () => {
// Attribute binding 相關測試
});
describe('Input Properties', () => {
describe('propertyName', () => {
it('should have correct default value', () => {});
it('should accept input value and update property', () => {});
it('should display property when provided', () => {});
});
});
describe('Output Events', () => {
let subscription: any;
afterEach(() => {
if (subscription) {
subscription.unsubscribe();
subscription = null;
}
});
// Event 測試
});
describe('Public Methods', () => {
// 公開方法測試
});
describe('Business Logic', () => {
// 商業邏輯測試
});
describe('UI Rendering', () => {
// 渲染和互動測試
});
describe('Error Handling', () => {
// 錯誤處理和邊界測試
});
});
2. 測試覆蓋率優先級
高優先級(必須測試):
✅ CVA 介面實作
✅ 公開 Input/Output 屬性
✅ 核心商業邏輯
✅ 錯誤處理和邊界情況
中優先級(建議測試):
✅ UI 互動和渲染
✅ 公開方法
✅ 複雜的使用者流程
低優先級(選擇性測試):
❓ 私有方法(透過公開方法間接測試)
❓ 簡單的 getter/setter
❓ 第三方程式庫的功能
3. 測試可維護性
// ✅ 好的做法 - 使用 Page Object 模式
class ComponentPageObject {
constructor(private fixture: ComponentFixture<any>) {}
get titleElement() {
return this.fixture.nativeElement.querySelector('.app-select-label');
}
get errorElement() {
return this.fixture.nativeElement.querySelector('.error-message');
}
get selectElement() {
return this.fixture.nativeElement.querySelector('mat-select');
}
setTitle(title: string) {
this.fixture.componentRef.setInput('title', title);
this.fixture.detectChanges();
}
clickSelect() {
this.selectElement.click();
this.fixture.detectChanges();
}
}
// 使用 Page Object
describe('ComponentName with Page Object', () => {
let pageObject: ComponentPageObject;
beforeEach(() => {
pageObject = new ComponentPageObject(fixture);
});
it('should display title correctly', () => {
pageObject.setTitle('Test Title');
expect(pageObject.titleElement.textContent).toContain('Test Title');
});
});
4. 測試資料管理
// ✅ 使用測試資料工廠
class TestDataFactory {
static createOptions(count: number = 3) {
return Array.from({ length: count }, (_, i) => ({
value: `option${i + 1}`,
text: `Option ${i + 1}`
}));
}
static createGroupedOptions() {
return [
{ value: 'apple', text: 'Apple', group: { name: 'Fruits', order: 1 } },
{ value: 'carrot', text: 'Carrot', group: { name: 'Vegetables', order: 2 } }
];
}
static createFormControl(value: any = null, validators: any[] = []) {
return new FormControl(value, validators);
}
}
// 使用測試資料工廠
it('should handle options correctly', () => {
const testOptions = TestDataFactory.createOptions(5);
fixture.componentRef.setInput('options', testOptions);
fixture.detectChanges();
expect(component.options()).toEqual(testOptions);
});
5. 異步測試處理
// ✅ 統一的異步測試處理
describe('Async Operations', () => {
it('should handle async operations with whenStable', async () => {
component.loadData();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(component.data).toBeDefined();
});
it('should handle timing with fakeAsync', fakeAsync(() => {
component.startTimer();
tick(1000);
expect(component.timerFinished).toBe(true);
flush();
}));
});
進階測試技巧
1. 測試組合行為
describe('Complex Scenarios', () => {
it('should handle multiple property changes together', () => {
// 模擬真實使用場景的複雜操作
fixture.componentRef.setInput('title', 'Complex Test');
fixture.componentRef.setInput('required', true);
fixture.componentRef.setInput('disabled', false);
fixture.componentRef.setInput('options', TestDataFactory.createOptions());
fixture.detectChanges();
// 驗證所有變更都正確處理
expect(component.title()).toBe('Complex Test');
expect(component.required()).toBe(true);
expect(component.disabled()).toBe(false);
expect(component.options().length).toBe(3);
// 驗證 UI 狀態
const titleElement = fixture.nativeElement.querySelector('.app-select-label');
const requiredMark = fixture.nativeElement.querySelector('.required-mark');
expect(titleElement).not.toBeNull();
expect(requiredMark).not.toBeNull();
});
it('should handle mode switching', () => {
// 測試從 Attribute Binding 模式切換到 CVA 模式
// (雖然實際上不會在運行時切換,但測試元件的彈性)
// 先以 Attribute Binding 模式測試
fixture.componentRef.setInput('value', 'initial');
fixture.detectChanges();
expect(component.value()).toBe('initial');
// 再以 CVA 模式測試
const onChangeSpy = jasmine.createSpy('onChange');
component.registerOnChange(onChangeSpy);
component.writeValue('updated');
expect(component.internalFormControl.value).toBe('updated');
});
});
2. 效能相關測試
describe('Performance Considerations', () => {
it('should not trigger unnecessary change detection', () => {
const spy = spyOn(component, 'convertToGroupedOptions').and.callThrough();
// 設定相同的選項多次
const options = TestDataFactory.createOptions();
fixture.componentRef.setInput('options', options);
fixture.detectChanges();
fixture.componentRef.setInput('options', options); // 相同的引用
fixture.detectChanges();
// 應該只被調用一次(如果有適當的優化)
expect(spy).toHaveBeenCalledTimes(1);
});
it('should handle large datasets efficiently', () => {
const largeOptions = TestDataFactory.createOptions(1000);
const startTime = performance.now();
fixture.componentRef.setInput('options', largeOptions);
fixture.detectChanges();
const endTime = performance.now();
// 確保處理大量資料時性能合理(這個閾值需要根據實際情況調整)
expect(endTime - startTime).toBeLessThan(100); // 100ms
});
});
3. 可訪問性測試
describe('Accessibility', () => {
it('should have proper ARIA attributes', () => {
fixture.componentRef.setInput('title', 'Accessible Select');
fixture.componentRef.setInput('required', true);
fixture.detectChanges();
const selectElement = fixture.nativeElement.querySelector('mat-select');
// 檢查基本的 ARIA 屬性
expect(selectElement.getAttribute('aria-required')).toBe('true');
expect(selectElement.getAttribute('role')).toBe('combobox');
});
it('should be keyboard navigable', () => {
const selectElement = fixture.nativeElement.querySelector('mat-select');
// 模擬鍵盤導航
selectElement.focus();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
selectElement.dispatchEvent(enterEvent);
fixture.detectChanges();
// 驗證鍵盤操作有效
expect(document.activeElement).toBe(selectElement);
});
});
總結
一個完整的 Angular 元件測試策略應該包含:
核心原則
分層測試 - CVA 模式 vs Attribute Binding 模式
全面覆蓋 - Input、Output、Methods、Business Logic
實用導向 - 專注於防止回歸錯誤和關鍵功能
可維護性 - 使用 Page Object、測試資料工廠等模式
測試重點
公開 API - 所有對外暴露的介面
商業邏輯 - 核心功能和複雜計算
邊界條件 - 錯誤處理和異常情況
整合行為 - 與其他元件和服務的互動
最佳實踐
結構化組織 - 清晰的 describe 區塊分組
一致的命名 - 使用統一的測試描述模板
適當的清理 - 訂閱取消、資源釋放
效能考量 - 避免不必要的重複測試
透過這些策略和技巧,可以建立穩定、可靠且易於維護的 Angular 元件測試套件,確保程式碼品質和長期可維護性。
Last updated