Host Component 測試模式

什麼是 Host Component?

Host Component 是專門為了測試而建立的「宿主元件」,它為被測試的 directive 或 child component 提供一個真實的 Angular 環境。這種測試模式特別適用於需要在真實 DOM 環境中運行的元件測試。

為什麼需要 Host Component?

在 Angular 測試中,有些情況下直接測試元件會遇到困難:

1. Directive 無法單獨存在

// ❌ Directive 無法這樣測試
describe('MyDirective', () => {
  it('should create', () => {
    const directive = new MyDirective(); // 錯誤:缺少依賴
    expect(directive).toBeTruthy();
  });
});

2. 複雜的依賴注入

// ❌ 手動模擬所有依賴很複雜
describe('MyDirective', () => {
  it('should create', () => {
    const mockElement = {} as ElementRef;
    const mockRenderer = {} as Renderer2;
    const mockDestroyRef = {} as DestroyRef;
    
    TestBed.runInInjectionContext(() => {
      const directive = new MyDirective(mockElement, mockRenderer, mockDestroyRef);
      // 還需要手動設定更多東西...
    });
  });
});

3. 缺少真實的使用情境

// ❌ 無法測試 directive 與宿主元素的互動
// 無法測試 child component 在父元件中的行為

Host Component 的基本結構

簡單的 Host Component 範例

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyDirective } from './my-directive';

@Component({
  standalone: true,
  imports: [MyDirective],
  template: `
    <div 
      myDirective
      [property1]="value1"
      [property2]="value2"
      (event1)="onEvent1($event)">
      Test Content
    </div>
  `,
})
class HostComponent {
  value1 = 'default';
  value2 = false;
  
  onEvent1(event: any) {
    console.log('Event received:', event);
  }
}

describe('MyDirective', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

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

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });
});

Host Component 的組成要素

1. Component 裝飾器配置

@Component({
  standalone: true,                    // Angular 17 standalone 模式
  imports: [                          // 匯入要測試的 directive/component
    MyDirective,                      // 被測試的 directive
    MatSelectModule,                  // 相關依賴模組
    ReactiveFormsModule,              // 其他需要的模組
  ],
  template: `...`,                    // 真實的使用模板
})

2. Template 設計

template: `
  <div 
    myDirective                       <!-- 使用被測試的 directive -->
    [inputProperty]="hostProperty"    <!-- 綁定 input 屬性 -->
    (outputEvent)="onEvent($event)"   <!-- 監聽 output 事件 -->
    class="test-wrapper">
    <!-- 提供真實的內容 -->
    <span>Test Content</span>
  </div>
`

3. Host Component 類別

class HostComponent {
  // 可控制的屬性
  hostProperty = 'initial value';
  isEnabled = true;
  
  // 事件處理器
  onEvent(event: any) {
    // 測試中可以驗證事件是否被觸發
  }
  
  // 測試輔助方法
  updateProperties() {
    this.hostProperty = 'updated value';
    this.isEnabled = false;
  }
}

Directive 測試實例

完整的 Directive 測試範例

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSelectKeyboardNavigationDirective } from './mat-select-keyboard-navigation.directive';
import { MatSelectModule } from '@angular/material/select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';

@Component({
  standalone: true,
  imports: [
    MatSelectKeyboardNavigationDirective,
    MatSelectModule,
    MatFormFieldModule,
  ],
  template: `
    <mat-form-field>
      <mat-select 
        appMatSelectKeyboardNavigation
        [searchable]="searchable"
        [isMultipleSelector]="isMultiple"
        [stickyHeaderHeight]="headerHeight">
        <mat-option value="option1">Option 1</mat-option>
        <mat-option value="option2">Option 2</mat-option>
        <mat-option value="option3">Option 3</mat-option>
      </mat-select>
    </mat-form-field>
  `,
})
class HostComponent {
  searchable = false;
  isMultiple = false;
  headerHeight: number | null = null;
}

describe('MatSelectKeyboardNavigationDirective', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

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

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should apply directive without errors', () => {
    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(matSelect).toBeTruthy();
    
    // 驗證 directive 成功附加(沒有拋出錯誤)
    expect(() => {
      matSelect.click();
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle input property changes', () => {
    host.searchable = true;
    host.isMultiple = true;
    host.headerHeight = 100;
    fixture.detectChanges();

    // 驗證屬性變更沒有造成錯誤
    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(matSelect).toBeTruthy();
  });
});

進階 Directive 測試

如果需要測試更具體的行為:

describe('MatSelectKeyboardNavigationDirective - Advanced', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

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

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should handle searchable mode', () => {
    host.searchable = true;
    fixture.detectChanges();

    const matSelect = fixture.nativeElement.querySelector('mat-select');
    
    // 測試 searchable 模式的特定行為
    expect(() => {
      matSelect.click();
      fixture.detectChanges();
    }).not.toThrow();
  });

  it('should handle multiple selector mode', () => {
    host.isMultiple = true;
    fixture.detectChanges();

    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(matSelect).toBeTruthy();
  });

  it('should handle custom header height', () => {
    host.headerHeight = 200;
    fixture.detectChanges();

    // 驗證自訂高度設定不會造成錯誤
    const matSelect = fixture.nativeElement.querySelector('mat-select');
    expect(matSelect).toBeTruthy();
  });
});

