Directive 測試指南
Directive 測試的核心概念
為什麼 Directive 測試特殊?
Directive 與 Component 的根本差異在於:Directive 無法獨立存在,必須附加到宿主元素上才能運作。
錯誤示範 vs 正確方法
// ❌ 無法直接測試 Directive
describe('MyDirective', () => {
it('should create', () => {
const directive = new MyDirective(); // 錯誤:缺少宿主和依賴
expect(directive).toBeTruthy();
});
});
// ❌ 複雜的手動依賴注入
describe('MyDirective', () => {
it('should create', () => {
const mockElementRef = { nativeElement: document.createElement('div') };
const mockRenderer = jasmine.createSpyObj('Renderer2', ['addClass']);
const mockDestroyRef = jasmine.createSpyObj('DestroyRef', ['onDestroy']);
TestBed.runInInjectionContext(() => {
const directive = new MyDirective(mockElementRef, mockRenderer, mockDestroyRef);
// 還需要手動觸發生命週期、設定屬性等...
});
});
});
// ✅ 正確方法:使用 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 appMyDirective>Content</div>`
})
class HostComponent {}
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();
});
});
簡潔測試方法論:三步驟測試法
基本測試模板
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { YourDirective } from './your-directive';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@Component({
standalone: true,
imports: [YourDirective],
template: `
<div
yourDirective
[property1]="value1"
[property2]="value2">
Test Content
</div>
`,
})
class HostComponent {
value1 = 'default';
value2 = false;
}
describe('YourDirective', () => {
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();
});
// 步驟 1:建立測試
it('should create', () => {
expect(host).toBeTruthy();
});
// 步驟 2:套用測試
it('should apply directive without errors', () => {
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
// 驗證基本互動不會出錯
expect(() => {
element.click();
fixture.detectChanges();
}).not.toThrow();
});
// 步驟 3:屬性變更測試
it('should handle input property changes', () => {
host.value1 = 'updated';
host.value2 = true;
fixture.detectChanges();
// 驗證屬性變更不會造成錯誤
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
});
});
Host Component 設計模式
模式 1:簡單模式
適用於最基本的功能測試:
@Component({
standalone: true,
imports: [HighlightDirective],
template: `<div appHighlight>Highlighted Text</div>`,
})
class SimpleHostComponent {}
describe('HighlightDirective - Simple', () => {
let fixture: ComponentFixture<SimpleHostComponent>;
let host: SimpleHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SimpleHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(SimpleHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(host).toBeTruthy();
});
it('should apply without errors', () => {
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
});
});
模式 2:屬性控制模式
適用於需要測試不同 input 組合:
@Component({
standalone: true,
imports: [ColorHighlightDirective],
template: `
<div
appColorHighlight
[highlightColor]="color"
[isEnabled]="enabled"
[intensity]="intensity">
Highlighted Text
</div>
`,
})
class PropertyHostComponent {
color = 'yellow';
enabled = true;
intensity = 0.5;
}
describe('ColorHighlightDirective - Properties', () => {
let fixture: ComponentFixture<PropertyHostComponent>;
let host: PropertyHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PropertyHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(PropertyHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should handle color changes', () => {
host.color = 'red';
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
});
it('should handle enabled/disabled state', () => {
host.enabled = false;
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
});
it('should handle intensity changes', () => {
host.intensity = 1.0;
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
});
it('should handle multiple property changes', () => {
host.color = 'blue';
host.enabled = false;
host.intensity = 0.3;
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).toBeTruthy();
});
});
模式 3:事件處理模式
適用於需要測試輸出事件:
@Component({
standalone: true,
imports: [ClickTrackerDirective],
template: `
<button
appClickTracker
[trackingEnabled]="trackingEnabled"
[trackingData]="trackingData"
(trackingEvent)="onTrackingEvent($event)"
(errorEvent)="onErrorEvent($event)">
Click Me
</button>
`,
})
class EventHostComponent {
trackingEnabled = true;
trackingData = { page: 'test' };
lastTrackingEvent: any = null;
lastErrorEvent: any = null;
onTrackingEvent(event: any) {
this.lastTrackingEvent = event;
}
onErrorEvent(event: any) {
this.lastErrorEvent = event;
}
}
describe('ClickTrackerDirective - Events', () => {
let fixture: ComponentFixture<EventHostComponent>;
let host: EventHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EventHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(EventHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should handle click events when tracking enabled', () => {
host.trackingEnabled = true;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(button).toBeTruthy();
// 可選:驗證事件是否正確觸發
// expect(host.lastTrackingEvent).not.toBeNull();
});
it('should handle tracking disabled state', () => {
host.trackingEnabled = false;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(button).toBeTruthy();
});
it('should handle tracking data changes', () => {
host.trackingData = { page: 'updated', section: 'test' };
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button).toBeTruthy();
});
});
模式 4:真實環境模式
適用於與第三方函式庫整合測試:
@Component({
standalone: true,
imports: [
MatSelectKeyboardNavigationDirective,
MatSelectModule,
MatFormFieldModule,
ReactiveFormsModule,
],
template: `
<form [formGroup]="testForm">
<mat-form-field>
<mat-select
appMatSelectKeyboardNavigation
[searchable]="searchable"
[isMultipleSelector]="isMultiple"
[stickyHeaderHeight]="headerHeight"
formControlName="selection">
<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>
</form>
`,
})
class RealisticHostComponent {
searchable = false;
isMultiple = false;
headerHeight: number | null = null;
testForm = this.fb.group({
selection: ['']
});
constructor(private fb: FormBuilder) {}
}
describe('MatSelectKeyboardNavigationDirective - Integration', () => {
let fixture: ComponentFixture<RealisticHostComponent>;
let host: RealisticHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RealisticHostComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(RealisticHostComponent);
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();
expect(() => {
matSelect.click();
fixture.detectChanges();
}).not.toThrow();
});
it('should handle searchable mode', () => {
host.searchable = true;
fixture.detectChanges();
const matSelect = fixture.nativeElement.querySelector('mat-select');
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 work with reactive forms', () => {
host.testForm.patchValue({ selection: 'option1' });
fixture.detectChanges();
expect(host.testForm.get('selection')?.value).toBe('option1');
});
});
不同類型 Directive 的測試實例
屬性型 Directive (Attribute Directives)
修改元素外觀或行為的 directive:
// 假設有一個工具提示 directive
@Component({
standalone: true,
imports: [TooltipDirective],
template: `
<button
appTooltip
[tooltipText]="tooltipText"
[tooltipPosition]="position"
[tooltipDelay]="delay"
[disabled]="disabled">
Hover me
</button>
`,
})
class TooltipHostComponent {
tooltipText = 'This is a tooltip';
position = 'top';
delay = 500;
disabled = false;
}
describe('TooltipDirective', () => {
let fixture: ComponentFixture<TooltipHostComponent>;
let host: TooltipHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TooltipHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TooltipHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(host).toBeTruthy();
});
it('should handle tooltip text changes', () => {
host.tooltipText = 'Updated tooltip';
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button).toBeTruthy();
});
it('should handle position changes', () => {
host.position = 'bottom';
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button).toBeTruthy();
});
it('should handle disabled state', () => {
host.disabled = true;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button).toBeTruthy();
});
it('should handle mouse events', () => {
const button = fixture.nativeElement.querySelector('button');
expect(() => {
// 模擬滑鼠移入
button.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
// 模擬滑鼠移出
button.dispatchEvent(new MouseEvent('mouseleave'));
fixture.detectChanges();
}).not.toThrow();
});
});
結構型 Directive (Structural Directives)
修改 DOM 結構的 directive:
// 假設有一個權限控制 directive
@Component({
standalone: true,
imports: [PermissionDirective],
template: `
<div>
<div *appPermission="currentPermission">
Protected Content
</div>
<div *appPermission="'admin'; else: elseTemplate">
Admin Only Content
</div>
<ng-template #elseTemplate>
<div>No Permission</div>
</ng-template>
</div>
`,
})
class PermissionHostComponent {
currentPermission = 'user';
}
describe('PermissionDirective', () => {
let fixture: ComponentFixture<PermissionHostComponent>;
let host: PermissionHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PermissionHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(PermissionHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(host).toBeTruthy();
});
it('should handle permission changes', () => {
host.currentPermission = 'admin';
fixture.detectChanges();
const container = fixture.nativeElement;
expect(container).toBeTruthy();
});
it('should handle null permission', () => {
host.currentPermission = null as any;
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
it('should render else template when needed', () => {
// 這個測試可以檢查 DOM 結構,但保持簡單
const container = fixture.nativeElement;
expect(container.children.length).toBeGreaterThan(0);
});
});
表單相關 Directive
與表單驗證和處理相關的 directive:
@Component({
standalone: true,
imports: [CustomValidatorDirective, ReactiveFormsModule],
template: `
<form [formGroup]="testForm">
<input
type="email"
formControlName="email"
appCustomValidator
[validationRules]="emailRules"
[customMessages]="customMessages"
[validateOnBlur]="validateOnBlur">
<input
type="password"
formControlName="password"
appCustomValidator
[validationRules]="passwordRules">
</form>
`,
})
class FormValidatorHostComponent {
emailRules = ['required', 'email', 'minLength:5'];
passwordRules = ['required', 'minLength:8', 'hasNumber'];
customMessages = {
required: 'This field is required',
email: 'Please enter a valid email'
};
validateOnBlur = true;
testForm = this.fb.group({
email: [''],
password: ['']
});
constructor(private fb: FormBuilder) {}
}
describe('CustomValidatorDirective', () => {
let fixture: ComponentFixture<FormValidatorHostComponent>;
let host: FormValidatorHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormValidatorHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(FormValidatorHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(host).toBeTruthy();
});
it('should apply to form controls', () => {
const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
const passwordInput = fixture.nativeElement.querySelector('input[type="password"]');
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
});
it('should handle validation rule changes', () => {
host.emailRules = ['required', 'email'];
fixture.detectChanges();
const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
expect(emailInput).toBeTruthy();
});
it('should handle user input', () => {
const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
expect(() => {
emailInput.value = 'test@example.com';
emailInput.dispatchEvent(new Event('input'));
fixture.detectChanges();
}).not.toThrow();
});
it('should handle blur events', () => {
const emailInput = fixture.nativeElement.querySelector('input[type="email"]');
expect(() => {
emailInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
}).not.toThrow();
});
it('should handle form value changes', () => {
host.testForm.patchValue({
email: 'test@example.com',
password: 'password123'
});
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
});
進階測試技巧
異步操作處理
describe('AsyncDirective', () => {
it('should handle async operations', async () => {
const element = fixture.nativeElement.querySelector('div');
// 觸發異步操作
element.click();
fixture.detectChanges();
// 等待異步操作完成
await fixture.whenStable();
expect(element).toBeTruthy();
});
it('should handle timed operations', fakeAsync(() => {
const element = fixture.nativeElement.querySelector('div');
// 觸發定時操作
element.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
// 快進時間
tick(1000);
expect(element).toBeTruthy();
flush();
}));
});
錯誤處理和邊界測試
describe('DirectiveErrorHandling', () => {
it('should handle null input values', () => {
host.inputValue = null;
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
it('should handle undefined input values', () => {
host.inputValue = undefined;
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
it('should handle empty string values', () => {
host.inputValue = '';
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
it('should handle large data sets', () => {
host.largeDataSet = new Array(1000).fill(0).map((_, i) => ({ id: i, name: `Item ${i}` }));
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
it('should handle rapid property changes', () => {
// 模擬快速變更
for (let i = 0; i < 100; i++) {
host.rapidChangeValue = `value-${i}`;
fixture.detectChanges();
}
expect(host.rapidChangeValue).toBe('value-99');
});
});
與其他元件的整合測試
@Component({
standalone: true,
imports: [
YourDirective,
ChildComponent,
MatDialogModule,
MatButtonModule
],
template: `
<div appYourDirective [config]="directiveConfig">
<app-child-component
[data]="childData"
(childEvent)="onChildEvent($event)">
</app-child-component>
<button mat-button (click)="openDialog()">
Open Dialog
</button>
</div>
`,
})
class IntegrationHostComponent {
directiveConfig = { enabled: true, theme: 'dark' };
childData = { items: ['item1', 'item2'] };
onChildEvent(event: any) {
console.log('Child event received:', event);
}
openDialog() {
// 對話框邏輯
}
}
describe('YourDirective - Integration', () => {
let fixture: ComponentFixture<IntegrationHostComponent>;
let host: IntegrationHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IntegrationHostComponent, NoopAnimationsModule],
}).compileComponents();
fixture = TestBed.createComponent(IntegrationHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should work with child components', () => {
const childComponent = fixture.nativeElement.querySelector('app-child-component');
expect(childComponent).toBeTruthy();
});
it('should work with Material Design components', () => {
const button = fixture.nativeElement.querySelector('button[mat-button]');
expect(() => {
button.click();
fixture.detectChanges();
}).not.toThrow();
});
it('should handle complex config changes', () => {
host.directiveConfig = {
enabled: false,
theme: 'light',
animation: 'fade',
duration: 300
};
host.childData = { items: ['new1', 'new2', 'new3'] };
expect(() => {
fixture.detectChanges();
}).not.toThrow();
});
});
測試配置的實際範例
基本配置
// 最簡單的配置
describe('SimpleDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent]
}).compileComponents();
});
});
進階配置
// 需要額外服務和設定的配置
describe('ComplexDirective', () => {
let mockService: jasmine.SpyObj<DataService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('DataService', ['getData', 'updateData']);
await TestBed.configureTestingModule({
imports: [HostComponent, NoopAnimationsModule],
providers: [
{ provide: DataService, useValue: spy },
{ provide: CONFIG_TOKEN, useValue: { apiUrl: 'test-url' } }
]
}).compileComponents();
mockService = TestBed.inject(DataService) as jasmine.SpyObj<DataService>;
mockService.getData.and.returnValue(of([]));
});
});
全域設定配置
// 處理全域設定的配置
describe('GlobalDirective', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
providers: [
{
provide: LOCALE_ID,
useValue: 'zh-TW'
},
{
provide: MatIconRegistry,
useValue: {
addSvgIcon: () => {},
getNamedSvgIcon: () => of(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
}
}
]
}).compileComponents();
});
});
實用的測試輔助函數
測試輔助類別
class DirectiveTestHelper {
constructor(private fixture: ComponentFixture<any>) {}
clickElement(selector: string): void {
const element = this.fixture.nativeElement.querySelector(selector);
if (element) {
element.click();
this.fixture.detectChanges();
}
}
setInputValue(selector: string, value: string): void {
const input = this.fixture.nativeElement.querySelector(selector);
if (input) {
input.value = value;
input.dispatchEvent(new Event('input'));
this.fixture.detectChanges();
}
}
triggerEvent(selector: string, eventType: string, eventData?: any): void {
const element = this.fixture.nativeElement.querySelector(selector);
if (element) {
element.dispatchEvent(new Event(eventType, eventData));
this.fixture.detectChanges();
}
}
waitForAsync(): Promise<void> {
return this.fixture.whenStable();
}
}
// 使用範例
describe('DirectiveWithHelper', () => {
let helper: DirectiveTestHelper;
beforeEach(() => {
helper = new DirectiveTestHelper(fixture);
});
it('should handle user interactions', async () => {
helper.clickElement('button');
helper.setInputValue('input', 'test value');
await helper.waitForAsync();
expect(/* 驗證結果 */).toBeTruthy();
});
});
測試資料工廠
class DirectiveTestDataFactory {
static createBasicConfig() {
return {
enabled: true,
theme: 'default',
animation: true
};
}
static createComplexConfig() {
return {
enabled: true,
theme: 'dark',
animation: true,
duration: 300,
easing: 'ease-in-out',
triggers: ['click', 'hover'],
customStyles: {
backgroundColor: '#333',
color: '#fff'
}
};
}
static createLargeDataSet(size: number = 100) {
return Array.from({ length: size }, (_, i) => ({
id: i,
name: `Item ${i}`,
value: Math.random(),
active: i % 2 === 0
}));
}
}
// 使用範例
describe('DirectiveWithTestData', () => {
it('should handle basic config', () => {
host.config = DirectiveTestDataFactory.createBasicConfig();
fixture.detectChanges();
expect(/* 驗證 */).toBeTruthy();
});
it('should handle complex config', () => {
host.config = DirectiveTestDataFactory.createComplexConfig();
fixture.detectChanges();
expect(/* 驗證 */).toBeTruthy();
});
});
總結
透過這些詳細的程式碼範例,Directive 測試的關鍵要點:
核心模式
Host Component - 提供真實的 Angular 環境
三步驟測試法 - create、apply、property changes
適度測試 - 專注於不出錯和基本功能
實用技巧
輔助函數 - 簡化重複的測試操作
測試資料工廠 - 統一管理測試資料
分層配置 - 根據複雜度選擇適當的 TestBed 設定
最佳實踐
真實環境優先 - 使用接近實際使用的 Host Component
錯誤處理 - 測試邊界條件和異常情況
保持簡潔 - 避免過度複雜的測試邏輯
Last updated