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