Angular Directive Testing
Introduction
Angular directives are powerful features that allow you to extend HTML with custom functionality. Whether it's attribute directives that modify behavior or structural directives that manipulate the DOM, ensuring their reliability through testing is crucial for maintaining a robust application.
This guide will walk you through the process of testing Angular directives, from simple attribute directives to more complex structural ones. You'll learn how to set up test environments, create test components, and verify that your directives behave as expected.
Understanding Directive Testing
Directives in Angular can be categorized into:
- Attribute directives - Change the appearance or behavior of an element
- Structural directives - Add or remove elements from the DOM
Testing directives differs from testing services or components because directives are typically used within host components. This means we often need to create test host components to properly evaluate directive behavior.
Setting Up Your Testing Environment
Before diving into directive tests, make sure your testing environment is properly configured:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { YourDirective } from './your.directive';
Testing a Simple Attribute Directive
Let's start by testing a basic highlight directive that changes an element's background color when hovered.
Example: Highlight Directive
First, here's our directive implementation:
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
@Directive({
selector: '[appHighlight]'
})
export class HighlightDirective {
@Input('appHighlight') highlightColor = 'yellow';
constructor(private el: ElementRef) {}
@HostListener('mouseenter') onMouseEnter() {
this.highlight(this.highlightColor || 'yellow');
}
@HostListener('mouseleave') onMouseLeave() {
this.highlight(null);
}
private highlight(color: string | null) {
this.el.nativeElement.style.backgroundColor = color;
}
}
Creating a Test Host Component
To test our directive, we'll create a test host component:
@Component({
template: `
<div id="default" appHighlight>Default highlight</div>
<div id="custom" [appHighlight]="'orange'">Custom highlight</div>
`
})
class TestComponent {}
Writing the Tests
Now let's write tests for our directive:
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let defaultElement: DebugElement;
let customElement: DebugElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [HighlightDirective, TestComponent]
});
fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
defaultElement = fixture.debugElement.query(By.css('#default'));
customElement = fixture.debugElement.query(By.css('#custom'));
});
it('should apply yellow background on mouseenter for default color', () => {
defaultElement.triggerEventHandler('mouseenter', null);
fixture.detectChanges();
expect(defaultElement.nativeElement.style.backgroundColor).toBe('yellow');
});
it('should apply custom background on mouseenter for custom color', () => {
customElement.triggerEventHandler('mouseenter', null);
fixture.detectChanges();
expect(customElement.nativeElement.style.backgroundColor).toBe('orange');
});
it('should remove background on mouseleave', () => {
// First trigger enter to apply color
defaultElement.triggerEventHandler('mouseenter', null);
fixture.detectChanges();
// Then trigger leave to remove color
defaultElement.triggerEventHandler('mouseleave', null);
fixture.detectChanges();
expect(defaultElement.nativeElement.style.backgroundColor).toBe('');
});
});
Testing a Structural Directive
Structural directives like *ngIf
, *ngFor
, and custom ones modify the DOM structure. Let's examine how to test a simple "unless" directive (opposite of *ngIf
).
Example: Unless Directive
Here's our structural directive:
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[appUnless]'
})
export class UnlessDirective {
private hasView = false;
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef
) {}
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}
Creating a Test Host Component
For testing our structural directive:
@Component({
template: `
<div *appUnless="condition" id="target">This shows unless condition is true</div>
<button (click)="toggleCondition()">Toggle</button>
`
})
class UnlessTestComponent {
condition = false;
toggleCondition() {
this.condition = !this.condition;
}
}
Writing the Tests
Testing our structural directive:
describe('UnlessDirective', () => {
let fixture: ComponentFixture<UnlessTestComponent>;
let component: UnlessTestComponent;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [UnlessDirective, UnlessTestComponent]
});
fixture = TestBed.createComponent(UnlessTestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create an element when condition is false', () => {
component.condition = false;
fixture.detectChanges();
const targetElement = fixture.debugElement.query(By.css('#target'));
expect(targetElement).toBeTruthy();
});
it('should remove the element when condition is true', () => {
component.condition = true;
fixture.detectChanges();
const targetElement = fixture.debugElement.query(By.css('#target'));
expect(targetElement).toBeNull();
});
it('should handle condition changes correctly', () => {
// Start with false (element visible)
expect(fixture.debugElement.query(By.css('#target'))).toBeTruthy();
// Change to true (element should disappear)
component.toggleCondition();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('#target'))).toBeNull();
// Change back to false (element should reappear)
component.toggleCondition();
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('#target'))).toBeTruthy();
});
});
Testing Directives with Dependencies
Many real-world directives have dependencies on services or other Angular features. Let's look at how to test a more complex directive that depends on a service.
Example: Permission Directive
This directive shows or hides elements based on user permissions:
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { UserPermissionsService } from './user-permissions.service';
@Directive({
selector: '[appPermission]'
})
export class PermissionDirective implements OnInit {
@Input() appPermission: string[] = [];
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private userPermissions: UserPermissionsService
) {}
ngOnInit() {
const permissions = this.appPermission || [];
if (this.userPermissions.hasPermissions(permissions)) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
}
}
Setting Up Mock Services
For this test, we need to mock the UserPermissionsService
:
class MockUserPermissionsService {
permissions: string[] = ['edit', 'view'];
hasPermissions(permissions: string[]): boolean {
return permissions.every(p => this.permissions.includes(p));
}
}
@Component({
template: `
<div *appPermission="['edit']" id="edit-div">Has edit permission</div>
<div *appPermission="['admin']" id="admin-div">Has admin permission</div>
<div *appPermission="['edit', 'view']" id="multi-div">Has multiple permissions</div>
`
})
class PermissionTestComponent {}
Writing the Tests
Now let's test our permission directive:
describe('PermissionDirective', () => {
let fixture: ComponentFixture<PermissionTestComponent>;
let mockPermissionsService: MockUserPermissionsService;
beforeEach(() => {
mockPermissionsService = new MockUserPermissionsService();
TestBed.configureTestingModule({
declarations: [PermissionDirective, PermissionTestComponent],
providers: [
{ provide: UserPermissionsService, useValue: mockPermissionsService }
]
});
fixture = TestBed.createComponent(PermissionTestComponent);
fixture.detectChanges();
});
it('should show elements when user has required permissions', () => {
const editDiv = fixture.debugElement.query(By.css('#edit-div'));
const multiDiv = fixture.debugElement.query(By.css('#multi-div'));
expect(editDiv).toBeTruthy();
expect(multiDiv).toBeTruthy();
});
it('should hide elements when user lacks permissions', () => {
const adminDiv = fixture.debugElement.query(By.css('#admin-div'));
expect(adminDiv).toBeNull();
});
it('should update view when permissions change', () => {
// Initially admin div is not visible
expect(fixture.debugElement.query(By.css('#admin-div'))).toBeNull();
// Add admin permission
mockPermissionsService.permissions.push('admin');
// Re-create component to trigger directive initialization
fixture = TestBed.createComponent(PermissionTestComponent);
fixture.detectChanges();
// Now admin div should be visible
expect(fixture.debugElement.query(By.css('#admin-div'))).toBeTruthy();
});
});
Real-world Example: Testing a Tooltip Directive
Let's look at a more complete example of testing a tooltip directive that displays information when hovering over an element:
import { Directive, ElementRef, HostListener, Input, Renderer2 } from '@angular/core';
@Directive({
selector: '[appTooltip]'
})
export class TooltipDirective {
@Input('appTooltip') tooltipText = '';
@Input() tooltipPosition = 'top';
private tooltipElement: HTMLElement | null = null;
constructor(private el: ElementRef, private renderer: Renderer2) {}
@HostListener('mouseenter') onMouseEnter() {
this.showTooltip();
}
@HostListener('mouseleave') onMouseLeave() {
this.hideTooltip();
}
private showTooltip() {
this.tooltipElement = this.renderer.createElement('div');
const text = this.renderer.createText(this.tooltipText);
this.renderer.appendChild(this.tooltipElement, text);
this.renderer.addClass(this.tooltipElement, 'tooltip');
this.renderer.addClass(this.tooltipElement, `tooltip-${this.tooltipPosition}`);
this.renderer.appendChild(document.body, this.tooltipElement);
// Position the tooltip
const hostPos = this.el.nativeElement.getBoundingClientRect();
const tooltipPos = this.tooltipElement.getBoundingClientRect();
let top, left;
if (this.tooltipPosition === 'top') {
top = hostPos.top - tooltipPos.height - 10;
left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
} else if (this.tooltipPosition === 'bottom') {
top = hostPos.bottom + 10;
left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
} else if (this.tooltipPosition === 'left') {
top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
left = hostPos.left - tooltipPos.width - 10;
} else {
top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
left = hostPos.right + 10;
}
this.renderer.setStyle(this.tooltipElement, 'top', `${top}px`);
this.renderer.setStyle(this.tooltipElement, 'left', `${left}px`);
this.renderer.setStyle(this.tooltipElement, 'position', 'absolute');
}
private hideTooltip() {
if (this.tooltipElement) {
this.renderer.removeChild(document.body, this.tooltipElement);
this.tooltipElement = null;
}
}
}
Test Host Component
@Component({
template: `
<div id="default-tooltip"
appTooltip="Default tooltip">
Hover for default tooltip
</div>
<div id="positioned-tooltip"
[appTooltip]="'Custom tooltip'"
[tooltipPosition]="'right'">
Hover for positioned tooltip
</div>
`
})
class TooltipTestComponent {}
Writing the Tests
describe('TooltipDirective', () => {
let fixture: ComponentFixture<TooltipTestComponent>;
let defaultElement: DebugElement;
let positionedElement: DebugElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TooltipDirective, TooltipTestComponent]
});
fixture = TestBed.createComponent(TooltipTestComponent);
fixture.detectChanges();
defaultElement = fixture.debugElement.query(By.css('#default-tooltip'));
positionedElement = fixture.debugElement.query(By.css('#positioned-tooltip'));
});
it('should create tooltip on mouseenter with correct text', () => {
defaultElement.triggerEventHandler('mouseenter', null);
const tooltip = document.querySelector('.tooltip');
expect(tooltip).toBeTruthy();
expect(tooltip?.textContent).toBe('Default tooltip');
});
it('should create tooltip with custom position', () => {
positionedElement.triggerEventHandler('mouseenter', null);
const tooltip = document.querySelector('.tooltip');
expect(tooltip).toBeTruthy();
expect(tooltip?.classList).toContain('tooltip-right');
});
it('should remove tooltip on mouseleave', () => {
// First trigger mouseenter to create tooltip
defaultElement.triggerEventHandler('mouseenter', null);
expect(document.querySelector('.tooltip')).toBeTruthy();
// Then trigger mouseleave to remove tooltip
defaultElement.triggerEventHandler('mouseleave', null);
expect(document.querySelector('.tooltip')).toBeNull();
});
// Clean up after tests to avoid tooltips persisting between tests
afterEach(() => {
// Remove any tooltips that might have been created
const tooltip = document.querySelector('.tooltip');
if (tooltip) {
document.body.removeChild(tooltip);
}
});
});
Testing Best Practices for Directives
When testing Angular directives, keep these best practices in mind:
- Use test host components - Create components that use your directives in realistic scenarios
- Test with different inputs - Verify that your directive behaves correctly with various input values
- Test dynamic changes - Check that your directive responds correctly when inputs change
- Test event handling - Verify that your directive responds to events correctly
- Mock dependencies - Isolate your directive tests by providing mock implementations of services
- Clean up after tests - Especially important for directives that modify the DOM outside their host component
Debugging Directive Tests
When your directive tests fail, these strategies can help:
- Inspect the DOM - Use
fixture.debugElement.nativeElement.outerHTML
to see the current state of the DOM - Check element properties - Verify that styles and attributes are applied correctly
- Console.log in tests - Add temporary debug output to see what's happening
- Use browser devtools - Run tests with
ng test --browsers=Chrome
to use browser debugging tools
Summary
Testing Angular directives requires a different approach than testing components or services. By creating test host components and verifying the effects of your directives on the DOM, you can ensure they behave correctly.
We've covered:
- Setting up directive tests with TestBed
- Testing attribute directives
- Testing structural directives
- Testing directives with dependencies
- A real-world example with a tooltip directive
- Best practices for directive testing
With these techniques, you can thoroughly test your Angular directives and ensure they behave as expected in all scenarios.
Additional Resources
- Angular Testing Guide
- Angular Testing Directives Documentation
- GitHub repository with directive testing examples
Exercises
- Create and test a simple highlight directive that changes text color instead of background color
- Create and test a structural directive that implements a simple "repeat" functionality (like
*ngFor
but simpler) - Create and test a directive that validates input fields and shows error messages
- Create and test a directive that implements a lazy loading image strategy (shows placeholder until image loads)
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)