Component 測試策略

測試分層與分類

在 Angular 17 中,特別是使用 standalone 元件時,我們需要針對不同的功能層面制定測試策略。一個完整的元件測試應該涵蓋多個面向,但又要避免過度測試。

測試金字塔在 Angular 中的應用

        E2E 測試
       ──────────
      整合測試
    ──────────────
   單元測試
──────────────────
  • 單元測試 - 元件的個別功能(Input、Output、Methods)

  • 整合測試 - 元件與其他元件/服務的互動

  • E2E 測試 - 完整的使用者流程

CVA vs Attribute Binding 測試策略

為什麼需要區分測試?

現代 Angular 元件通常支援兩種使用模式:

  1. CVA (ControlValueAccessor) 模式 - 與 Reactive Forms 整合

  2. 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