Child Component 測試實例

測試 Child Component 的 Input/Output

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IconBtnComponent, IconBtnProps } from './icon-btn.component';

@Component({
  standalone: true,
  imports: [IconBtnComponent],
  template: `
    <app-icon-btn
      [iconBtnPropsInput]="btnProps"
      (btnClickOutput)="onBtnClick()"
    ></app-icon-btn>
  `,
})
class HostComponent {
  btnProps: IconBtnProps = { 
    name: 'Star', 
    icon: 'star', 
    disabled: false 
  };
  
  clicked = false;
  
  onBtnClick() {
    this.clicked = true;
  }
}

describe('IconBtnComponent', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

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

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should render icon', () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector('mat-icon')).toBeTruthy();
  });

  it('should emit btnClickOutput when clicked', () => {
    const btn = fixture.nativeElement.querySelector('button');
    btn.click();
    expect(host.clicked).toBeTrue();
  });

  it('should update when props change', () => {
    host.btnProps = { 
      name: 'Home', 
      icon: 'home', 
      disabled: true 
    };
    fixture.detectChanges();

    const btn = fixture.nativeElement.querySelector('button');
    expect(btn.disabled).toBeTrue();
  });
});

複雜的 Child Component 測試

@Component({
  standalone: true,
  imports: [SingleSelectorComponent, ReactiveFormsModule],
  template: `
    <app-single-selector
      [title]="title"
      [options]="options"
      [placeholder]="placeholder"
      [disabled]="disabled"
      [required]="required"
      [searchable]="searchable"
      (selectionChange)="onSelectionChange($event)"
      (searchChange)="onSearchChange($event)"
    ></app-single-selector>
  `,
})
class HostComponent {
  title = 'Test Selector';
  options = [
    { value: 'option1', text: 'Option 1' },
    { value: 'option2', text: 'Option 2' }
  ];
  placeholder = 'Please select';
  disabled = false;
  required = false;
  searchable = false;
  
  selectedValue: any = null;
  searchTerm = '';
  
  onSelectionChange(value: any) {
    this.selectedValue = value;
  }
  
  onSearchChange(term: string) {
    this.searchTerm = term;
  }
}

describe('SingleSelectorComponent Integration', () => {
  let fixture: ComponentFixture<HostComponent>;
  let host: HostComponent;

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

    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display title', () => {
    const titleElement = fixture.nativeElement.querySelector('.app-select-label');
    expect(titleElement.textContent).toContain('Test Selector');
  });

  it('should handle selection changes', () => {
    // 這裡需要實際操作 UI 來觸發 selectionChange
    // 具體實作取決於元件的互動方式
    expect(host.selectedValue).toBeNull();
  });

  it('should handle disabled state', () => {
    host.disabled = true;
    fixture.detectChanges();

    const formField = fixture.nativeElement.querySelector('mat-form-field');
    expect(formField.classList).toContain('mat-form-field-disabled');
  });

  it('should handle required state', () => {
    host.required = true;
    fixture.detectChanges();

    const requiredMark = fixture.nativeElement.querySelector('.required-mark');
    expect(requiredMark).toBeTruthy();
  });
});

Host Component 的設計模式

1. 最簡模式(推薦)

適用於基本功能測試,專注於「不會出錯」:

@Component({
  standalone: true,
  imports: [TargetDirective, RelatedModules],
  template: `<div targetDirective>Content</div>`,
})
class SimpleHostComponent {}

describe('TargetDirective', () => {
  it('should create', () => {
    expect(host).toBeTruthy();
  });

  it('should apply without errors', () => {
    const element = fixture.nativeElement.querySelector('div');
    expect(element).toBeTruthy();
  });
});

2. 屬性控制模式

適用於需要測試不同輸入組合:

@Component({
  standalone: true,
  imports: [TargetDirective],
  template: `
    <div 
      targetDirective
      [property1]="prop1"
      [property2]="prop2">
      Content
    </div>
  `,
})
class PropertyHostComponent {
  prop1 = 'default1';
  prop2 = false;
}

describe('TargetDirective Properties', () => {
  it('should handle property changes', () => {
    host.prop1 = 'updated';
    host.prop2 = true;
    fixture.detectChanges();
    
    // 驗證行為不會出錯
    expect(fixture.nativeElement.querySelector('div')).toBeTruthy();
  });
});

3. 事件監聽模式

適用於需要測試輸出事件:

@Component({
  standalone: true,
  imports: [TargetComponent],
  template: `
    <app-target
      (customEvent)="onCustomEvent($event)"
      (dataChange)="onDataChange($event)">
    </app-target>
  `,
})
class EventHostComponent {
  receivedEvent: any = null;
  receivedData: any = null;
  
  onCustomEvent(event: any) {
    this.receivedEvent = event;
  }
  
  onDataChange(data: any) {
    this.receivedData = data;
  }
}

describe('TargetComponent Events', () => {
  it('should emit custom events', () => {
    // 觸發事件的操作...
    expect(host.receivedEvent).not.toBeNull();
  });
});

4. 完整整合模式

適用於複雜的整合測試:

@Component({
  standalone: true,
  imports: [
    TargetComponent, 
    ReactiveFormsModule, 
    MatFormFieldModule
  ],
  template: `
    <form [formGroup]="testForm">
      <mat-form-field>
        <app-target
          formControlName="targetField"
          [options]="options"
          [disabled]="isDisabled"
          (selectionChange)="onSelection($event)">
        </app-target>
      </mat-form-field>
    </form>
  `,
})
class IntegrationHostComponent {
  testForm = this.fb.group({
    targetField: ['']
  });
  
  options = [/* test data */];
  isDisabled = false;
  
  constructor(private fb: FormBuilder) {}
  
  onSelection(value: any) {
    console.log('Selected:', value);
  }
}

Host Component vs 其他測試方式

與直接測試的比較

// ❌ 直接測試 - 複雜且不真實
describe('Direct Component Testing', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [TargetComponent, /* 所有依賴 */],
      providers: [/* 所有服務 */]
    });
  });
  
  it('should create', () => {
    const fixture = TestBed.createComponent(TargetComponent);
    // 需要手動設定所有 input
    // 需要手動處理所有依賴
    // 測試環境與真實使用差異很大
  });
});

// ✅ Host Component 測試 - 簡單且真實
describe('Host Component Testing', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HostComponent] // 只需要一行
    });
  });
  
  it('should create', () => {
    expect(host).toBeTruthy(); // 簡單直接
  });
});

與 TestBed.createComponent 的比較

// ❌ 複雜的 TestBed 設定
describe('Complex TestBed Setup', () => {
  let directiveInstance: MyDirective;
  
  beforeEach(() => {
    const mockElement = { nativeElement: document.createElement('div') };
    const mockDestroyRef = {};
    
    TestBed.configureTestingModule({
      providers: [
        { provide: ElementRef, useValue: mockElement },
        { provide: DestroyRef, useValue: mockDestroyRef }
      ]
    });
    
    TestBed.runInInjectionContext(() => {
      directiveInstance = new MyDirective(
        TestBed.inject(ElementRef),
        TestBed.inject(DestroyRef)
      );
    });
  });
});

// ✅ Host Component - 清晰簡潔
describe('Host Component Setup', () => {
  beforeEach(() => {
    fixture = TestBed.createComponent(HostComponent);
    host = fixture.componentInstance;
  });
});

Host Component 的優點

1. 真實性

// 模擬真實使用情境
template: `
  <mat-form-field>
    <mat-select appDirective>
      <mat-option>Option</mat-option>
    </mat-select>
  </mat-form-field>
`
// 這就是使用者實際會寫的程式碼

2. 簡潔性

// 一次設定,多次使用
class HostComponent {
  property1 = 'default';
  property2 = false;
}

// 測試中輕鬆修改
host.property1 = 'updated';
host.property2 = true;
fixture.detectChanges();

3. 完整性

// 包含完整的 Angular 生命週期
// - 依賴注入
// - 變更檢測
// - DOM 渲染
// - 事件處理

4. 可維護性

// 當 directive 或 component 的 API 改變時
// 只需要更新 Host Component 的 template
// 所有測試自動適應新的 API

最佳實踐

1. 保持 Host Component 簡單

// ✅ 好的做法
@Component({
  template: `<div appDirective [prop]="value">Content</div>`
})
class HostComponent {
  value = 'test';
}

// ❌ 避免過度複雜
@Component({
  template: `
    <div>
      <header>...</header>
      <main>
        <section>
          <div appDirective><!-- 被測試的內容埋得太深 --></div>
        </section>
      </main>
    </div>
  `
})
class OverComplexHostComponent {}

2. 專注於測試目標

// ✅ 專注測試 directive 行為
describe('MyDirective', () => {
  it('should apply directive without errors', () => {
    expect(host).toBeTruthy();
  });
  
  it('should handle property changes', () => {
    host.property = 'new value';
    fixture.detectChanges();
    expect(/* directive 相關驗證 */).toBeTruthy();
  });
});

// ❌ 避免測試無關的東西
describe('MyDirective', () => {
  it('should test Angular framework features', () => {
    // 不應該測試 Angular 本身的功能
  });
});

3. 使用有意義的測試名稱

// ✅ 清楚的測試描述
it('should apply directive without errors', () => {});
it('should handle input property changes', () => {});
it('should work with disabled state', () => {});

// ❌ 模糊的測試描述
it('should work', () => {});
it('should test functionality', () => {});

總結

Host Component 測試模式是現代 Angular 測試的最佳實踐,因為它:

  • 模擬真實使用情境 - 最接近實際開發場景

  • 提供完整的 Angular 環境 - 包含依賴注入、生命週期等

  • 簡化測試設定 - 避免複雜的 mock 和配置

  • 易於維護和理解 - 測試程式碼清晰直觀

  • 防止回歸錯誤 - 專注於「不會壞」的基本保證

這種測試方式特別適合團隊協作,因為它強調實用性和可維護性,而不是過度的技術複雜性。

Last updated